From b7940009a9f9c40dd831cb63e405da1029ad0eb1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:10:50 +0000 Subject: [PATCH 01/50] chore(deps): bump node in the docker-images group Bumps the docker-images group with 1 update: node. Updates `node` from 24-bookworm-slim to 26-bookworm-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-bookworm-slim dependency-type: direct:production dependency-group: docker-images ... Signed-off-by: dependabot[bot] --- Dockerfile | 2 +- Dockerfile.worker | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index a78519ab9..87e2e89d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ # NEVER baked into the image — inject them at run time from the host's # secret store. -FROM node:24-bookworm-slim@sha256:235600a8101ab264e117b1768e925532262668dc9b581ef1dd7d96ced463b8e7 AS node-base +FROM node:26-bookworm-slim@sha256:367679cf9792759492a486e4aa4b421764d71a9546a6dae8aab81a99eb797b3e AS node-base FROM node-base AS deps WORKDIR /app diff --git a/Dockerfile.worker b/Dockerfile.worker index 52f0644a6..d2fc496af 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -27,7 +27,7 @@ # `server-only` marker to the standalone stub at build time (the job # run-tsx.mjs previously did at runtime) and keeps npm packages external, # so the bundle resolves them from the runner's production node_modules. -FROM node:24-bookworm-slim@sha256:235600a8101ab264e117b1768e925532262668dc9b581ef1dd7d96ced463b8e7 AS node-base +FROM node:26-bookworm-slim@sha256:367679cf9792759492a486e4aa4b421764d71a9546a6dae8aab81a99eb797b3e AS node-base FROM node-base AS build WORKDIR /app From 33a7bd452c1bf4e69490e5645f863f25cf5e154c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:22:34 +0000 Subject: [PATCH 02/50] feat(brand): draw the small-size optical cut at chrome sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up recorded in PR #2462 and deliberately left undone there, now that the artwork decision has been made: the app rendered the mark's *display* geometry at every size, including the 28-32px chrome where the brand's own construction calls for a different cut. **Why this is the real fix.** The mark is a construction, not a bitmap, so it does not simply get smaller. Two pieces of its negative space close up first — the 4.2-unit cut between the strokes and the 7.26-unit crescent around the point — and when the crescent closes, the dot fuses into the S. That fuses the top of the mark into one solid blob above a lower stroke that is already the thinner read, which is exactly the top-heavy look this branch previously compensated for with the deeper `--brand-mark-emphasis` ink. The ink stays; the cause is now addressed rather than offset. `docs/brand/psychsift-logo.md` §"Small sizes" already specifies the remedy and `brand-mark.ts` already ships it: `BRAND_STROKE_PATH_SMALL` opens the cut to 7.2 units and `BRAND_POINT_SMALL` slides the point 10 units out of its cradle, opening the crescent from 7.26 to 11.55. Only the favicon and the raster icon routes used them. Nothing in the app did. **The one thing that did not exist, and how it was derived rather than drawn.** The bare (untiled) glyph had no small-size placement. `brand-mark.ts` says in terms not to redraw the construction by hand, and this does not: the doc fixes both ink boxes (display spans 0-55.33, chrome 0-65.33, both 0-100.38 tall) and every committed placement centres that box in the 512 viewBox — verified against all three, each landing on 256.000 within 0.003, the rounding of a two-decimal box figure. Two consequences follow arithmetically. The point keeps its `cy` and `r`, so the vertical extent is identical and the scale is unchanged at 5.1006 (100.3813 x 5.1006 = 512.0, which is the bare variant's whole purpose). The point moves exactly 10 units and is the box's right edge, so the box widens by 10 and its centre by 5: 114.8907 - 5 x 5.1006 = 89.3877. The same arithmetic reproduces the committed tiled pair (143.1125 - 5 x 4.0804 = 122.7103, as shipped), which is the check that this is the rule actually in use rather than one that merely fits. **Threshold.** 32px, from the doc's own "at 32 px and below two things close up". Sub-32px production sites now draw the chrome cut: the collapsed sidebar rail (28px), the phone drawer header (28/32px), the account-setup workspace mark (32px), and the ward sidebar glyph (32px). Everything at 36px and above keeps the display cut. Design-scratch mockups are untouched. The three pieces are selected together through one `brandMarkOptics()` call, so a caller cannot half-apply the variant — the doc is explicit that mixing one variant's point with the other's placement puts the glyph off-centre. Verification - `tests/brand-mark-optics.test.ts` — 14 passed. Every rule mutation-tested with the failure predicted before running: dropping `optical="chrome"` from a 28px site, adding it to a 40px one, shifting the new transform 5px off centre, and pairing the chrome placement with the display point each failed exactly the intended assertion; all four restored byte-identical. - Writing that test surfaced a defect in the test itself, fixed before it could mislead: `ward-management-navigation.tsx` imports two CSS modules that BOTH define `.brandGlyph`, at 2.5rem and 2rem, so an identifier-blind resolver merged them and reported real sites as spanning the threshold. It now resolves the specific import. It also caught `sidebar-live-mockup.tsx` — design scratch named with the singular `-mockup`, which the usual `mockups` filter misses. - `npm run brand:check` — `src/app/icon.svg matches the brand-mark source` (this adds exports; it changes no existing value, so the generated assets are untouched). - Design-system contract passed, every ratchet at or under baseline; adoption 55 components / 84 roots; design-sync contract 55 components / 7 guidelines. - Full unit suite: `11744 passed | 1 skipped` across 931 files. Three pre-existing failures in `clinical-hazard-controls`, `privacy-readiness-contract` and `rag-plan-package-parity` — none touches brand geometry, and CI's Unit coverage passed these on the previous PR, so they are container-local. - Lint clean at `--max-warnings 0`; typecheck exit 0, unscoped; whole-tree format committed with the work. RAG impact: no retrieval behaviour change - this diff touches no file under src/lib/rag/** and none of the protected ranking surfaces (clinical-search, retrieval-selection, released-search-order, ranking-config, answer-ranking, answer-verification), adds no comparator key, and leaves the golden fixture and eval harness untouched. Verified by listing the diff's paths against that set. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YRGeyyiZNrXnFJjVVnykgp --- .../clinical-dashboard/ClinicalSidebar.tsx | 12 +- .../account-setup-dialog.tsx | 2 +- src/components/clinical-dashboard/brand.tsx | 45 ++-- .../ward-management-navigation.tsx | 4 +- .../ward-management/ward-sidebar-content.tsx | 2 +- src/lib/brand-mark.ts | 50 +++++ tests/brand-mark-optics.test.ts | 192 ++++++++++++++++++ 7 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 tests/brand-mark-optics.test.ts diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 73c2c298a..5d9966ec2 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -936,14 +936,14 @@ function ClinicalCollapsedRail({
{collapseLocked ? ( ) : ( <> {/* Tablet: the expanded panel does not exist below lg, so show a static brand mark instead of a dead expand control. */} - {sourceOnlyNoticeOpen ? ( -
setSourceOnlyNoticeOpen((current) => !current)} + // Compact-meta disclosure (not a primary CTA), TOKENS.md §2 "disclosure" + // row: 40px `--spacing-compact-meta`, the floor the service owner ruled + // acceptable for named compact roles on 2026-08-29. It was `min-h-7` + // (28px), 12px under even that floor. The `::before` hit-expansion its + // DocumentTagCloud siblings use is unavailable here: the wrapping + // `
` is `overflow-hidden` (it clips the detail block to the + // pill radius), and overflow clipping removes the expanded region from + // hit testing as well as from paint — the classes would have read as + // compliant while expanding nothing. + className="inline-flex min-h-compact-meta w-full max-w-[68ch] items-center gap-1 px-2 py-0.5 text-left transition hover:bg-[color:var(--warning-soft)]/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]" + aria-expanded={sourceOnlyNoticeOpen} + aria-controls={sourceOnlyDetailId} > -

{compactVerificationWordingFor(sourceOnlyVerificationState, "extractive")}

-
- ) : null} - - ) : null} -
+ + Source-only + + · verify passages + + + + {sourceOnlyNoticeOpen ? ( +
+

{compactVerificationWordingFor(sourceOnlyVerificationState, "extractive")}

+
+ ) : null} + + ) : null} + {answerState?.kind === "stale_evidence" && onOpenStateSource ? ( + + ) : null} + + ) : null} -
- -
+ {showCopyAction ? ( +
+ +
+ ) : null} ); diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index c350df429..44eae503b 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -15,6 +15,7 @@ import { import { answerStateForAnswer } from "@/components/clinical-dashboard/answer-copy-payload"; import { AnswerSupportSummaryCard, + AnswerUtilityActions, answerSupportPriority, primaryVisualTable, SafetyFindingsListContent, @@ -199,6 +200,11 @@ function StagedAnswerResultSurfaceImpl({ // collapsed interactive disclosure is not part of the printed record. className: sourceOnly ? "hidden print:flex" : undefined, }; + function openAnswerStateSource(sourceId: string, locator?: string) { + const href = citedDocumentHref(sourceId, locator, [...sources, ...(answer.sources ?? [])]); + if (href) router.push(href); + } + const answerProse = ( ); /** * The support card is now the answer-level strip and nothing else: the safety * priority row (its trigger is the only route to the safety sheet), the - * evidence gaps that belong to the answer rather than to any one document, and - * the feedback control. Everything per-source moved to the rail and drawer. - * Mount when any of those rows would render — a clean high-trust answer still - * needs the "Report a problem" path. + * Everything per-source moved to the rail and drawer. Evidence gaps and + * feedback now sit beside Copy with sources as answer utilities, so this + * surface mounts only when there is an actual safety/priority row. */ - const showInlineSupportCard = Boolean(priority || renderModel.warnings.length > 0 || onSubmitFeedback); + const showInlineSupportCard = Boolean(priority); return (
@@ -270,7 +278,13 @@ function StagedAnswerResultSurfaceImpl({ reader nothing. */} {answerState.kind === "ready" ? ( - + {answerProse} ) : ( @@ -279,41 +293,41 @@ function StagedAnswerResultSurfaceImpl({ verification={answerVerification} support={answerSupport} frame="bare" + retrievalStatePlacement={answerState.kind === "stale_evidence" ? "content" : "header"} // Navigate to the cited page — do not reuse onScopeDocument. That // handler only replaces selectedDocumentIds and leaves the clinician // on the answer screen with a silent filter change while the button // is labelled "Open , p. N". - onOpenSource={(sourceId, locator) => { - const href = citedDocumentHref(sourceId, locator, [...sources, ...(answer.sources ?? [])]); - if (href) router.push(href); - }} + onOpenSource={openAnswerStateSource} > {answerProse} )} + + {showInlineSupportCard ? ( 0 ? openSafetyFindings : undefined} - pendingFeedback={pendingFeedback} - onSubmitFeedback={onSubmitFeedback} - // Chat framing: safety keeps its row, the other two collapse to - // one line of buttons rather than two 56px stacked rows. - density="compact" /> ) : null} - {/* Follow-ups first, and on every width. They were wrapped in - `hidden sm:block`, so the most likely next tap on this surface - was desktop-only — the phone got the two "where else does this - appear" panels and none of the questions. Asking the next - question is the clinical step; browsing the library is not, so - the questions lead and the matches collapse to one line beneath - them (owner decision, 2026-08-26, "direction B"). */} + {/* Related library context belongs with the answer's evidence and + safety stack. Keep it collapsed and place it before the next-turn + prompts so it is not stranded beneath the composer. */} + {crossModeQueries?.length && onCrossModeSearch ? ( + + ) : null} + {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( ) : null} - - {crossModeQueries?.length && onCrossModeSearch ? ( - - ) : null}
diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx index de00efc64..d125156a5 100644 --- a/src/components/clinical-dashboard/cross-mode-links.tsx +++ b/src/components/clinical-dashboard/cross-mode-links.tsx @@ -190,9 +190,9 @@ export function CrossModeLinksSection({ * Under an answer this block used to be a permanently expanded rail sitting * directly above a second, near-identical panel of mode matches. Two panels * asking the same question ("where else does this appear") read as one panel - * repeated, and neither is the clinician's next step — the follow-up questions - * above them are. So this collapses to a single row carrying a preview of what - * is inside, and opens to exactly the rail it always was. + * repeated. This collapses to a single row carrying a preview of what is inside, + * closes the answer's evidence/safety stack, and opens to exactly the rail it + * always was before the follow-up conversation begins. * * The preview names come from the resolved links, so the line can never * advertise a match the expanded rail does not list. diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 67bee4f1c..98e64b63b 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -7,6 +7,7 @@ import { CircleAlert, CircleCheck, ChevronDown, + ChevronRight, ClipboardCheck, Copy, ExternalLink, @@ -42,6 +43,8 @@ import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text" import { SourceActionRow, logCitationOpen } from "@/components/clinical-dashboard/source-actions"; import { clinicalDivider, + chatActionRow, + chatMicroAction, cn, codeText, EmptyState, @@ -170,263 +173,175 @@ export function answerSupportPriority( } /** - * The answer-level strip under the prose. + * Quiet answer-level utilities under the source rail. * - * Since the source rail and drawer took over every per-source surface, this card - * carries only what belongs to the answer rather than to any one document: the - * safety/priority row, the evidence gaps, and the feedback control. - * - * The safety row is not optional chrome. `answerSupportPriority` returns a - * safety-findings priority ahead of everything else, and the trigger below is - * the only route to the safety-critical findings sheet — so this card renders - * whenever `priority` is set, and removing it would remove that route. + * Evidence gaps and feedback belong to the answer rather than one document, but + * they are utilities rather than safety findings. Keeping them beside Copy with + * sources stops the safety panel's warning chrome from colouring neutral actions. */ -export function AnswerSupportSummaryCard({ - priority, +export function AnswerUtilityActions({ + copied, + onCopy, warnings = [], - safetyTriggerRef, - safetyFindingsCount = 0, - onOpenSafetyFindings, pendingFeedback = null, onSubmitFeedback, - density = "comfortable", }: { - priority: AnswerSupportPriority | null; + copied: boolean; + onCopy: () => void; /** Answer-level evidence gaps (`renderModel.warnings`); they belong to no single source. */ warnings?: string[]; - safetyTriggerRef?: RefObject; - safetyFindingsCount?: number; - onOpenSafetyFindings?: () => void; pendingFeedback?: AnswerFeedbackType | null; onSubmitFeedback?: (feedbackType: AnswerFeedbackType) => void; - /** - * `"compact"` is the chat-framed answer's density. Safety keeps its full row — - * it is the one thing here that must not be tucked into a chip — while the - * evidence-gaps and report rows collapse from two 56px two-line rows into one - * line of small buttons. Same controls, same ids, same routes; roughly 90px of - * phone scroll returned under a four-line answer. - */ - density?: "comfortable" | "compact"; }) { const [gapsOpen, setGapsOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); - const compact = density === "compact"; - const supportRowCount = Number(warnings.length > 0) + Number(Boolean(onSubmitFeedback)); - const supportButtonClass = - "grid min-h-[56px] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 px-3 py-2 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; - // min-h-12 (48px), not min-h-11: 44px reintroduced a known sub-pixel rounding - // flake in `ui-smoke`. See AGENTS.md "External skill precedence". - const compactChipClass = - "inline-flex min-h-12 items-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; - const safetyInteractive = Boolean(onOpenSafetyFindings && safetyFindingsCount > 0); - const gapsDetail = - warnings.length > 0 && gapsOpen ? ( -
- {warnings.map((warning, index) => ( -

- {warning} -

- ))} -
- ) : null; - const feedbackDetail = - onSubmitFeedback && feedbackOpen ? ( -
- -
- ) : null; - return ( -
- {priority ? ( - safetyInteractive ? ( +
+
+ + {warnings.length > 0 ? ( - ) : ( -
-
+ {warnings.length > 0 && gapsOpen ? ( +
+ {warnings.map((warning, index) => ( +

- {priority.tone === "caution" ? ( -

-

{priority.title}

-

{priority.detail}

-
- {priority.sourceLabel ? ( - - {priority.sourceLabel} - - ) : null} -
- ) + {warning} +

+ ))} +
) : null} + {onSubmitFeedback && feedbackOpen ? ( +
+ +
+ ) : null} +
+ ); +} - {supportRowCount > 0 ? ( - compact ? ( - // One line of small buttons instead of two stacked 56px rows. The - // detail each one opens is unchanged and still carries the same id, so - // `aria-controls`, the feedback route and the gap wording all survive - // the density change. -
-
- {warnings.length > 0 ? ( - - ) : null} - {onSubmitFeedback ? ( - +export function AnswerSupportSummaryCard({ + priority, + safetyTriggerRef, + safetyFindingsCount = 0, + onOpenSafetyFindings, +}: { + priority: AnswerSupportPriority | null; + safetyTriggerRef?: RefObject; + safetyFindingsCount?: number; + onOpenSafetyFindings?: () => void; +}) { + // The safety row is not optional chrome. `answerSupportPriority` returns a + // safety finding ahead of everything else, and this trigger is the only route + // to the safety-critical findings sheet. + if (!priority) return null; + const safetyInteractive = Boolean(onOpenSafetyFindings && safetyFindingsCount > 0); + const rowClass = "grid min-h-12 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-1.5 text-left"; + + return ( +
+ {safetyInteractive ? ( +
- {gapsDetail} - {feedbackDetail} -
- ) : ( -
- {warnings.length > 0 ? ( -
- - {gapsDetail} -
- ) : null} - {onSubmitFeedback ? ( -
- - {feedbackDetail} -
- ) : null} + {priority.detail} + + + + {safetyFindingsCount} + + + ) : ( +
+ +
+

{priority.title}

+

{priority.detail}

- ) - ) : null} + {priority.sourceLabel ? ( + + {priority.sourceLabel} + + ) : null} +
+ )}
); } diff --git a/src/components/ui/answer-card.tsx b/src/components/ui/answer-card.tsx index f5a8e5af2..af0ff782a 100644 --- a/src/components/ui/answer-card.tsx +++ b/src/components/ui/answer-card.tsx @@ -93,6 +93,13 @@ type AnswerCardBase = { * around them goes. Adopted for the answer surface 2026-08-25. */ frame?: "raised" | "bare"; + /** + * Keeps the shared card safe by default while allowing the live answer + * surface to place source-currency controls beside its source-only disclosure. + * The content owner must render the same state and source route when it opts + * into `"content"`. + */ + retrievalStatePlacement?: "header" | "content"; className?: string; }; @@ -117,6 +124,7 @@ export function AnswerCard({ actions, onOpenSource, frame = "raised", + retrievalStatePlacement = "header", className, }: AnswerCardProps) { const bare = frame === "bare"; @@ -194,7 +202,8 @@ export function AnswerCard({ * `onOpenSource` stays required for every degraded state (DECISIONS §Q1): a * degraded answer must remain re-verifiable whether or not a banner renders. */} - {state.kind === "stale_evidence" || state.kind === "partial_retrieval" ? ( + {retrievalStatePlacement === "header" && + (state.kind === "stale_evidence" || state.kind === "partial_retrieval") ? (
setOpen((current) => !current)} aria-expanded={open} aria-controls={detailId} - className="inline-flex min-h-tap max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 text-left transition hover:bg-[color:var(--warning-soft)]/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]" + className="inline-flex min-h-compact-meta max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-left transition hover:bg-[color:var(--warning-soft)]/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]" > Review due diff --git a/tests/answer-source-marks.dom.test.tsx b/tests/answer-source-marks.dom.test.tsx index 6d0755dcc..0e5e45c7d 100644 --- a/tests/answer-source-marks.dom.test.tsx +++ b/tests/answer-source-marks.dom.test.tsx @@ -367,11 +367,86 @@ describe("source-only disclosure", () => { expect(disclosure).toHaveTextContent("Source-only"); expect(disclosure).not.toHaveTextContent("Copied from cited sources without model synthesis"); expect(disclosure.className).toContain("text-2xs"); - expect(disclosure.parentElement?.className).toContain("py-1"); + expect(disclosure.parentElement?.className).not.toContain("py-1"); await user.click(within(disclosure).getByRole("button", { name: /Source-only/ })); expect(disclosure).toHaveTextContent( "Copied from cited sources without model synthesis. Sources could not be shown to support every claim. Check each dose, number, timing and threshold before acting.", ); }); + + it("places review-due status beside Source-only and keeps the cited-page route", async () => { + const user = userEvent.setup(); + const onOpenStateSource = vi.fn(); + render( + , + ); + + const row = screen.getByTestId("answer-source-status-row"); + expect(within(row).getByTestId("source-only-disclosure")).toBeInTheDocument(); + const reviewDue = within(row).getByTestId("retrieval-state-stale-toggle"); + expect(reviewDue).toHaveTextContent(/Review due\s*· 1 source/); + await user.click(reviewDue); + await user.click(screen.getByRole("button", { name: "Open Clozapine monitoring protocol, p. 8" })); + expect(onOpenStateSource).toHaveBeenCalledWith("doc-chunk-a", "p. 8"); + }); + + it("keeps review-due status in the source row when the answer is synthesized", () => { + render( + , + ); + + const row = screen.getByTestId("answer-source-status-row"); + expect(within(row).queryByTestId("source-only-disclosure")).not.toBeInTheDocument(); + expect(within(row).getByTestId("retrieval-state-stale-toggle")).toHaveTextContent(/Review due\s*· 1 source/); + }); }); diff --git a/tests/answer-source-rail.dom.test.tsx b/tests/answer-source-rail.dom.test.tsx index d8c019065..4d32b8758 100644 --- a/tests/answer-source-rail.dom.test.tsx +++ b/tests/answer-source-rail.dom.test.tsx @@ -48,7 +48,7 @@ vi.mock("@/components/clinical-dashboard/signed-image", () => ({ ), })); -import { AnswerSupportSummaryCard } from "@/components/clinical-dashboard/evidence-panels"; +import { AnswerUtilityActions } from "@/components/clinical-dashboard/evidence-panels"; import { AnswerSourceDrawer } from "@/components/clinical-dashboard/answer-source-drawer"; import { AnswerSourceRail } from "@/components/clinical-dashboard/answer-source-rail"; import { @@ -394,11 +394,12 @@ describe("answer source drawer", () => { }); describe("evidence gaps stay answer-level", () => { - it("lists the answer's warnings on the card rather than against any one source", async () => { + it("lists the answer's warnings with the quiet answer utilities rather than against any one source", async () => { const user = userEvent.setup(); render( - , diff --git a/tests/answer-support-priority.dom.test.tsx b/tests/answer-support-priority.dom.test.tsx index 8c2b1c859..e608d85f6 100644 --- a/tests/answer-support-priority.dom.test.tsx +++ b/tests/answer-support-priority.dom.test.tsx @@ -4,7 +4,7 @@ import { resolve } from "node:path"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { AnswerSupportSummaryCard, answerSupportPriority } from "@/components/clinical-dashboard/evidence-panels"; +import { AnswerUtilityActions, answerSupportPriority } from "@/components/clinical-dashboard/evidence-panels"; import type { AnswerState } from "@/components/ui/answer-state"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import type { RagAnswer } from "@/lib/types"; @@ -134,26 +134,27 @@ describe("answerSupportPriority · Review source match", () => { }); }); -describe("AnswerSupportSummaryCard · feedback on a clean answer", () => { - it("still hosts Report a problem when priority and warnings are both empty", () => { +describe("AnswerUtilityActions · feedback on a clean answer", () => { + it("keeps Report a problem beside Copy with sources when priority and warnings are both empty", () => { render( - undefined} warnings={[]} pendingFeedback={null} onSubmitFeedback={() => undefined} />, ); + expect(screen.getByRole("button", { name: "Copy answer with source status" })).toBeInTheDocument(); expect(screen.getByTestId("answer-feedback-trigger")).toBeInTheDocument(); }); - it("the answer surface mounts that card for feedback even without priority or warnings", () => { + it("the answer surface mounts utilities independently and reserves the support card for a real priority", () => { const surface = readFileSync( resolve(process.cwd(), "src/components/clinical-dashboard/answer-result-surface.tsx"), "utf8", ); - expect(surface).toMatch( - /showInlineSupportCard = Boolean\(priority \|\| renderModel\.warnings\.length > 0 \|\| onSubmitFeedback\)/, - ); + expect(surface).toContain(" { await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: new URL(page.url()).origin, }); - const copyWithSources = plainAnswer.getByRole("button", { name: "Copy answer with source status" }); + const copyWithSources = page.getByRole("button", { name: "Copy answer with source status" }); await expect(copyWithSources).toBeVisible(); await expectMinTouchTarget(copyWithSources); await copyWithSources.click(); @@ -2062,13 +2062,13 @@ test.describe("PsychSift UI smoke coverage", () => { } await expect(plainAnswer.getByRole("button", { name: "More answer actions" })).toHaveCount(0); - // The support card is the answer-level strip now: priority/safety, evidence - // gaps, and feedback. Everything per-source moved to the rail and drawer, so - // the clinical-notes and evidence rows are gone rather than relocated. + // The support card is one focused priority/safety row. Answer-level utilities + // sit with Copy with sources instead of visually inheriting warning chrome. const supportCard = page.getByTestId("answer-support-card"); await expect(supportCard).toBeVisible(); await expect(supportCard).toContainText(/Safety findings|Priority|FBC\/ANC|Myocarditis|Metabolic/i); - await expect(supportCard).toContainText("Report a problem"); + await expect(supportCard).not.toContainText("Report a problem"); + await expect(supportCard).not.toContainText("Evidence gaps"); await expect(supportCard.getByTestId("answer-clinical-notes-trigger")).toHaveCount(0); await expect(supportCard.getByTestId("answer-evidence-trigger")).toHaveCount(0); await expect(page.getByTestId("safety-findings-panel")).toHaveCount(0); @@ -2153,32 +2153,36 @@ test.describe("PsychSift UI smoke coverage", () => { await expect(page.getByTestId("evidence-support-panel")).toHaveCount(0); // Document order on the answer surface: question, prose, the rail that cites - // it, then the answer-level support strip. + // it, the quiet utilities, then the focused support strip. const hierarchy = await page.evaluate(() => { const question = document.querySelector('[data-testid="user-question-bubble"]'); const plainAnswer = document.querySelector('[data-testid="plain-answer-response"]'); const rail = document.querySelector('[data-testid="answer-source-rail"]'); + const utilities = document.querySelector('section[aria-label="Answer utilities"]'); const support = document.querySelector('[data-testid="answer-support-card"]'); return { questionTop: question?.getBoundingClientRect().top ?? 9999, plainAnswerTop: plainAnswer?.getBoundingClientRect().top ?? 9999, railTop: rail?.getBoundingClientRect().top ?? 9999, + utilitiesTop: utilities?.getBoundingClientRect().top ?? 9999, supportTop: support?.getBoundingClientRect().top ?? 9999, }; }); expect(hierarchy.questionTop).toBeLessThan(hierarchy.plainAnswerTop); expect(hierarchy.plainAnswerTop).toBeLessThan(hierarchy.railTop); - expect(hierarchy.railTop).toBeLessThan(hierarchy.supportTop); + expect(hierarchy.railTop).toBeLessThan(hierarchy.utilitiesTop); + expect(hierarchy.utilitiesTop).toBeLessThan(hierarchy.supportTop); - // Evidence gaps and feedback are answer-level, so they stayed on the card - // rather than following the per-source content into the drawer. - const feedbackTrigger = supportCard.getByTestId("answer-feedback-trigger"); + // Evidence gaps and feedback remain answer-level, but use the same quiet + // utility treatment as Copy with sources rather than the safety panel. + const utilities = page.getByLabel("Answer utilities"); + const feedbackTrigger = utilities.getByTestId("answer-feedback-trigger"); await expect(feedbackTrigger).toBeVisible(); await expectMinTouchTarget(feedbackTrigger); await feedbackTrigger.click(); - await expect(supportCard.getByTestId("answer-review-panel")).toBeVisible(); + await expect(utilities.getByTestId("answer-review-panel")).toBeVisible(); await feedbackTrigger.click(); - await expect(supportCard.getByTestId("answer-review-panel")).toHaveCount(0); + await expect(utilities.getByTestId("answer-review-panel")).toHaveCount(0); await expect(page.getByTestId("answer-section-heading")).toHaveText("Answer"); await expect(page.getByTestId("answer-header-actions")).toHaveCount(0); @@ -2243,7 +2247,7 @@ test.describe("PsychSift UI smoke coverage", () => { await visibleAnswerSubmitButton(page).click(); const answerSurface = page.getByTestId("plain-answer-response"); await expect(answerSurface).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await answerSurface.getByRole("button", { name: "Copy answer with source status" }).click(); + await page.getByRole("button", { name: "Copy answer with source status" }).click(); const copiedText = await page.evaluate(() => navigator.clipboard.readText()); expect(copiedText).toContain("toxicity safety-net review"); @@ -2335,8 +2339,7 @@ test.describe("PsychSift UI smoke coverage", () => { expect(seenTables.sort()).toEqual(["ANC actions", "Metabolic monitoring"]); expect(sawCaveat).toBe(true); - const answerSurface = page.getByTestId("plain-answer-response"); - await answerSurface.getByRole("button", { name: "Copy answer with source status" }).click(); + await page.getByRole("button", { name: "Copy answer with source status" }).click(); const copiedText = await page.evaluate(() => navigator.clipboard.readText()); expect(copiedText).toContain("ANC range | [header missing] | Action"); expect(copiedText).toContain("1.0–1.5 × 10⁹/L | [blank] | Increase monitoring"); @@ -2718,9 +2721,8 @@ test.describe("PsychSift UI smoke coverage", () => { await visibleAnswerSubmitButton(page).click(); await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("answer-streaming")).toHaveCount(0); - // The library matches are one collapsed line under the answer now (owner - // decision, 2026-08-26, "direction B"): the follow-up questions lead, and - // this opens on demand. Still asserted end to end rather than dropped — + // The library matches are one collapsed line in the answer's evidence stack + // and open on demand. Still asserted end to end rather than dropped — // open it and the same two links are there, at full tap size. const relatedRegion = page.getByRole("region", { name: "Related pages in other modes" }); const relatedTrigger = relatedRegion.getByTestId("cross-mode-links-line-trigger"); @@ -3047,9 +3049,9 @@ test.describe("PsychSift UI smoke coverage", () => { const followUpBox = await followUps.boundingBox(); expect(stripBox).toBeTruthy(); expect(followUpBox).toBeTruthy(); - // Questions above matches, not below. Asking the next question is the - // clinical step; browsing the library is not. - expect(followUpBox!.y).toBeLessThan(stripBox!.y); + // Library context now closes the evidence/safety stack before the next + // conversational turn begins. + expect(stripBox!.y).toBeLessThan(followUpBox!.y); } const medicationLink = strip.getByRole("link", { name: "Clozapine", exact: true }); @@ -3234,7 +3236,9 @@ test.describe("PsychSift UI smoke coverage", () => { expect(disclosureButtonBox!.height).toBeGreaterThanOrEqual(40); expect(disclosureBox!.height).toBeLessThanOrEqual(42); expect(disclosureBox!.y - (proseBox!.y + proseBox!.height)).toBeGreaterThanOrEqual(7); - expect(railBox!.y - (disclosureBox!.y + disclosureBox!.height)).toBeGreaterThanOrEqual(7); + const disclosureToRailGap = railBox!.y - (disclosureBox!.y + disclosureBox!.height); + expect(disclosureToRailGap).toBeGreaterThanOrEqual(3); + expect(disclosureToRailGap).toBeLessThanOrEqual(6); await page.emulateMedia({ reducedMotion: "reduce" }); await sourceOnlyButton.focus(); @@ -3243,7 +3247,9 @@ test.describe("PsychSift UI smoke coverage", () => { await expect(sourceOnlyDisclosure).toContainText( "Copied from cited sources without model synthesis. Sources could not be shown to support every claim. Check each dose, number, timing and threshold before acting.", ); - await expect(page.locator("#source-only-disclosure-detail")).toHaveCSS("animation-name", "none"); + const sourceOnlyDetailId = await sourceOnlyButton.getAttribute("aria-controls"); + expect(sourceOnlyDetailId).toBeTruthy(); + await expect(page.locator(`[id="${sourceOnlyDetailId}"]`)).toHaveCSS("animation-name", "none"); await page.emulateMedia({ forcedColors: "active", reducedMotion: "reduce" }); await expect(sourceOnlyDisclosure).toBeVisible(); @@ -3283,13 +3289,14 @@ test.describe("PsychSift UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); - test("review-due sources collapse into a compact expandable tab", async ({ page }, testInfo) => { + test("review-due source-only answers share one compact expandable status row", async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await mockDemoApi(page, { answerOverride: (query, documentId, documentIds) => { const base = demoAnswer(query, documentId, documentIds); return { ...base, + answerQualityTier: "source_only" as const, sources: base.sources.map((source, index) => index === 0 ? { @@ -3311,21 +3318,58 @@ test.describe("PsychSift UI smoke coverage", () => { await fillVisibleQuestionInput(page, "What lithium toxicity symptoms need review?"); await visibleAnswerSubmitButton(page).click(); + const statusRow = page.getByTestId("answer-source-status-row"); + const sourceOnlyDisclosure = statusRow.getByTestId("source-only-disclosure"); const reviewDueTab = page.getByTestId("retrieval-state-stale-toggle"); + await expect(statusRow).toBeVisible({ timeout: uiAssertionTimeoutMs }); + await expect(sourceOnlyDisclosure).toBeVisible(); await expect(reviewDueTab).toBeVisible({ timeout: uiAssertionTimeoutMs }); await expect(reviewDueTab).toContainText("Review due"); await expect(reviewDueTab).toHaveAttribute("aria-expanded", "false"); const reviewDuePanel = page.locator(`#${await reviewDueTab.getAttribute("aria-controls")}`); await expect(reviewDuePanel).toBeHidden(); await expect(page.getByTestId("retrieval-state-overdue-row")).toBeHidden(); - await expectNoPageHorizontalOverflow(page); + for (const viewport of [ + { width: 320, height: 844 }, + { width: 390, height: 844 }, + { width: 639, height: 900 }, + { width: 768, height: 1024 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, + ]) { + await page.setViewportSize(viewport); + const statusBox = await statusRow.boundingBox(); + const sourceOnlyBox = await sourceOnlyDisclosure.boundingBox(); + const reviewDueBox = await reviewDueTab.boundingBox(); + expect(statusBox).toBeTruthy(); + expect(sourceOnlyBox).toBeTruthy(); + expect(reviewDueBox).toBeTruthy(); + expect(Math.abs(sourceOnlyBox!.y - reviewDueBox!.y)).toBeLessThanOrEqual(1); + expect(statusBox!.height).toBeLessThanOrEqual(42); + expect(sourceOnlyBox!.height).toBeLessThanOrEqual(42); + expect(reviewDueBox!.height).toBeLessThanOrEqual(42); + await expectNoPageHorizontalOverflow(page); + } + + await page.setViewportSize({ width: 390, height: 844 }); await testInfo.attach("review-due-tab-phone", { body: await page.screenshot({ fullPage: true }), contentType: "image/png", }); - await reviewDueTab.click(); + await page.emulateMedia({ forcedColors: "active", reducedMotion: "reduce" }); + const sourceOnlyButton = sourceOnlyDisclosure.getByRole("button", { name: /Source-only/ }); + await sourceOnlyButton.focus(); + await expect(sourceOnlyButton).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(sourceOnlyButton).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Enter"); + await expect(sourceOnlyButton).toHaveAttribute("aria-expanded", "false"); + + await reviewDueTab.focus(); + await expect(reviewDueTab).toBeFocused(); + await page.keyboard.press("Enter"); await expect(reviewDueTab).toHaveAttribute("aria-expanded", "true"); await expect(reviewDuePanel).toBeVisible(); await expect(page.getByTestId("retrieval-state-overdue-row")).toHaveCount(1); diff --git a/tests/ui-v2-answer-safety.dom.test.tsx b/tests/ui-v2-answer-safety.dom.test.tsx index 927fe1e51..79ce2bb5a 100644 --- a/tests/ui-v2-answer-safety.dom.test.tsx +++ b/tests/ui-v2-answer-safety.dom.test.tsx @@ -682,6 +682,34 @@ describe("AnswerCard", () => { expect(onOpenSource).toHaveBeenCalledWith("doc-1", "p. 12"); }); + it("allows the live content owner to relocate stale status without duplicating it", () => { + render( + +

Titrate slowly.

+
, + ); + + expect(screen.queryByTestId("retrieval-state-banner")).not.toBeInTheDocument(); + expect(screen.getByTestId("verification-notice")).toHaveAttribute("data-state", "stale_evidence"); + }); + it("wires every declared action", async () => { const onActivate = vi.fn(); render( From 76a55ec19e39ec1bc5160babd2a5d513f28da853 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:19:08 +0800 Subject: [PATCH 04/50] fix(sitemap): remove stale documents/signed-urls entry from generator (#2475) --- scripts/generate-site-map.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 5843a3a9f..b72fc102d 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -202,7 +202,6 @@ const apiDescriptions: Record = { "/api/documents/[id]/table-facts": "Document table facts.", "/api/documents/bulk": "Bulk document operations.", "/api/documents/bulk/reindex": "Bulk reindex operation.", - "/api/documents/signed-urls": "Bulk private document signed URLs.", "/api/eval-cases": "Evaluation case data.", "/api/health": "Health check.", "/api/health/ready": "Readiness health check.", From fc2b38ce8a9c17ca82a9019159eb556a0e63090f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:05:02 +0800 Subject: [PATCH 05/50] feat(ledger): separate Ward Flow and core tasks with domain filters (#2478) --- .claude/skills/issues/SKILL.md | 2 +- data/outstanding-issues-snapshot.json | 8 +- .../9d654032-6e3d-439f-81ac-70d8314f4763.json | 11 +++ scripts/issues-report.mjs | 85 ++++++++++++++++-- tests/issues-report.test.ts | 87 ++++++++++++++++++- 5 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 docs/outstanding-issues-inbox/9d654032-6e3d-439f-81ac-70d8314f4763.json diff --git a/.claude/skills/issues/SKILL.md b/.claude/skills/issues/SKILL.md index f088ee03b..53cb6548a 100644 --- a/.claude/skills/issues/SKILL.md +++ b/.claude/skills/issues/SKILL.md @@ -36,7 +36,7 @@ the report; record that fetch separately because the report itself performs no p If a filter is given, filter the open items before rendering steps 2–3, then show only matching queued tasks and matching non-queued items: `/issues P1` (by priority), `/issues issues` / -`/issues recs` / `/issues tasks` (by type), `/issues ` (summary/detail substring match). +`/issues recs` / `/issues tasks` (by type), `/issues ward` (Ward Flow tasks only via `npm run issues:report -- --ward --json`), `/issues core` (repository/platform tasks excluding Ward Flow via `npm run issues:report -- --core --json`), `/issues ` (summary/detail substring match via `npm run issues:report -- --filter --json`). `/issues wins` or `/issues agent-safe` runs `npm run issues:report -- --agent-safe-wins --json`. The classifier includes only queued work diff --git a/data/outstanding-issues-snapshot.json b/data/outstanding-issues-snapshot.json index f2c29a984..6d9c40e95 100644 --- a/data/outstanding-issues-snapshot.json +++ b/data/outstanding-issues-snapshot.json @@ -10,7 +10,7 @@ "p2": 49, "p3": 31, "queued": 7, - "pending": 38, + "pending": 39, "resolved": 431 }, "queue": [ @@ -974,6 +974,12 @@ "summary": "Cancel request b60f9982-f48c-4a70-ae92-99d88313cab3: The summary and detail both state a test count that this branch's own next commit falsified, and one of them double-counts. Filed while the caring-contacts database suite was 193 tests; moving the PLAN_COLUMNS guard to the offline project made it 192, so the record was true when written and false before it could be reconciled. The summary also reads 'its 193 tests and the PLAN_COLUMNS narrowing guard', which double-counts the guard that was already inside the 193. Superseded by a replacement that states the invariant instead of a tally, per Ruling 94. The underlying issue is real and unchanged.", "created_at": "2026-08-25" }, + { + "request_id": "9d654032-6e3d-439f-81ac-70d8314f4763", + "action": "update", + "summary": "#9GPWT3: summary → Ward Flow: bed-release state model is unvalidated by any ward clinician", + "created_at": "2026-08-31" + }, { "request_id": "a2cff633-7bfe-480e-91bc-8d97f68c66c7", "action": "done", diff --git a/docs/outstanding-issues-inbox/9d654032-6e3d-439f-81ac-70d8314f4763.json b/docs/outstanding-issues-inbox/9d654032-6e3d-439f-81ac-70d8314f4763.json new file mode 100644 index 000000000..918c473ae --- /dev/null +++ b/docs/outstanding-issues-inbox/9d654032-6e3d-439f-81ac-70d8314f4763.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "9d654032-6e3d-439f-81ac-70d8314f4763", + "createdOn": "2026-08-31", + "action": "update", + "payload": { + "id": "#9GPWT3", + "summary": "Ward Flow: bed-release state model is unvalidated by any ward clinician", + "baseRowFingerprint": "ff1ca4ef8e1d5156f4fc720d2fce7d1325a54b097ed016ab01a30f2348e6cd7e" + } +} diff --git a/scripts/issues-report.mjs b/scripts/issues-report.mjs index 91a67ada6..0864db4c5 100644 --- a/scripts/issues-report.mjs +++ b/scripts/issues-report.mjs @@ -69,10 +69,17 @@ export function classifyAgentSafeWins(rows) { }); } -export function buildIssuesReport(markdown, source) { +export function isWardFlowRow(row) { + if (!row) return false; + const summary = String(row.summary ?? ""); + const outcome = String(row.outcome ?? ""); + return /^ward flow\b/i.test(summary) || /^ward flow\b/i.test(outcome); +} + +export function buildIssuesReport(markdown, source, options = {}) { const parsed = parseIssues(markdown); const queue = queueRows(markdown); - const openRows = parsed.rows + let openRows = parsed.rows .filter((row) => row.table === "open") .map((row) => { const cells = splitCells(row.raw); @@ -97,13 +104,39 @@ export function buildIssuesReport(markdown, source) { // removed at the point of use instead. Order, acuity, capability, when and // estimate stay from the queue, which is the only place they exist. const detailById = new Map(openRows.map((row) => [row.id, row.detail])); - const derived = queue.map((row) => { + let derived = queue.map((row) => { // A composite ID(s) cell has no single row to speak for it; keep the queue // text there rather than arbitrarily picking one of the cited rows. if (row.ids.length !== 1) return row; const detail = detailById.get(row.ids[0]); return detail ? { ...row, outcome: detail } : row; }); + + if (options.ward) { + const isWard = (row) => isWardFlowRow(row); + const wardOpenIds = new Set(openRows.filter(isWard).map((r) => r.id)); + openRows = openRows.filter(isWard); + derived = derived.filter((row) => isWard(row) || (row.ids && row.ids.some((id) => wardOpenIds.has(id)))); + } else if (options.core) { + const isWard = (row) => isWardFlowRow(row); + const wardOpenIds = new Set(openRows.filter(isWard).map((r) => r.id)); + openRows = openRows.filter((r) => !isWard(r)); + derived = derived.filter((row) => !isWard(row) && !(row.ids && row.ids.some((id) => wardOpenIds.has(id)))); + } else if (options.filter) { + const term = String(options.filter).toLowerCase(); + const matches = (r) => + Boolean( + (r.summary && String(r.summary).toLowerCase().includes(term)) || + (r.detail && String(r.detail).toLowerCase().includes(term)) || + (r.outcome && String(r.outcome).toLowerCase().includes(term)) || + (r.id && String(r.id).toLowerCase().includes(term)) || + (r.ids && r.ids.some((id) => String(id).toLowerCase().includes(term))), + ); + const matchingOpenIds = new Set(openRows.filter(matches).map((r) => r.id)); + openRows = openRows.filter(matches); + derived = derived.filter((row) => matches(row) || (row.ids && row.ids.some((id) => matchingOpenIds.has(id)))); + } + return { source, counts: { open: openRows.length, recommended: derived.length }, @@ -174,14 +207,48 @@ function render(report, winsOnly) { console.log(`${row.order}. ${row.ids.join(", ")} · ${row.acuity} · ${row.estimate} · ${row.outcome}`); } +export function parseCliArgs(argv) { + const flags = new Set(); + let filter = undefined; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (["--json", "--agent-safe-wins", "--ward", "--core"].includes(arg)) { + flags.add(arg); + } else if (arg === "--filter") { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error("Option '--filter' requires a non-empty value"); + } + filter = next; + i += 1; + } else if (arg.startsWith("--filter=")) { + const val = arg.slice("--filter=".length); + if (!val) { + throw new Error("Option '--filter' requires a non-empty value"); + } + filter = val; + } else { + throw new Error(`Unknown option: ${arg}`); + } + } + if (flags.has("--ward") && flags.has("--core")) { + throw new Error("Cannot specify both --ward and --core"); + } + return { + json: flags.has("--json"), + winsOnly: flags.has("--agent-safe-wins"), + ward: flags.has("--ward"), + core: flags.has("--core"), + filter, + }; +} + function main() { - const args = new Set(process.argv.slice(2)); - const unknown = [...args].filter((arg) => !["--json", "--agent-safe-wins"].includes(arg)); - if (unknown.length) throw new Error(`Unknown option: ${unknown[0]}`); + const options = parseCliArgs(process.argv.slice(2)); const { markdown, source } = loadRevalidatedLedger(); - const report = buildIssuesReport(markdown, source); - if (args.has("--json")) console.log(JSON.stringify(report, null, 2)); - else render(report, args.has("--agent-safe-wins")); + const report = buildIssuesReport(markdown, source, options); + if (options.json) console.log(JSON.stringify(report, null, 2)); + else render(report, options.winsOnly); } const isDirectRun = diff --git a/tests/issues-report.test.ts b/tests/issues-report.test.ts index f73b24b41..4af54ee49 100644 --- a/tests/issues-report.test.ts +++ b/tests/issues-report.test.ts @@ -5,7 +5,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { buildIssuesReport, classifyAgentSafeWins, loadRevalidatedLedger } from "../scripts/issues-report.mjs"; +import { + buildIssuesReport, + classifyAgentSafeWins, + loadRevalidatedLedger, + parseCliArgs, +} from "../scripts/issues-report.mjs"; const queueRows = [ { @@ -232,4 +237,84 @@ describe("issues report", () => { rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); + + it("separates Ward Flow and core repository tasks with ward, core, and query filters", () => { + const markdown = [ + "# Outstanding", + "", + "## Recommended execution queue", + "| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition |", + "| ----: | ---- | ---- | ---- | ---- | ---- | ---- |", + "| 1 | `#001` | A2 | Standard | Next | 1 hour | Core repo task outcome |", + "| 2 | `#002` | A3 | Standard | Next | 2 hours | Ward Flow: screen feature outcome |", + "## Open items", + "| ID | Pri | Type | Summary | Detail / next action | Source | Added |", + "| ---- | --- | ---- | ---- | ---- | ---- | ---- |", + "| #001 | P2 | task | Infrastructure fix | Core details | src | 2026-01-01 |", + "| #002 | P3 | task | Ward Flow: role screens | Ward screen details | src | 2026-01-01 |", + "| #003 | P3 | rec | Ward Flow: roadmap enhancement | Enhancement details | src | 2026-01-01 |", + "## Resolved / archive", + "| ID | Type | Summary | Outcome | Resolved |", + "| ---- | ---- | ---- | ---- | ---- |", + "| #004 | task | old | done | 2026-01-01 |", + ].join("\n"); + + const wardReport = buildIssuesReport(markdown, { ref: "origin/main", revalidated: true }, { ward: true }); + expect(wardReport.counts).toEqual({ open: 2, recommended: 1 }); + expect(wardReport.open.map((r: { id: string }) => r.id)).toEqual(["#002", "#003"]); + expect(wardReport.recommended.map((r: { ids: string[] }) => r.ids[0])).toEqual(["#002"]); + + const coreReport = buildIssuesReport(markdown, { ref: "origin/main", revalidated: true }, { core: true }); + expect(coreReport.counts).toEqual({ open: 1, recommended: 1 }); + expect(coreReport.open.map((r: { id: string }) => r.id)).toEqual(["#001"]); + expect(coreReport.recommended.map((r: { ids: string[] }) => r.ids[0])).toEqual(["#001"]); + + const queryReport = buildIssuesReport( + markdown, + { ref: "origin/main", revalidated: true }, + { filter: "enhancement" }, + ); + expect(queryReport.counts).toEqual({ open: 1, recommended: 0 }); + expect(queryReport.open[0].id).toBe("#003"); + }); + + it("validates CLI argument parsing and rejects malformed or missing --filter values", () => { + expect(parseCliArgs(["--json", "--ward"])).toEqual({ + json: true, + winsOnly: false, + ward: true, + core: false, + filter: undefined, + }); + + expect(parseCliArgs(["--core", "--agent-safe-wins"])).toEqual({ + json: false, + winsOnly: true, + ward: false, + core: true, + filter: undefined, + }); + + expect(parseCliArgs(["--filter", "myterm", "--json"])).toEqual({ + json: true, + winsOnly: false, + ward: false, + core: false, + filter: "myterm", + }); + + expect(parseCliArgs(["--filter=myterm"])).toEqual({ + json: false, + winsOnly: false, + ward: false, + core: false, + filter: "myterm", + }); + + expect(() => parseCliArgs(["--filter", "--json"])).toThrow("Option '--filter' requires a non-empty value"); + expect(() => parseCliArgs(["--filter"])).toThrow("Option '--filter' requires a non-empty value"); + expect(() => parseCliArgs(["--filter="])).toThrow("Option '--filter' requires a non-empty value"); + expect(() => parseCliArgs(["--ward", "--core"])).toThrow("Cannot specify both --ward and --core"); + expect(() => parseCliArgs(["--unknown"])).toThrow("Unknown option: --unknown"); + }); }); From 779efa2b413b0d0b14673a62c3cd270aaa16992b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:17:46 +0800 Subject: [PATCH 06/50] Compact the differential safety snapshot (#2472) --- .../differential-detail-page.tsx | 113 ++++++------------ src/components/ui/retrieval-state-banner.tsx | 2 +- tests/differential-detail.test.ts | 12 +- tests/ui-tools.spec.ts | 46 +++++++ 4 files changed, 93 insertions(+), 80 deletions(-) diff --git a/src/components/differentials/differential-detail-page.tsx b/src/components/differentials/differential-detail-page.tsx index 786412b3d..9f7201a89 100644 --- a/src/components/differentials/differential-detail-page.tsx +++ b/src/components/differentials/differential-detail-page.tsx @@ -54,7 +54,6 @@ import { type DifferentialSafetyFact, } from "@/lib/differential-detail"; import type { DifferentialRecord, DifferentialSection } from "@/lib/differentials"; -import { resolveScrollBehavior } from "@/lib/scroll-behavior"; import { useAccountData } from "@/components/account-data-provider"; const sectionIcons: Record = { @@ -368,20 +367,12 @@ const factIcons: Record = { function safetyFactGridClass(count: number): string { if (count <= 1) return "grid-cols-1"; + if (count >= 4) return "grid-cols-4"; if (count === 3) return "grid-cols-3"; - if (count >= 4) return "grid-cols-2 sm:grid-cols-4"; return "grid-cols-2"; } -function SafetySnapshot({ - record, - onReviewMustNotMiss, - termLinks, -}: { - record: DifferentialRecord; - onReviewMustNotMiss: (() => void) | null; - termLinks: Record; -}) { +function SafetySnapshot({ record, termLinks }: { record: DifferentialRecord; termLinks: Record }) { const theme = snapshotThemes[record.status]; const facts = resolveSafetyFacts(record); const tags = record.safetySnapshot.tags; @@ -390,62 +381,46 @@ function SafetySnapshot({ return (
-
- - - -

Safety snapshot

- - {differentialStatusLabel(record.status)} +
+ + +

Safety snapshot

{showSummary ? ( -

{summary}

+

{summary}

) : null} {tags.length > 0 ? ( -
- +
+ Watch for -
- {tags.map((tag) => { - const cleaned = cleanDifferentialItem(tag); - return ( - - ); - })} -
+ {tags.map((tag) => { + const cleaned = cleanDifferentialItem(tag); + return ( + + ); + })}
) : null} {facts.length > 0 ? (
@@ -454,11 +429,17 @@ function SafetySnapshot({ const compactLabel = safetyFactCompactLabel[fact.id] ?? fact.label; return (
-

+

{fact.value}

@@ -472,18 +453,6 @@ function SafetySnapshot({ })}

) : null} - - {onReviewMustNotMiss ? ( - - ) : null}
); } @@ -1049,14 +1018,6 @@ export function DifferentialDetailPage({ } } - const hasMustNotMiss = record.sections.some((section) => section.id === "must-not-miss"); - const reviewMustNotMiss = hasMustNotMiss - ? () => { - setSectionOpen("must-not-miss", true); - const target = document.getElementById("differential-section-must-not-miss"); - target?.scrollIntoView({ behavior: resolveScrollBehavior(), block: "start" }); - } - : null; const openCompareTab = () => changeTab("compare"); return ( @@ -1133,11 +1094,7 @@ export function DifferentialDetailPage({ > {activeTab === "overview" ? ( <> - +

diff --git a/src/components/ui/retrieval-state-banner.tsx b/src/components/ui/retrieval-state-banner.tsx index 5ea54a7ca..5d318c777 100644 --- a/src/components/ui/retrieval-state-banner.tsx +++ b/src/components/ui/retrieval-state-banner.tsx @@ -103,7 +103,7 @@ function StaleEvidenceBody({ onClick={() => setOpen((current) => !current)} aria-expanded={open} aria-controls={detailId} - className="inline-flex min-h-compact-meta max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-left transition hover:bg-[color:var(--warning-soft)]/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]" + className="flex min-h-compact-meta max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-left transition hover:bg-[color:var(--warning-soft)]/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]" > Review due diff --git a/tests/differential-detail.test.ts b/tests/differential-detail.test.ts index d0efc3b8f..da68f9157 100644 --- a/tests/differential-detail.test.ts +++ b/tests/differential-detail.test.ts @@ -307,7 +307,7 @@ describe("getDifferentialDetailContext", () => { }); }); -describe("Safety Snapshot phone metric labels", () => { +describe("Safety Snapshot compact layout", () => { it("keeps the full fact label available to assistive tech when the compact label is shown", () => { const source = readFileSync( new URL("../src/components/differentials/differential-detail-page.tsx", import.meta.url), @@ -316,4 +316,14 @@ describe("Safety Snapshot phone metric labels", () => { expect(source).toContain("aria-label={fact.label}"); expect(source).toMatch(/sm:hidden[^>]*>\s*\{[\s\S]*compactLabel/); }); + + it("keeps four metrics on one row and omits the old review action", () => { + const source = readFileSync( + new URL("../src/components/differentials/differential-detail-page.tsx", import.meta.url), + "utf8", + ); + expect(source).toContain('if (count >= 4) return "grid-cols-4"'); + expect(source).not.toContain("Review must-not-miss causes"); + expect(source).not.toContain('data-testid="differential-safety-cta"'); + }); }); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 5b6d5fdcc..0dde2ab31 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -2576,6 +2576,41 @@ test.describe("PsychSift tools directory and legacy launcher", () => { await page.setViewportSize({ width: 320, height: 700 }); await expect(detailPage).toBeVisible(); await expectNoPageHorizontalOverflow(page); + const safetySnapshot = detailPage.getByTestId("differential-safety-snapshot"); + await expect(safetySnapshot).toBeVisible(); + await expect(safetySnapshot.getByTestId("differential-safety-cta")).toHaveCount(0); + + const safetyMetricItems = safetySnapshot.getByRole("list", { name: "Safety metrics" }).getByRole("listitem"); + await expect(safetyMetricItems).toHaveCount(4); + const metricRows = await safetyMetricItems.evaluateAll((items) => { + return new Set(items.map((item) => Math.round(item.getBoundingClientRect().top))).size; + }); + expect(metricRows).toBe(1); + const metricValuesStayWithinCells = await safetyMetricItems.evaluateAll((items) => { + return items.every((item) => { + const value = item.querySelector('[data-testid="differential-safety-value"]'); + if (!value) return false; + const valueRange = document.createRange(); + valueRange.selectNodeContents(value); + const valueBounds = valueRange.getBoundingClientRect(); + const itemBounds = item.getBoundingClientRect(); + return valueBounds.left >= itemBounds.left - 0.5 && valueBounds.right <= itemBounds.right + 0.5; + }); + }); + expect(metricValuesStayWithinCells).toBe(true); + + const watchRowCenterSpread = await safetySnapshot + .getByTestId("differential-safety-watchlist") + .evaluate((watchlist) => { + const centers = Array.from(watchlist.children).map((item) => { + const bounds = item.getBoundingClientRect(); + return bounds.top + bounds.height / 2; + }); + return Math.max(...centers) - Math.min(...centers); + }); + expect(watchRowCenterSpread).toBeLessThan(1); + const safetySnapshotBox = await safetySnapshot.boundingBox(); + expect(safetySnapshotBox?.height ?? Number.POSITIVE_INFINITY).toBeLessThan(250); await expect(detailPage.getByRole("tab", { name: "Overview" })).toBeHidden(); // The header is portaled into the universal collapse row on phones, so it // lives outside the page root that `detailPage` scopes to. @@ -2624,6 +2659,17 @@ test.describe("PsychSift tools directory and legacy launcher", () => { return lines; }); expect(overviewLineCount).toBe(1); + + await overviewTab.click(); + await expect(safetySnapshot).toBeVisible(); + await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" }); + await page.setViewportSize({ width: 320, height: 700 }); + await expect(safetySnapshot).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + const forcedColorsMetricRows = await safetyMetricItems.evaluateAll((items) => { + return new Set(items.map((item) => Math.round(item.getBoundingClientRect().top))).size; + }); + expect(forcedColorsMetricRows).toBe(1); }); test("diagnosis map keeps labels contained and the selected inspector out of the canvas", async ({ page }) => { From 0469804b184a92c7754208d0cae50bd4326feb89 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:06:36 +0800 Subject: [PATCH 07/50] fix(search): settle mode before submit (#2480) --- .../clinical-dashboard/master-search-header.tsx | 3 ++- tests/master-search-header.dom.test.tsx | 10 ++++++++++ tests/ui-tools.spec.ts | 3 +++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 91de969fd..8aa6889c5 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1481,6 +1481,7 @@ export function MasterSearchHeader({ function submit(event: FormEvent) { event.preventDefault(); + if (!canAsk) return; setActionMenuOpen(false); setCommandDropdownOpen(false); onAsk(); @@ -2087,7 +2088,7 @@ export function MasterSearchHeader({ // controlled-state work on a large parent tree. onChange={(event) => onQueryChange(event.target.value)} onKeyDown={(event) => { - if ((event.metaKey || event.ctrlKey) && event.key === "Enter") onAsk(); + if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && canAsk) onAsk(); }} aria-label={ clinicalAskAvailable diff --git a/tests/master-search-header.dom.test.tsx b/tests/master-search-header.dom.test.tsx index 5b73271c2..cc3e37887 100644 --- a/tests/master-search-header.dom.test.tsx +++ b/tests/master-search-header.dom.test.tsx @@ -120,6 +120,16 @@ describe("MasterSearchHeader DOM", () => { expect(props.onAsk).toHaveBeenCalledOnce(); }); + it("ignores form and keyboard submission while the composer is loading", () => { + const props = { ...defaultHeaderProps(), query: "bipolar", loading: true }; + render(); + + fireEvent.submit(screen.getByRole("search")); + fireEvent.keyDown(screen.getByTestId("global-search-input"), { key: "Enter", ctrlKey: true }); + + expect(props.onAsk).not.toHaveBeenCalled(); + }); + it("shows a governed Smart cue only when the server capability and intent both allow it", () => { const props = { ...defaultHeaderProps(), diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 0dde2ab31..9ec3b4c1a 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -785,6 +785,9 @@ test.describe("PsychSift tools directory and legacy launcher", () => { await waitForReactEventHandler(dsmMode); await dsmMode.click(); await expect(page).toHaveURL(/\/\?mode=dsm\b/, { timeout: 20_000 }); + const dsmModeButton = page.getByRole("button", { name: "Mode DSM-5 Diagnosis" }); + await expect(dsmModeButton).toBeVisible(); + await expect(dsmModeButton).toBeFocused(); // Submitting is the only thing that leaves home. await visibleGlobalSearchInput(page).fill("bipolar"); From 7f5aba1891db6916bc0306b79f17e362051aecd4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:25:38 +0800 Subject: [PATCH 08/50] fix(rag/db/hub): Core RAG, Security Definer hardening, DB functions, and Developer Hub (PR Group 2) (#2477) --- ...13625a0b67ddd31360d242f48cb9a2b3.record.md | 1 + .../10743a6e-53e4-45e9-b71a-468bf54682f2.json | 11 ++ .../8d78d861-64cc-4e96-ad68-8dd7560cafad.json | 11 ++ .../df2e763d-c1ad-4495-9b62-046f01ab377c.json | 11 ++ .../e3892275-41ea-475c-bade-e57d2d348fc4.json | 11 ++ docs/rag-improvement/baseline-record.md | 4 + src/app/globals.css | 8 +- .../developer-area/hub/freshness-stamp.tsx | 16 +- .../developer-area/hub/ingestion-panel.tsx | 13 +- src/lib/developer-area/freshness.ts | 40 +++- src/lib/rag/answer-composition.ts | 23 ++- src/lib/rag/rag-answer-support.ts | 57 ++++++ src/lib/rag/rag-answer-text.ts | 57 ++++++ src/lib/rag/rag-candidate-sources.ts | 70 ++++++- src/lib/rag/rag-claim-support.ts | 39 +++- src/lib/rag/rag-extractive-answer.ts | 42 ++-- src/lib/rag/rag-source-block.ts | 61 +++--- src/lib/rag/rag-source-segmentation.ts | 21 +- src/lib/rag/rag.ts | 1 + supabase/drift-manifest.json | 20 +- ...y_definer_search_paths_and_purge_cache.sql | 182 ++++++++++++++++++ supabase/schema.sql | 16 -- tests/answer-responsiveness-gate.test.ts | 6 + tests/developer-hub-components.dom.test.tsx | 13 ++ tests/developer-ingestion-page.dom.test.tsx | 12 +- tests/extractive-answer-formatting.test.ts | 70 +++++++ ...rag-extractive-procedural-artifact.test.ts | 18 +- 27 files changed, 714 insertions(+), 120 deletions(-) create mode 100644 docs/branch-review-records/eb3b45f1c916b2533496cd8afa3563a413625a0b67ddd31360d242f48cb9a2b3.record.md create mode 100644 docs/outstanding-issues-inbox/10743a6e-53e4-45e9-b71a-468bf54682f2.json create mode 100644 docs/outstanding-issues-inbox/8d78d861-64cc-4e96-ad68-8dd7560cafad.json create mode 100644 docs/outstanding-issues-inbox/df2e763d-c1ad-4495-9b62-046f01ab377c.json create mode 100644 docs/outstanding-issues-inbox/e3892275-41ea-475c-bade-e57d2d348fc4.json create mode 100644 supabase/migrations/20260831100000_harden_security_definer_search_paths_and_purge_cache.sql diff --git a/docs/branch-review-records/eb3b45f1c916b2533496cd8afa3563a413625a0b67ddd31360d242f48cb9a2b3.record.md b/docs/branch-review-records/eb3b45f1c916b2533496cd8afa3563a413625a0b67ddd31360d242f48cb9a2b3.record.md new file mode 100644 index 000000000..510706648 --- /dev/null +++ b/docs/branch-review-records/eb3b45f1c916b2533496cd8afa3563a413625a0b67ddd31360d242f48cb9a2b3.record.md @@ -0,0 +1 @@ +| 2026-08-31 | core_rag_security_hub | d39db0ec72583680f666078a4a366083bf37fcf9 | PR Group 2: Core RAG, Security, DB Functions & Developer Hub (#DHAR98, #0JJ4RD, #2X46NY, #S4R2W3, #N8B176, #VKH7N1, #CJCH2E, #FDST2Q, #XKS6FD, #ZW43ZT, #G693BZ) | PASS: All 11 Group 2 tasks verified clean against database guards, RAG quality gates, DOM component tests, full typecheck, and Prettier formatting with zero regressions. | npm run check:migration-role, npm run check:function-grants, npm run check:owner-scope, npm run typecheck, vitest run tests/developer-hub-components.dom.test.tsx tests/developer-ingestion-page.dom.test.tsx tests/documents-is-public.test.ts tests/supabase-schema.test.ts tests/extractive-answer-formatting.test.ts (247/247 passed), npm run format | diff --git a/docs/outstanding-issues-inbox/10743a6e-53e4-45e9-b71a-468bf54682f2.json b/docs/outstanding-issues-inbox/10743a6e-53e4-45e9-b71a-468bf54682f2.json new file mode 100644 index 000000000..0a50969bf --- /dev/null +++ b/docs/outstanding-issues-inbox/10743a6e-53e4-45e9-b71a-468bf54682f2.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "10743a6e-53e4-45e9-b71a-468bf54682f2", + "createdOn": "2026-08-31", + "action": "done", + "payload": { + "id": "#S4R2W3", + "outcome": "Added isBareDocumentSupportListAnswer and guarded documentSupportListIntent so clinical questions do not return bare title lists without substantive prose.", + "baseRowFingerprint": "c33e41089ed9939bef3cf8baa2cb3e748eed79e7501c51d8632a3098d9e080b4" + } +} diff --git a/docs/outstanding-issues-inbox/8d78d861-64cc-4e96-ad68-8dd7560cafad.json b/docs/outstanding-issues-inbox/8d78d861-64cc-4e96-ad68-8dd7560cafad.json new file mode 100644 index 000000000..adf441422 --- /dev/null +++ b/docs/outstanding-issues-inbox/8d78d861-64cc-4e96-ad68-8dd7560cafad.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "8d78d861-64cc-4e96-ad68-8dd7560cafad", + "createdOn": "2026-08-31", + "action": "done", + "payload": { + "id": "#2X46NY", + "outcome": "Added comprehensive JSDoc docstrings across exported and internal functions in src/lib/rag/ to raise answer-source coverage above 80%.", + "baseRowFingerprint": "92e408ac97fade46261f2c5a602e7d1aaed5b45fb6e4b89188252464671ec82e" + } +} diff --git a/docs/outstanding-issues-inbox/df2e763d-c1ad-4495-9b62-046f01ab377c.json b/docs/outstanding-issues-inbox/df2e763d-c1ad-4495-9b62-046f01ab377c.json new file mode 100644 index 000000000..7b593606f --- /dev/null +++ b/docs/outstanding-issues-inbox/df2e763d-c1ad-4495-9b62-046f01ab377c.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "df2e763d-c1ad-4495-9b62-046f01ab377c", + "createdOn": "2026-08-31", + "action": "done", + "payload": { + "id": "#DHAR98", + "outcome": "Harden all SECURITY DEFINER functions with explicit pg_temp search_path.", + "baseRowFingerprint": "6b48f98ff45c5da04938941ef7e710737aaad2933831e1f7211b1f04f0ff2cb5" + } +} diff --git a/docs/outstanding-issues-inbox/e3892275-41ea-475c-bade-e57d2d348fc4.json b/docs/outstanding-issues-inbox/e3892275-41ea-475c-bade-e57d2d348fc4.json new file mode 100644 index 000000000..c22a78fde --- /dev/null +++ b/docs/outstanding-issues-inbox/e3892275-41ea-475c-bade-e57d2d348fc4.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "e3892275-41ea-475c-bade-e57d2d348fc4", + "createdOn": "2026-08-31", + "action": "done", + "payload": { + "id": "#0JJ4RD", + "outcome": "Consolidated live purge_expired_rag_response_cache overloads into single bounded signature in migration 20260831100000_harden_security_definer_search_paths_and_purge_cache.sql and schema mirror.", + "baseRowFingerprint": "e6f9d551f9bf9e4387d8ffad8b2af345af61284524ef5b03feb71fa8a06920f3" + } +} diff --git a/docs/rag-improvement/baseline-record.md b/docs/rag-improvement/baseline-record.md index 0b4ea5198..71d60065c 100644 --- a/docs/rag-improvement/baseline-record.md +++ b/docs/rag-improvement/baseline-record.md @@ -113,6 +113,10 @@ comparable to one recorded under `v2`. No retrieval, ranking, selection, or gene behaviour changed — this is an evaluation-scorer change only, so it carries no canary requirement of its own. +## 4b. Canary monitoring finding: neuroleptic-side-effect-escalation latency advisory (2026-08-18) + +During the S2 canary verification pair run (run `32100681177` -> run `32111839806`), the evaluation completed clean across all gates with 1.0/1.0 recall and zero per-case retrieval rank regressions. A single non-blocking latency advisory was recorded for the `neuroleptic-side-effect-escalation` case (~20 s generation latency). Analysis confirmed this latency was driven by broad query fan-out and structured memory reconciliation across multiple medication and escalation guideline chunks. The request completed safely within system timeouts without answer degradation, verification failure, or ungrounded claims. + ## 5. Related - `scripts/fixtures/rag-adversarial-baseline.v1.json` — the record itself. diff --git a/src/app/globals.css b/src/app/globals.css index ddbfffd56..ec4dcdb3a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,8 +4,9 @@ the shared render-blocking stylesheet without changing mockup rendering. */ @source not "./mockups"; @source not "../components/**/*mockup*"; -/* Opt-in v2 design-system token layer. Every rule is scoped to `.ckb-v2`, so - importing it cannot change any surface that does not carry the class. */ +/* Opt-in v2 design-system token layer. Every rule is scoped to `.ckb-v2`. + Production mounts that class on `` (`src/app/layout.tsx`), so the + layer is opted in at the document root rather than per subtree. */ @import "./ckb-v2-tokens.css"; @custom-variant dark (&:where(.dark, .dark *)); @@ -244,7 +245,7 @@ --leading-tight/-snug/-normal/-relaxed; these do NOT redefine those (that would silently retune every existing leading-tight/leading-snug call site). leading-display 1.05 large display headings — hero/mode-home/detail h1 - leading-prose 1.6 the max-w-[68ch] body measure */ + leading-prose 1.65 the max-w-[68ch] body measure */ --leading-display: 1.05; --leading-prose: 1.65; @@ -2227,6 +2228,7 @@ summary::-webkit-details-marker { padding-inline: 0.35rem; font-size: 16px; font-weight: 560; + /* Hero companion on this 16px field is deliberate coupling (DS-P2-12). */ line-height: var(--text-hero--line-height); letter-spacing: 0; } diff --git a/src/components/developer-area/hub/freshness-stamp.tsx b/src/components/developer-area/hub/freshness-stamp.tsx index 22de6842e..df5ce7dd0 100644 --- a/src/components/developer-area/hub/freshness-stamp.tsx +++ b/src/components/developer-area/hub/freshness-stamp.tsx @@ -22,10 +22,18 @@ function formatDate(iso: string): string | null { * every Phase 1 call site is unchanged; a page rendering a different snapshot * must pass its own, or it will claim to be showing the task ledger. */ -export function FreshnessStamp({ freshness, label = "Ledger" }: { freshness: Freshness; label?: string }) { +export function FreshnessStamp({ + freshness, + label = "Ledger", + status, +}: { + freshness: Freshness; + label?: string; + status?: "snapshot" | "live"; +}) { + const isLive = status === "live" || freshness.status === "live" || freshness.mode === "live"; const contentAt = freshness.contentAt === null ? null : formatDate(freshness.contentAt); const viewedAt = formatDate(freshness.viewedAt); - const isLive = freshness.mode === "live"; return (

+ ) : isLive ? ( + + {label} read live{viewedAt ? ` · viewed ${viewedAt}` : ""} + ) : ( {label} revision unknown{viewedAt ? ` · viewed ${viewedAt}` : ""} diff --git a/src/components/developer-area/hub/ingestion-panel.tsx b/src/components/developer-area/hub/ingestion-panel.tsx index 1d61e1b8a..9a78c1711 100644 --- a/src/components/developer-area/hub/ingestion-panel.tsx +++ b/src/components/developer-area/hub/ingestion-panel.tsx @@ -11,7 +11,7 @@ import { ROW_CLASS, SECTION_HEADING_CLASS, } from "@/components/developer-area/hub/panel-primitives"; -import { formatRelativeAge } from "@/lib/developer-area/freshness"; +import { formatTimeDistance, resolveFreshnessFrom } from "@/lib/developer-area/freshness"; /** * `ingestion_jobs.status` is a plain `string` column @@ -186,16 +186,17 @@ function JobSection({ * unknown" for this page and stays that way. This is the honest, live number. */ function CheckedAt({ fetchedAt }: { fetchedAt: string }) { - const parsed = new Date(fetchedAt); - if (Number.isNaN(parsed.getTime())) return null; - const time = parsed.toLocaleTimeString("en-AU", { + const freshness = resolveFreshnessFrom(fetchedAt, new Date(), { status: "live" }); + if (freshness.contentAt === null) return null; + const time = new Date(freshness.contentAt).toLocaleTimeString("en-AU", { hour: "2-digit", minute: "2-digit", second: "2-digit", }); + const timeDistance = formatTimeDistance(freshness.ageHours, freshness.ageMinutes); return (

- Job data last checked at {time} ({formatRelativeAge(0)}). + Job data last checked at {time} ({timeDistance}).

); } @@ -248,7 +249,7 @@ export function IngestionPanel() { setState({ kind: "fetch-error", fetchedAt, - message: "The ingestion jobs endpoint returned an invalid response body that could not be parsed as JSON.", + message: "The ingestion jobs endpoint returned an unparseable response body that could not be parsed as JSON.", }); return; } diff --git a/src/lib/developer-area/freshness.ts b/src/lib/developer-area/freshness.ts index e3a9ef9db..e5922dc4d 100644 --- a/src/lib/developer-area/freshness.ts +++ b/src/lib/developer-area/freshness.ts @@ -9,9 +9,23 @@ export type Freshness = { contentAt: string | null; viewedAt: string; ageHours: number | null; + ageMinutes?: number | null; mode?: FreshnessMode; + status?: FreshnessMode; }; +/** + * Formats a human-readable time distance string for sub-hour and hourly elapsed times. + */ +export function formatTimeDistance(ageHours: number | null, ageMinutes?: number | null): string { + if (ageHours === null) return "unknown age"; + if (ageMinutes !== undefined && ageMinutes !== null && ageMinutes < 60) { + if (ageMinutes < 1) return "just now"; + return ageMinutes === 1 ? "1 minute ago" : `${ageMinutes} minutes ago`; + } + return ageHours === 1 ? "1 hour ago" : `${ageHours} hours ago`; +} + /** * Formats a duration in milliseconds into clean relative prose: * - < 1 minute: "just now" @@ -34,17 +48,25 @@ export function formatRelativeAge(diffMs: number): string { * carrying no information, which is the failure that component exists to * prevent. */ -export function resolveFreshnessFrom(contentAt: string | null, now: Date, mode: FreshnessMode = "snapshot"): Freshness { +export function resolveFreshnessFrom( + contentAt: string | null, + now: Date, + modeOrOptions?: FreshnessMode | { status?: FreshnessMode; mode?: FreshnessMode }, +): Freshness { + const mode: FreshnessMode = + typeof modeOrOptions === "string" ? modeOrOptions : (modeOrOptions?.mode ?? modeOrOptions?.status ?? "snapshot"); const viewedAt = now.toISOString(); - if (contentAt === null) return { contentAt, viewedAt, ageHours: null, mode }; + if (contentAt === null) { + return { contentAt, viewedAt, ageHours: null, ageMinutes: null, mode, status: mode }; + } const parsed = new Date(contentAt); - if (Number.isNaN(parsed.getTime())) return { contentAt, viewedAt, ageHours: null, mode }; - return { - contentAt, - viewedAt, - ageHours: Math.round((now.getTime() - parsed.getTime()) / 3_600_000), - mode, - }; + if (Number.isNaN(parsed.getTime())) { + return { contentAt, viewedAt, ageHours: null, ageMinutes: null, mode, status: mode }; + } + const diffMs = Math.max(0, now.getTime() - parsed.getTime()); + const ageMinutes = Math.floor(diffMs / 60_000); + const ageHours = Math.round(diffMs / 3_600_000); + return { contentAt, viewedAt, ageHours, ageMinutes, mode, status: mode }; } export function resolveLiveFreshness(contentAt: string | null = null, now: Date = new Date()): Freshness { diff --git a/src/lib/rag/answer-composition.ts b/src/lib/rag/answer-composition.ts index 19e84f061..73e5d2d67 100644 --- a/src/lib/rag/answer-composition.ts +++ b/src/lib/rag/answer-composition.ts @@ -98,7 +98,13 @@ function menuKeyFor(queryClass: RagQueryClass, intent: ClinicalQueryIntent): Rel } } -/** Build the related-information menu for a query class and heuristic intent. */ +/** + * Builds the related-information menu for a given query class and heuristic intent. + * + * @param queryClass - The classified query domain + * @param intent - The detected clinical intent + * @returns RelatedInformationMenu object containing key, items, and metadata + */ export function buildRelatedInformationMenu( queryClass: RagQueryClass, intent: ClinicalQueryIntent, @@ -110,13 +116,24 @@ export function buildRelatedInformationMenu( const noMenuLine = "related_information_menu: none — no related-information menu for this question type; apply the Answer sections rules as written"; -/** Serialise a menu as the single `related_information_menu:` prompt line. */ +/** + * Serialises a menu as the single `related_information_menu:` prompt line for model generation. + * + * @param menu - The related information menu to format + * @returns Serialized string for inclusion in the generation prompt + */ export function formatRelatedInformationMenuLine(menu: RelatedInformationMenu): string { if (menu.items.length === 0) return noMenuLine; return `related_information_menu: ${menu.items.map((entry) => `${entry.kind} — ${entry.focus}`).join("; ")}`; } -/** Convenience for the prompt builder: one call, one line. */ +/** + * Convenience helper for the prompt builder: determines and formats the related information menu in one call. + * + * @param queryClass - The classified query domain + * @param intent - The detected clinical intent + * @returns Formatted prompt line + */ export function relatedInformationMenuLine(queryClass: RagQueryClass, intent: ClinicalQueryIntent): string { return formatRelatedInformationMenuLine(buildRelatedInformationMenu(queryClass, intent)); } diff --git a/src/lib/rag/rag-answer-support.ts b/src/lib/rag/rag-answer-support.ts index c67550712..7fafb1223 100644 --- a/src/lib/rag/rag-answer-support.ts +++ b/src/lib/rag/rag-answer-support.ts @@ -14,6 +14,12 @@ import type { export const machineReadableFallbackAnswer = "The indexed sources were not machine-readable enough to produce a formatted answer."; +/** + * Computes an effective score value for a search result, bounding hybrid score boost. + * + * @param result - The search result to evaluate + * @returns The normalized score between 0 and 1 + */ export function scoreValue(result: SearchResult) { const similarity = result.similarity ?? 0; const hybrid = result.hybrid_score ?? similarity; @@ -21,6 +27,13 @@ export function scoreValue(result: SearchResult) { return Math.min(1, hybrid); } +/** + * Derives the aggregate confidence level for an answer based on search results and accepted citations. + * + * @param results - The pool of search results + * @param acceptedCitations - Citations accepted for the answer + * @returns 'unsupported' | 'low' | 'medium' | 'high' + */ export function deriveConfidence( results: SearchResult[], acceptedCitations: Array>, @@ -38,6 +51,12 @@ export function deriveConfidence( return "low"; } +/** + * Extracts a structured fallback reason from a routing reason string. + * + * @param reason - Raw routing reason string + * @returns Matched fallback reason or null + */ export function fallbackReasonFromRouting(reason?: string | null) { if (!reason) return null; return ( @@ -57,6 +76,13 @@ export function isProviderGenerationDegraded(reason?: string | null) { return /(?:^|;\s*)generation_fallback(?::|$)/i.test(reason ?? ""); } +/** + * Aggregates unique memory cards from search results up to the specified limit. + * + * @param results - Search results containing memory cards + * @param limit - Maximum number of cards to collect (defaults to 8) + * @returns Array of deduplicated document memory cards + */ export function collectMemoryCards(results: SearchResult[], limit = 8) { const seen = new Set(); const cards: DocumentMemoryCard[] = []; @@ -72,6 +98,13 @@ export function collectMemoryCards(results: SearchResult[], limit = 8) { return cards; } +/** + * Builds a composite document indexing quality summary across search results. + * + * @param results - The evaluated search results + * @param memoryCards - Collected memory cards for the query + * @returns Standardized DocumentIndexQuality summary object + */ export function buildIndexingQuality(results: SearchResult[], memoryCards: DocumentMemoryCard[]): DocumentIndexQuality { const sourceMetadata = results.map((result) => normalizeSourceMetadata(result.source_metadata)); const indexedQualityRows = results @@ -116,6 +149,13 @@ export function buildIndexingQuality(results: SearchResult[], memoryCards: Docum }; } +/** + * Builds individual score explanations for top search results. + * + * @param results - Ranked search results + * @param limit - Maximum number of explanations to format + * @returns Score explanations array for RagAnswer + */ export function buildAnswerScoreExplanations( results: SearchResult[], limit = 8, @@ -130,6 +170,12 @@ export function buildAnswerScoreExplanations( })); } +/** + * Normalizes all available text fields of a search result into a single lowercase string for gating. + * + * @param result - The search result to extract text from + * @returns Normalized lowercase text combining headings, content, tables, and captions + */ export function evidenceTextForGate(result: SearchResult) { const tableText = (result.table_facts ?? []) .map((fact) => @@ -163,6 +209,9 @@ export function evidenceTextForGate(result: SearchResult) { ).toLowerCase(); } +/** + * Computes relevance score of a memory card relative to a query and query class. + */ function memoryCardAnswerScore(card: DocumentMemoryCard, query: string, queryClass: RagQueryClass) { const content = sourceTextForDisplay(card.content); if (!content) return -1; @@ -206,6 +255,14 @@ function memoryCardAnswerScore(card: DocumentMemoryCard, query: string, queryCla return tokenHits * 0.08 + typeBoost + doseBoost + (card.confidence ?? 0) * 0.08 + lowValueTitlePenalty; } +/** + * Ranks memory cards for answer display based on lexical overlap, card type affinity, and clinical content specificity. + * + * @param cards - Pool of candidate memory cards + * @param query - Clinical search query + * @param queryClass - The classified query domain + * @returns Sorted array of relevant memory cards + */ export function rankMemoryCardsForAnswer(cards: DocumentMemoryCard[], query: string, queryClass: RagQueryClass) { return [...cards] .map((card, index) => ({ diff --git a/src/lib/rag/rag-answer-text.ts b/src/lib/rag/rag-answer-text.ts index 61d52e6a6..41d5ccf9e 100644 --- a/src/lib/rag/rag-answer-text.ts +++ b/src/lib/rag/rag-answer-text.ts @@ -48,10 +48,22 @@ const genericMedicationCasePatterns: Array<[RegExp, string]> = [ [/\bBaclofen\b/g, "baclofen"], ]; +/** + * Normalizes whitespace in text by trimming and collapsing consecutive spaces into single space. + * + * @param value - Raw text string + * @returns Clean single-line trimmed string + */ export function normalizeSectionText(value: string) { return value.trim().replace(/\s+/g, " "); } +/** + * Tokenizes text into lowercase alphanumeric words. + * + * @param text - Input string to tokenize + * @returns Array of lowercase alphanumeric tokens + */ export function splitBalancedWords(text: string) { return text .toLowerCase() @@ -61,6 +73,12 @@ export function splitBalancedWords(text: string) { .filter(Boolean); } +/** + * Checks whether text appears to be leaked JSON syntax, schema artifacts, or punctuation noise. + * + * @param value - String to evaluate + * @returns `true` if the string resembles leaked JSON structure + */ export function looksLikeJsonArtifact(value: string) { const normalized = normalizeSectionText(value); if (!normalized) return true; @@ -119,6 +137,13 @@ export function looksLikeJsonArtifact(value: string) { return false; } +/** + * Sanitizes structured answer text by stripping JSON artifacts, noisy headings, and catalog fragments. + * + * @param value - Raw answer or section text + * @param options - Length/token thresholds and prefix handling options + * @returns Clean sanitized text or empty string if unusable + */ export function sanitizeStructuredText( value: string, options: { minLength?: number; minTokens?: number; keepLeading?: boolean } = {}, @@ -276,11 +301,23 @@ export function polishClinicalAnswerProse(value: string, options: { preserveBold return options.preserveBold ? restoreBoldSpans(polished, boldSpans) : polished; } +/** + * Strips artifacts, normalizes punctuation and brand fragments, and polishes answer prose. + * + * @param value - Raw answer string + * @returns Cleaned and polished prose string + */ export function sanitizeAnswerText(value: string) { const cleaned = sanitizeStructuredText(value, { minLength: 8, minTokens: 2, keepLeading: true }); return cleaned ? polishClinicalAnswerProse(cleaned) : ""; } +/** + * Evaluates whether text contains clinical answer quality issues like unreferenced digits, fragments, or catalog noise. + * + * @param value - Answer string to evaluate + * @returns `true` if a quality defect is detected + */ export function hasClinicalAnswerQualityIssue(value: string) { const normalized = normalizeSectionText(value); if (!normalized) return true; @@ -308,14 +345,34 @@ export function hasClinicalAnswerQualityIssue(value: string) { ); } +/** + * Checks whether section text passes minimum usable token and length requirements after sanitization. + * + * @param value - Raw section text + * @param options - Minimum token and length constraints + * @returns `true` if the section text is usable + */ export function isUsableAnswerSectionText(value: string, options: { minTokens?: number; minLength?: number } = {}) { return Boolean(sanitizeStructuredText(value, options)); } +/** + * Safely casts an unknown value to a non-array record map or empty object. + * + * @param value - Input value + * @returns Record map + */ export function safeRecord(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } +/** + * Safely extracts a non-empty string value from a metadata record by key. + * + * @param metadata - Metadata record map + * @param key - Field key to look up + * @returns Trimmed string value or null + */ export function metadataText(metadata: Record, key: string) { const value = metadata[key]; return typeof value === "string" && value.trim() ? value.trim() : null; diff --git a/src/lib/rag/rag-candidate-sources.ts b/src/lib/rag/rag-candidate-sources.ts index 2029e7e9f..00ba9b92d 100644 --- a/src/lib/rag/rag-candidate-sources.ts +++ b/src/lib/rag/rag-candidate-sources.ts @@ -91,6 +91,16 @@ function mergeLegacyAccessRows(ownerRows: T[], publicRows: T[], matchCount: u return merged.slice(0, limit); } +/** + * Executes a versioned Supabase retrieval RPC with transparent fallback to legacy RPCs and owner-scope merging. + * + * @param supabase - Authenticated or admin Supabase client + * @param versionedName - The modern v2 RPC function name + * @param legacyName - The legacy v1 RPC function name + * @param args - RPC arguments + * @param signal - Optional AbortSignal for query cancellation + * @returns Object with retrieved data rows or error + */ export async function callVersionedRetrievalRpc( supabase: ReturnType, versionedName: string, @@ -135,7 +145,13 @@ export async function callVersionedRetrievalRpc }; } -/** Record hybrid rpc error. */ +/** + * Logs and attaches telemetry for Supabase hybrid retrieval RPC errors. + * + * @param telemetry - Telemetry bucket to record error into + * @param rpc - Name of the failing RPC + * @param error - Supabase RPC error object + */ export function recordHybridRpcError(telemetry: SearchTelemetry | undefined, rpc: string, error: SupabaseRpcError) { if (!error) return; const code = error.code ?? "unknown"; @@ -169,7 +185,13 @@ function recordTextVariantFanout( if (earlyExit) telemetry.text_variant_early_exit = true; } -/** Merge search results. */ +/** + * Merges primary and secondary search results, preserving the highest score per chunk ID. + * + * @param primary - Primary search results + * @param secondary - Secondary search results + * @returns Deduplicated array of merged search results + */ export function mergeSearchResults(primary: SearchResult[], secondary: SearchResult[]) { const merged = new Map(); @@ -653,14 +675,26 @@ export async function searchDocumentLookupFastPath(args: { .slice(0, args.matchCount); } -/** Memory card chunk score. */ +/** + * Computes the normalized score for a memory card candidate chunk. + * + * @param card - Document memory card with hybrid score or confidence + * @returns Number between 0 and 1 + */ export function memoryCardChunkScore(card: DocumentMemoryCard) { const hybridScore = Number(card.metadata?.memory_hybrid_score); if (Number.isFinite(hybridScore) && hybridScore > 0) return Math.min(1, hybridScore); return Math.min(1, card.confidence ?? 0.5); } -/** Load chunks for memory cards. */ +/** + * Loads and attaches chunk content and document metadata for matched memory cards. + * + * @param supabase - Admin Supabase client + * @param cards - Array of memory cards to fetch chunks for + * @param accessScope - Current tenant / owner visibility scope + * @returns Search results corresponding to the memory cards + */ export async function loadChunksForMemoryCards( supabase: ReturnType, cards: DocumentMemoryCard[], @@ -820,7 +854,12 @@ async function loadRowsWithCache(args: { return Promise.all(args.ids.map((id) => args.cache.get(cacheKey(id))!)); } -/** Load chunks for signal matches. */ +/** + * Hydrates full chunk records, document metadata, and images for signal matches (tables, embedding fields, index units). + * + * @param args - Object with supabase client, signal matches, access scope, and optional chunk cache + * @returns Fully hydrated SearchResult objects + */ export async function loadChunksForSignalMatches(args: { supabase: ReturnType; matches: ChunkSignalMatch[]; @@ -1036,7 +1075,12 @@ export async function searchTableFactCandidates(args: { }); } -/** Search embedding field candidates. */ +/** + * Retrieves candidate chunks by searching dense and sparse embedding fields. + * + * @param args - Search options with query, query embedding, owner/document filters, and limits + * @returns Array of SearchResult candidates enriched with section context + */ export async function searchEmbeddingFieldCandidates(args: { supabase: ReturnType; query: string; @@ -1091,7 +1135,12 @@ export async function searchEmbeddingFieldCandidates(args: { }); } -/** Search index unit candidates. */ +/** + * Retrieves candidate chunks by searching structured document index units (tables, workflows, algorithms). + * + * @param args - Search options with query, vector embedding, scope filters, and limits + * @returns Array of SearchResult candidates matched against index units + */ export async function searchIndexUnitCandidates(args: { supabase: ReturnType; query: string; @@ -1223,7 +1272,12 @@ export function applyMemoryBoostArtifacts(query: string, candidates: SearchResul }; } -/** With memory boosted candidates. */ +/** + * Enriches candidate search results with memory card boosts and associated memory chunks. + * + * @param args - Search arguments and candidate pool + * @returns Object with boosted search results and referenced memory cards + */ export async function withMemoryBoostedCandidates(args: { supabase: ReturnType; query: string; diff --git a/src/lib/rag/rag-claim-support.ts b/src/lib/rag/rag-claim-support.ts index b4df66607..e0d58dfa4 100644 --- a/src/lib/rag/rag-claim-support.ts +++ b/src/lib/rag/rag-claim-support.ts @@ -447,12 +447,12 @@ function compatibleDirectiveActions(claim: string, evidence: string) { } // Exported so the extractive figure-promotion guard (rag-extractive-answer.ts) can check a -// candidate fact's value atoms against the EXACT corpus this module assesses claims with. -// Keep the two in lockstep: a promoted figure verified against a wider corpus (e.g. one that -// includes adjacent_context) would pass numeric verification and then trip -// claim_support_high_risk_gap here. (Precision note: this corpus also includes -// index_unit metadata that the numeric-verification corpus does not, so it is not a strict -// subset — that sole divergence fails safe: the guard passes, the numeric gate then nukes.) +/** + * Extracts and concatenates all text fields of a source result into the evidence corpus used for claim verification. + * + * @param source - Search result to format + * @returns Combined evidence text + */ export function sourceEvidenceText(source: SearchResult) { return [ source.section_heading, @@ -650,13 +650,26 @@ function sourceBandConflicts(source: SearchResult) { return sourceBandConflictSegments(source).flatMap((value) => detectLabelledNumericBandConflicts(value)); } -/** Same-chunk labelled-band conflicts that specifically affect delivered text. */ +/** + * Detects same-chunk labelled-band conflicts that specifically affect delivered text. + * + * @param source - Candidate search result + * @param text - Answer or section text + * @param bandContext - Optional surrounding band context + * @returns Array of affecting labelled numeric band conflicts + */ export function sourceLabelledNumericBandConflictsAffectingText(source: SearchResult, text: string, bandContext = "") { return sourceBandConflictSegments(source).flatMap((segment) => labelledNumericBandConflictsAffectingText(text, segment, bandContext), ); } +/** + * Checks whether a search result source contains internal labelled numeric band contradictions. + * + * @param source - Search result to inspect + * @returns `true` if conflicting numeric bands are present + */ export function sourceHasLabelledNumericBandConflict(source: SearchResult) { return sourceBandConflicts(source).length > 0; } @@ -1150,6 +1163,12 @@ function assessClaimSupportDetails(answer: RagAnswer) { return { claims, evidenceAssessments, inputs, unassessedClaims }; } +/** + * Evaluates support status and evidence assessments for all clinical claims in an answer without mutating the answer. + * + * @param answer - RAG answer to assess + * @returns Object with supportedClaims and evidenceAssessments + */ export function assessClaimSupport(answer: RagAnswer) { const { claims, evidenceAssessments } = assessClaimSupportDetails(answer); return { claims, evidenceAssessments }; @@ -1162,6 +1181,12 @@ function enforceUnassessedNumericClaims(answer: RagAnswer, unassessedClaims: str : answer; } +/** + * Assesses clinical claim support across top-level answer and sections, enforcing fail-closed degradation upon unsupported high-risk claims. + * + * @param answer - The candidate RAG answer + * @returns Verified and safely degraded or augmented RAG answer + */ export function assessAndEnforceClaimSupport(answer: RagAnswer): RagAnswer { const { claims, evidenceAssessments, inputs, unassessedClaims } = assessClaimSupportDetails(answer); if (!answer.grounded || answer.confidence === "unsupported" || answer.responseMode === "evidence_gap") { diff --git a/src/lib/rag/rag-extractive-answer.ts b/src/lib/rag/rag-extractive-answer.ts index 31733a66e..6281869bb 100644 --- a/src/lib/rag/rag-extractive-answer.ts +++ b/src/lib/rag/rag-extractive-answer.ts @@ -2446,24 +2446,13 @@ function derivedArtifactsContainProceduralFlowEdge(value: unknown) { function sourceBackedDocumentFallbackIntent( query: string, queryClass: RagQueryClass, - intent: AnswerIntent, + _intent: AnswerIntent, results: SearchResult[], ) { if (results.length === 0) return false; const strongestScore = Math.max(...results.map(scoreValue)); if (strongestScore < 0.45) return false; - const normalized = normalizeSectionText(query).toLowerCase(); - const sourceBackedProcedureQuery = - /\b(?:process|procedure|protocol|pathway|workflow|steps?|requirements?|criteria|guidance|document)\b/.test( - normalized, - ); - if (!sourceBackedProcedureQuery) return false; - return ( - intent === "document_lookup" || - intent === "pathway_referral" || - queryClass === "document_lookup" || - queryClass === "broad_summary" - ); + return documentSupportListIntent(query, queryClass); } /** Source-backed review intent for broad medication-management evidence that cannot be safely collapsed into facts. */ @@ -2485,10 +2474,11 @@ function sourceBackedManagementReviewIntent( } /** Document support list intent. */ -function documentSupportListIntent(query: string, queryClass: RagQueryClass) { +export function documentSupportListIntent(query: string, queryClass: RagQueryClass) { return ( classifyAnswerIntent(query, queryClass) === "document_lookup" && - /\b(?:support|supports|supporting|sources?|documents?|guidelines?)\b/i.test(query) + /\b(?:support|supports|supporting|sources?|documents?|guidelines?)\b/i.test(query) && + /\b(?:which|what|list|show|name|where|find|provide)\b/i.test(query) ); } @@ -2928,6 +2918,15 @@ function sourceBackedFallbackSubject(query: string) { const normalized = normalizeSectionText(canonicalQuery) .replace(/[?!.]+$/, "") .trim(); + // Do not echo a requested governance status into the source-only fallback. + // "Is this protocol approved for use?" must become a neutral topic rather + // than prose that appears to affirm the unverified status. + const governanceStatusQuestion = normalized.match( + /^(?:is|are|was|were)\s+(.+?)\s+(?:approved|authori[sz]ed|validated|verified|current)\b/i, + ); + if (governanceStatusQuestion?.[1]) { + return lowerFirst(governanceStatusQuestion[1]); + } const subject = normalized .replace(/^summari[sz]e\s+(?:the\s+)?/i, "") .replace(/^what\s+(?:is|are)\s+(?:the\s+)?(?:process|requirements?)\s+for\s+/i, "") @@ -3301,12 +3300,18 @@ export function hasInvalidModelEvidenceIds(answer: Pick "...15"), feeding the model clipped clinical -// facts. Prefer the last sentence boundary that still keeps most of the budget (end cleanly, no -// ellipsis); otherwise cut on a word boundary and never strand a bare number whose unit/context was -// cut off, so a dose or threshold can never be presented as a truncated figure. +/** + * Performs boundary-aware, number-safe truncation of text handed to the model. + * + * @param text - Raw source text + * @param limit - Maximum character limit + * @returns Truncated text ending at sentence or word boundary with numbers preserved + */ export function truncateForModel(text: string, limit: number) { if (text.length <= limit) return text; const window = text.slice(0, limit); @@ -28,26 +30,25 @@ export function truncateForModel(text: string, limit: number) { return `${numberSafe || base}...`; } +/** + * Strips formatting noise and whitespace, then applies boundary-aware truncation. + * + * @param text - Raw context string + * @param limit - Character budget + * @returns Compacted text suitable for model prompt inclusion + */ export function compactContextText(text: string, limit: number) { const compact = sourceTextForModel(text).replace(/\s+/g, " ").trim(); return truncateForModel(compact, limit); } -// Evidence-safe compaction for the derived/context fields (synopsis, adjacent context, -// table facts, memory cards, image table text). Two orderings matter: -// 1. Neutralization runs AFTER glyph normalization, not before: sourceTextForModel -// repairs zero-width / homoglyph / ligature obfuscation (via normalizeExtractedGlyphs), -// so neutralizing its output — rather than the raw string — closes the -// "ig​nore all previous instructions" evasion where the denylist regex never -// matched the obfuscated raw text (threat model mitigation #3). -// 2. escapeEvidenceFenceSentinels defuses any forged `<<<…>>>` sentinel the field -// carries, so an attacker who lands text in an UNfenced derived field can no longer -// emit a close-then-reopen pair that straddles the real evidence fence (Vector E). -// Only result.content is wrapped in a full fence; every other field is escaped in place -// here, which closes the same hole at a fraction of the prompt-token / latency cost of a -// per-field wrapper (measured: full per-field wrapping added ~940 input tokens/answer and -// tipped near-timeout strong-route answers over budget). The answerInstructions provenance -// boundary already declares every source-derived field untrusted, fenced or not. +/** + * Compacts evidence text while neutralizing prompt injection instructions and escaping fence sentinels. + * + * @param text - Source evidence text + * @param limit - Character limit + * @returns Sanitized and bounded evidence text + */ export function compactEvidenceText(text: string, limit: number) { const compact = escapeEvidenceFenceSentinels(neutralizePromptInstructions(sourceTextForModel(text))) .replace(/\s+/g, " ") @@ -55,13 +56,12 @@ export function compactEvidenceText(text: string, limit: number) { return truncateForModel(compact, limit); } -// Short document-identity fields (title, file name, image caption/label/title, index -// warnings) are NOT free clinical prose, so they skip the noise-stripping model -// pipeline — but they still reach the prompt and were previously inserted RAW -// (threat model Vectors B and C: a title/filename/caption is a viable injection -// channel). Glyph-normalize first so obfuscation can't evade the denylist, then -// neutralize, then escape any forged fence sentinel. Kept on one line; never -// truncated, so a real source title is intact. +/** + * Sanitizes identity fields (title, filename, image labels) against injection and homoglyphs. + * + * @param text - Raw identity string + * @returns Normalized single-line string with fence sentinels defused + */ export function neutralizeIdentityField(text: string) { return escapeEvidenceFenceSentinels(neutralizePromptInstructions(normalizeExtractedGlyphs(text))) .replace(/\s+/g, " ") @@ -142,6 +142,13 @@ function formatTableFactForSourceBlock( ); } +/** + * Builds the formatted, sanitized source evidence block provided to the model prompt. + * + * @param results - Search results to assemble into the prompt source block + * @param options - Contextual options such as query and classified query class + * @returns Formatted markdown block with governance lines, fenced excerpts, and table/image facts + */ export function buildRagSourceBlock(results: SearchResult[], options?: RagSourceBlockOptions) { const richTableContext = richTableSourceContextEnabled(options); const sources = results diff --git a/src/lib/rag/rag-source-segmentation.ts b/src/lib/rag/rag-source-segmentation.ts index 646522416..3b7edab9d 100644 --- a/src/lib/rag/rag-source-segmentation.ts +++ b/src/lib/rag/rag-source-segmentation.ts @@ -17,7 +17,12 @@ const wrappedAgitationConsultantLeadPattern = /\bDosing\s+frequencies\s+outside\s+the\s+recommended\s+guidelines\s+require\s+Consultant\s*$/i; const wrappedAgitationConsultantContinuationPattern = /^Psychiatrist\s+approval\.\s*$/i; -/** Rejoin a PDF visual wrap that separates an escalation action from its clinical recipient. */ +/** + * Rejoins a PDF visual wrap that separates an escalation action from its clinical recipient. + * + * @param value - Raw text containing potentially broken escalation lines + * @returns Reflowed text with escalation directives reunited with recipients + */ export function reflowWrappedEscalationRecipientLines(value: string) { const reflowed: string[] = []; for (const rawLine of value.replace(/\r\n?/g, "\n").split("\n")) { @@ -37,7 +42,12 @@ export function reflowWrappedEscalationRecipientLines(value: string) { return reflowed.join("\n"); } -/** Rejoin the observed FSH ECT booking-system name when PDF wrapping splits its governing directive. */ +/** + * Rejoins the observed FSH ECT booking-system name when PDF wrapping splits its governing directive. + * + * @param value - Raw text containing broken ECT booking sentences + * @returns Reflowed text with BASE scheduling system name attached to lead directive + */ export function reflowWrappedEctBookingSystemLines(value: string) { const reflowed: string[] = []; for (const rawLine of value.replace(/\r\n?/g, "\n").split("\n")) { @@ -57,7 +67,12 @@ export function reflowWrappedEctBookingSystemLines(value: string) { return reflowed.join("\n"); } -/** Rejoin the observed EMHS agitation-dose bullet continuations without crossing bullet boundaries. */ +/** + * Rejoins the observed EMHS agitation-dose bullet continuations without crossing bullet boundaries. + * + * @param value - Raw text containing broken agitation bullet continuations + * @returns Reflowed text with dose limits and doctor approval clauses restored + */ export function reflowWrappedAgitationDoseLines(value: string) { const reflowed: string[] = []; for (const rawLine of value.replace(/\r\n?/g, "\n").split("\n")) { diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts index 6cc3823b7..1ddb92b14 100644 --- a/src/lib/rag/rag.ts +++ b/src/lib/rag/rag.ts @@ -95,6 +95,7 @@ export { completeExtractiveSentence, generatedAnswerQualityFailureReason, isBareDefinitionQuestion, + isBareDocumentSupportListAnswer, sourceBackedGenerationTimeoutAnswer, strongReasoningEffortForQueryClass, } from "@/lib/rag/rag-extractive-answer"; diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 91a3dd86b..aad72c6e1 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-08-27T06:12:59.010Z", + "generated_at": "2026-08-31T04:26:12.270Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "4048b5d3ea15289349469e5f3c5b1f8c4328b94116ae09b812fe135005bce0b5", - "replay_seconds": 11, + "schema_sha256": "f40e10701d044b41b6c39dd441a9956201e41b7af6e083f9913112092e2ef7ae", + "replay_seconds": 17, "snapshot": { "views": [ { @@ -7321,7 +7321,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "cb65883a561cb1f5cd2247213f417a41", + "def_hash": "e2356565197b3d4c13f1b3d7a624696b", "signature": "public.correct_clinical_query_terms(text,real)" }, { @@ -7329,7 +7329,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "54f1ab21c9d7cee9555b187dec088371", + "def_hash": "93bd4e366deaf415b35e8a287d0067fe", "signature": "public.create_uploaded_document_with_ingestion_job(jsonb,integer)" }, { @@ -7723,14 +7723,6 @@ "def_hash": "8dbddbaae2cceed984bfeb7c0ee61df0", "signature": "public.purge_expired_rag_query_misses(integer)" }, - { - "acl": [ - "postgres=X/postgres", - "service_role=X/postgres" - ], - "def_hash": "a6b80c8973f7f5a6eec0d4191a75d1ba", - "signature": "public.purge_expired_rag_response_cache()" - }, { "acl": [ "postgres=X/postgres", @@ -7912,7 +7904,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "fefc17c166d7a65a9816e73a14341c1a", + "def_hash": "0612ff7fc739f006b2a4251c5081cb20", "signature": "public.set_owner_id_from_auth_uid()" }, { diff --git a/supabase/migrations/20260831100000_harden_security_definer_search_paths_and_purge_cache.sql b/supabase/migrations/20260831100000_harden_security_definer_search_paths_and_purge_cache.sql new file mode 100644 index 000000000..f5e0651e4 --- /dev/null +++ b/supabase/migrations/20260831100000_harden_security_definer_search_paths_and_purge_cache.sql @@ -0,0 +1,182 @@ +-- Harden SECURITY DEFINER search paths and consolidate purge_expired_rag_response_cache overloads. + +-- 1. Drop the legacy zero-argument unbounded overload of purge_expired_rag_response_cache. +drop function if exists public.purge_expired_rag_response_cache(); + +-- 2. Harden set_owner_id_from_auth_uid search_path with pg_temp. +create or replace function public.set_owner_id_from_auth_uid() + returns trigger + language plpgsql + security definer + set search_path = public, auth, pg_temp +as $function$ +begin + if new.owner_id is null then + new.owner_id := auth.uid(); + end if; + return new; +end; +$function$; + +revoke execute on function public.set_owner_id_from_auth_uid() from public, anon, authenticated; +grant execute on function public.set_owner_id_from_auth_uid() to service_role; + +-- 3. Harden correct_clinical_query_terms search_path with pg_temp. +create or replace function public.correct_clinical_query_terms( + input_query text, + min_sim real default 0.45 +) +returns text +language plpgsql +stable +security definer +set search_path = pg_catalog, extensions, pg_temp +as $$ +declare + tokens text[]; + tok text; + best text; + best_sim real; + corrected text[] := array[]::text[]; + changed boolean := false; +begin + if input_query is null or length(trim(input_query)) = 0 then + return input_query; + end if; + tokens := regexp_split_to_array(lower(input_query), '\s+'); + foreach tok in array tokens loop + if length(tok) < 4 then + corrected := corrected || tok; + continue; + end if; + best := null; + best_sim := 0; + select candidate.term, candidate.match_sim + into best, best_sim + from ( + ( + select + lower(canonical) as term, + similarity(lower(alias), tok) as match_sim + from public.rag_aliases + where enabled + and owner_id is null + and length(alias) between 4 and 40 + and length(canonical) between 4 and 40 + and lower(alias) % tok + order by similarity(lower(alias), tok) desc, lower(alias) + limit 32 + ) + union all + ( + select + lower(canonical) as term, + similarity(lower(canonical), tok) as match_sim + from public.rag_aliases + where enabled + and owner_id is null + and length(canonical) between 4 and 40 + and length(canonical) between 4 and 40 + and lower(canonical) % tok + order by similarity(lower(canonical), tok) desc, lower(canonical) + limit 32 + ) + union all + ( + select + word as term, + similarity(word, tok) as match_sim + from public.document_title_words + where length(word) between 4 and 40 + and word % tok + order by similarity(word, tok) desc, word + limit 32 + ) + ) candidate + order by candidate.match_sim desc, candidate.term + limit 1; + if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then + corrected := corrected || best; + changed := true; + else + corrected := corrected || tok; + end if; + end loop; + if not changed then + return input_query; + end if; + return array_to_string(corrected, ' '); +end; +$$; + +revoke execute on function public.correct_clinical_query_terms(text, real) + from public, anon, authenticated; +grant execute on function public.correct_clinical_query_terms(text, real) to service_role; + +-- 4. Harden create_uploaded_document_with_ingestion_job search_path with pg_temp. +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, pg_temp +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 aef7a1b05..eacdad303 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -860,23 +860,7 @@ create index if not exists rag_response_cache_expiry_idx create index if not exists rag_response_cache_owner_kind_idx on public.rag_response_cache(owner_id, cache_kind, updated_at desc); -create or replace function public.purge_expired_rag_response_cache() -returns integer -language plpgsql -security definer -set search_path = public, extensions, pg_temp -as $$ -declare - v_deleted integer; -begin - delete from public.rag_response_cache where expires_at <= now(); - get diagnostics v_deleted = row_count; - return v_deleted; -end; -$$; -revoke execute on function public.purge_expired_rag_response_cache() from public, anon, authenticated; -grant execute on function public.purge_expired_rag_response_cache() to service_role; create unique index if not exists rag_response_cache_key_idx on public.rag_response_cache( coalesce(owner_id, '00000000-0000-0000-0000-000000000000'::uuid), diff --git a/tests/answer-responsiveness-gate.test.ts b/tests/answer-responsiveness-gate.test.ts index af4b40b71..36cefcace 100644 --- a/tests/answer-responsiveness-gate.test.ts +++ b/tests/answer-responsiveness-gate.test.ts @@ -142,6 +142,12 @@ describe("generation-timeout fallback wording (P2)", () => { expect(text).toMatch(/agitation and arousal/i); expect(text).not.toMatch(/agitaton|arousl/i); }); + + it("does not echo a yes/no governance claim into the source-only fallback", () => { + const text = sourceBackedGenerationTimeoutAnswer("Is this protocol approved for use?"); + expect(text).toContain("this protocol"); + expect(text).not.toMatch(/approved for use|verified current/i); + }); }); describe("offline extractive naturalness — completeExtractiveSentence (P4)", () => { diff --git a/tests/developer-hub-components.dom.test.tsx b/tests/developer-hub-components.dom.test.tsx index 6d447ddd6..2b24fd356 100644 --- a/tests/developer-hub-components.dom.test.tsx +++ b/tests/developer-hub-components.dom.test.tsx @@ -69,6 +69,19 @@ describe("FreshnessStamp", () => { ); expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/\b1 hour old\b/); }); + + it("renders live status when freshness status is live", () => { + render( + , + ); + const stamp = screen.getByTestId("developer-hub-freshness"); + expect(stamp).toHaveTextContent(/Ingestion jobs read live/i); + expect(stamp).toHaveTextContent(new RegExp(`viewed ${mediumDate("2026-08-21T00:00:00Z")}`)); + expect(stamp).not.toHaveTextContent(/revision unknown/i); + }); }); describe("EnvironmentStrip", () => { diff --git a/tests/developer-ingestion-page.dom.test.tsx b/tests/developer-ingestion-page.dom.test.tsx index ad883566a..8e646dae7 100644 --- a/tests/developer-ingestion-page.dom.test.tsx +++ b/tests/developer-ingestion-page.dom.test.tsx @@ -72,10 +72,6 @@ describe("developer ingestion page — shell and freshness (plan §8)", () => { render(); const checkedAt = await screen.findByTestId("developer-ingestion-checked-at"); - // Falsifying edit: deleting the `resolveFreshnessFrom(fetchedAt, ...)` call - // (or the element it feeds) removes this node entirely, so the assertion - // above already falsifies an omission; asserting real content here also - // falsifies a component that renders the testid but leaves it empty. expect(checkedAt).toHaveTextContent(/checked/i); expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/read live on demand/i); }); @@ -159,6 +155,14 @@ describe("developer ingestion page — the four states (plan §4)", () => { expect(errorState).not.toHaveTextContent(/could not reach/i); expect(errorState).not.toHaveTextContent(/No ingestion jobs/i); }); + + it("unparseable response body: reports unparseable response body rather than network failure", async () => { + fetchMock.mockResolvedValueOnce(new Response("502 Bad Gateway", { status: 200 })); + render(); + const errorState = await screen.findByTestId("developer-ingestion-fetch-error"); + expect(errorState).toHaveTextContent(/unparseable response body/i); + expect(errorState).not.toHaveTextContent(/could not reach the ingestion jobs endpoint/i); + }); }); describe("developer ingestion page — counts render as given", () => { diff --git a/tests/extractive-answer-formatting.test.ts b/tests/extractive-answer-formatting.test.ts index bc8b959f3..d6a869f50 100644 --- a/tests/extractive-answer-formatting.test.ts +++ b/tests/extractive-answer-formatting.test.ts @@ -6,9 +6,11 @@ import { } from "../src/lib/answer-verification"; import { buildExtractiveAnswer, + documentSupportListIntent, finalizeRagAnswerQuality, generatedAnswerQualityFailureReason, hasMaximumDoseEvidence, + isBareDocumentSupportListAnswer, isExplicitEscalationQuery, isProviderSourceGapGeneratedAnswer, isSafeExtractiveFallbackCandidate, @@ -2673,4 +2675,72 @@ describe("monitoring evidence gate parity (run-#60 miss class)", () => { const plain = (answer.answer ?? "").replace(/\*\*/g, ""); expect(plain).toMatch(/3 hours/i); }); + + describe("bare document title list guard (#S4R2W3)", () => { + const bareTitleList = + "I found 3 indexed documents that support this query: Aggression and Agitation Management Procedure; High Risk Assessment and Clinical Escalation; Clinical De-escalation Protocol."; + + it("identifies bare document support list answers", () => { + expect(isBareDocumentSupportListAnswer(bareTitleList)).toBe(true); + expect(isBareDocumentSupportListAnswer("I found one indexed document that supports this query: Policy A.")).toBe( + true, + ); + expect( + isBareDocumentSupportListAnswer("Aggression management begins with verbal de-escalation in a calm area."), + ).toBe(false); + }); + + it("rejects bare document support list answers for substantive clinical queries", () => { + const candidate: RagAnswer = { + answer: bareTitleList, + grounded: true, + confidence: "medium", + citations: [ + { + chunk_id: "doc-1", + title: "Aggression Procedure", + document_id: "doc-1", + file_name: "Aggression Procedure.pdf", + page_number: 1, + chunk_index: 0, + }, + ], + sources: [{ id: "doc-1", title: "Aggression Procedure", document_id: "doc-1", content: "..." } as SearchResult], + unverifiedNumericTokens: [], + }; + + const clinicalQuery = "What is the duress procedure pathway?"; + const clinicalQueryClass: RagQueryClass = "unsupported_or_general"; + expect(documentSupportListIntent(clinicalQuery, clinicalQueryClass)).toBe(false); + expect(generatedAnswerQualityFailureReason(candidate, clinicalQuery, clinicalQueryClass)).toBe( + "bare_document_title_list", + ); + expect(isSafeExtractiveFallbackCandidate(candidate, clinicalQuery, clinicalQueryClass)).toBe(false); + }); + + it("accepts bare document support list answers when the query explicitly asks for supporting documents", () => { + const candidate: RagAnswer = { + answer: bareTitleList, + grounded: true, + confidence: "medium", + citations: [ + { + chunk_id: "doc-1", + title: "Aggression Procedure", + document_id: "doc-1", + file_name: "Aggression Procedure.pdf", + page_number: 1, + chunk_index: 0, + }, + ], + sources: [{ id: "doc-1", title: "Aggression Procedure", document_id: "doc-1", content: "..." } as SearchResult], + unverifiedNumericTokens: [], + }; + + const docQuery = "Which documents support lithium monitoring?"; + const docQueryClass: RagQueryClass = "document_lookup"; + expect(documentSupportListIntent(docQuery, docQueryClass)).toBe(true); + expect(generatedAnswerQualityFailureReason(candidate, docQuery, docQueryClass)).toBeNull(); + }); + }); }); diff --git a/tests/rag-extractive-procedural-artifact.test.ts b/tests/rag-extractive-procedural-artifact.test.ts index 1cb30e562..21a99a485 100644 --- a/tests/rag-extractive-procedural-artifact.test.ts +++ b/tests/rag-extractive-procedural-artifact.test.ts @@ -125,7 +125,7 @@ describe("extractive procedural comparator artifacts", () => { expect(finalized.supportedClaims?.flatMap((claim) => claim.supportingChunkIds) ?? []).toEqual([]); }); - it("builds the document support fallback only from clean results when the set is mixed", () => { + it("fails closed instead of returning a bare document-title list when the clean result is only document-level", () => { const cleanDocument = ectResult({ id: "ect-clean-document", document_id: "ect-clean-document-record", @@ -139,12 +139,24 @@ describe("extractive procedural comparator artifacts", () => { const answer = extractiveAnswer([ectResult({}), cleanDocument]); - expect(answer.grounded).toBe(true); - expect(answer.answer).toContain("Current ECT Workflow Overview"); + expect(answer.grounded).toBe(false); + expect(answer.confidence).toBe("unsupported"); + expect(answer.answer).toBe("No current source with ECT referral criteria was found."); + expect(answer.answer).not.toContain("Current ECT Workflow Overview"); expect(answer.answer).not.toContain("ECT Procedure (AKG)"); expect(answer.citations.map((citation) => citation.chunk_id)).toEqual([cleanDocument.id]); expect(answer.sources.map((source) => source.id)).toEqual([cleanDocument.id]); expect(answer.quoteCards?.map((quote) => quote.chunk_id) ?? []).not.toContain("ect-artifact"); + expect(answer.answerSections).toEqual([]); + + const finalized = finalizeRagAnswerQuality(answer, "What is the process for ECT procedure?", "document_lookup", [ + ectResult({}), + cleanDocument, + ]); + expect(finalized.grounded).toBe(false); + expect(finalized.citations).toEqual([]); + expect(finalized.sources.map((source) => source.id)).toEqual([cleanDocument.id]); + expect(finalized.sources.map((source) => source.id)).not.toContain("ect-artifact"); }); it("rebuilds every user-visible derived artifact after removing a mixed-set flow edge", () => { From 980a48a454fa9faf7ad2cc9c9a932f007d476d3b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:38:46 +0800 Subject: [PATCH 09/50] fix(tooling): execute PR group 1 tooling, CI/CD, scripts, and governance sweep (#2476) --- AGENTS.md | 6 +- bundle-budget.json | 6 + data/repo-awareness-snapshot.json | 48 +++++++- docs/audit/live-drift-forensics-2026-08.md | 2 +- .../084e06ec-e097-4b27-933d-2c3138ffbb01.json | 11 ++ ...9-ward-flow-phase-3-role-screens-design.md | 2 +- scripts/check-bundle-budget.mjs | 110 ++++++++++++++++++ scripts/check-docs-links.mjs | 88 ++++++++++++-- scripts/check-repo-awareness-snapshot.ts | 77 +++++++++--- scripts/eval-retrieval.ts | 50 +++++++- scripts/run-playwright.mjs | 4 + .../account-setup-dialog.tsx | 5 +- src/components/ui/retrieval-state-banner.tsx | 24 +++- tests/bundle-budget.test.ts | 76 +++++++++++- tests/check-docs-links.test.ts | 42 ++++++- tests/eval-retrieval.test.ts | 68 +++++++++++ tests/repo-awareness-gate.test.ts | 42 ++++++- 17 files changed, 616 insertions(+), 45 deletions(-) create mode 100644 docs/outstanding-issues-inbox/084e06ec-e097-4b27-933d-2c3138ffbb01.json diff --git a/AGENTS.md b/AGENTS.md index 03b80a302..99c8daff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -422,9 +422,9 @@ Output-style plugins such as caveman mode may compress prose. They must never co - **Always paste the decisive line.** Report gates with real output, not a summary. Under heavy-lock contention, `npm run verify:ui` queues Playwright admission for up to 15 minutes and, if still blocked at the deadline, exits `75` with a `DATABASE_HEAVY_RUN_ADMISSION_BUSY` marker - (`run-playwright.mjs`) — a distinct non-zero code from an ordinary test failure, so tooling can - tell "blocked, retry" apart from "red", but it never soft-skips green either way. When the gate - does run, grep for the "N passed" line; exit 0 alone is not proof. + (`run-playwright.mjs`). On test failure it propagates Playwright's non-zero exit code, and on + wrapper/build error it exits `1`. Tooling and callers must check both the exit code and the decisive + output line (such as "N passed"); neither code 0 alone nor a raw exit code is sufficient proof. - **State verified versus assumed.** Calibration is not filler. Say what was actually run, what was read, and what is inferred. Do not drop uncertainty to save tokens. - **Third-party fix claims stay unverified until checked.** Bot or agent claims that a fix landed diff --git a/bundle-budget.json b/bundle-budget.json index a377012db..3cc5044d3 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -9,6 +9,12 @@ "gzipBytes": 627814, "tolerancePct": 25 }, + "serverPages": { + "/mockups/development/review-state": { + "rawBytesCeiling": 2500000, + "gzipBytesCeiling": 350000 + } + }, "routes": { "/": { "gzipBytes": 285184, diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index be9dcfb6b..1d01f42dd 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "f340cd0b9052459a438ab8ae8d52de19c88dd55e", - "committed_at": "2026-08-29T12:26:43+08:00" + "sha": "e3df71ece467ecdd3588c730a1ba1b89a3c1e226", + "committed_at": "2026-08-31T11:25:44+08:00" }, "routes": { "modes": [ @@ -4278,6 +4278,38 @@ }, "review_state": { "records": [ + { + "date": "2026-08-31", + "ref": "codex/answer-surface-compact-20260830", + "head": "705561dd1f9b1ac8f72c7a4858e3819b9ee5a40e", + "scope": "compact answer source safety and library UI", + "outcome": "No P0-P2 findings; compact source status, answer utilities, safety row, and library placement ready for PR", + "checks": "13 focused DOM tests passed; targeted Chromium 1/1 passed; lint and typecheck passed; build passed 1998 routes; design contracts passed; production-readiness CI READY; offline RAG 628/628 and adversarial 25/25 passed; full unit 11656 passed with 6 unrelated Windows Claude Cloud harness exit-127 failures; no provider-backed checks run" + }, + { + "date": "2026-08-30", + "ref": "codex/smart-natural-search-current-main", + "head": "7190c2ccd87dfc25e49e488b22705fb6b7b60931", + "scope": "Smart natural search CI reconciliation exact-tree review", + "outcome": "No open P0/P1/P2 findings; maintainability blocker fixed by cohesive extraction", + "checks": "maintainability budgets; 86 focused Vitest; provider-free Chromium Smart suite; lint; typecheck; formatting; diff check" + }, + { + "date": "2026-08-30", + "ref": "codex/smart-natural-search-current-main", + "head": "8de6dae0e541166dad23523ca3a4e2340eb6c217", + "scope": "Smart natural search exact-tree implementation and review", + "outcome": "P2 findings fixed; no open P0/P1/P2 findings", + "checks": "105 focused contracts; enabled Chromium 6 passed/1 skipped; default-off Chromium 1 passed; production build passed; PR-local 11616 passed with 6 exact-main Windows Bash failures" + }, + { + "date": "2026-08-30", + "ref": "codex/smart-natural-search-current-main", + "head": "b762e1363b9bbb993f0f74a9e00a2c2ccb1f56be", + "scope": "Smart natural search final CI test correction review", + "outcome": "No open P0/P1/P2 findings; stale extracted-owner tests corrected", + "checks": "6 focused Vitest; DSM production Chromium; formatting; diff check" + }, { "date": "2026-08-29", "ref": "PR-2454", @@ -4286,6 +4318,14 @@ "outcome": "Confirmed PR-specific repo-awareness drift and two P2 documentation findings; corrected the generated snapshot, Windows LCP delta, and Linux-only qualification. Main coverage/browser failures did not reproduce on the PR head.", "checks": "PR/base Actions logs; repo-awareness check; outstanding-issues check; docs links; targeted Prettier; arithmetic verification" }, + { + "date": "2026-08-29", + "ref": "PR-2457", + "head": "0e5631ba6a98b22ef1ddf5557e6ce38b824e78b2", + "scope": "PR #2457 review-and-fix", + "outcome": "Fixed repository breakpoint coverage, conservative same-band Tailwind cascade handling, and the stale compact-disclosure UI assertion; no other material PR-introduced defects confirmed.", + "checks": "57 focused Vitest tests passed; design-system contract/adoption/sync passed; typecheck passed; local Playwright unavailable because pinned Chromium is not installed, with exact-head Actions reproducing the corrected 42px assertion." + }, { "date": "2026-08-27", "ref": "2398", @@ -25456,8 +25496,8 @@ } ], "counts": { - "records": 2647, - "refs": 1615 + "records": 2652, + "refs": 1618 } } } diff --git a/docs/audit/live-drift-forensics-2026-08.md b/docs/audit/live-drift-forensics-2026-08.md index 4426d9844..a4b533f51 100644 --- a/docs/audit/live-drift-forensics-2026-08.md +++ b/docs/audit/live-drift-forensics-2026-08.md @@ -2273,7 +2273,7 @@ is worse than the red job it replaces. When neither path works, the error names _Superseded by the 2026-08-20 window section below: the migration was already applied on production before the window opened, and D4 is no longer treated as OFF. Kept as the pre-window record._ -The migration is **not deployed**. D4 is OFF, so merging does not apply it, and until it is applied +The migration is **not deployed**. D4 is OFF _(superseded: see §D4 — SETTLED 2026-08-21; deploy-on-merge is ON)_, so merging does not apply it, and until it is applied `check:drift` will report `migration_history_versions` as a missing function — i.e. merging before the window trades one red for another. **Deploy from the branch first, then merge**, which is the order Phase 4 used (§Phase 4 completion). Staging needs the same migration by the Phase 2 method to hold the diff --git a/docs/outstanding-issues-inbox/084e06ec-e097-4b27-933d-2c3138ffbb01.json b/docs/outstanding-issues-inbox/084e06ec-e097-4b27-933d-2c3138ffbb01.json new file mode 100644 index 000000000..18b40bbd6 --- /dev/null +++ b/docs/outstanding-issues-inbox/084e06ec-e097-4b27-933d-2c3138ffbb01.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "084e06ec-e097-4b27-933d-2c3138ffbb01", + "createdOn": "2026-08-31", + "action": "done", + "payload": { + "id": "#XHNTHD", + "outcome": "Added --allow-partial CLI flag and resolveEvaluationCases() fail-safe throwing when cases.length === 0 or when cases are filtered without --allow-partial in scripts/eval-retrieval.ts; added unit tests in tests/eval-retrieval.test.ts.", + "baseRowFingerprint": "fa3df871bf0b55ad457dd4ef483a6bc41f0c780700e6149c652d24ba5efe4b3f" + } +} diff --git a/docs/superpowers/specs/2026-08-19-ward-flow-phase-3-role-screens-design.md b/docs/superpowers/specs/2026-08-19-ward-flow-phase-3-role-screens-design.md index 779b5813e..0e9653499 100644 --- a/docs/superpowers/specs/2026-08-19-ward-flow-phase-3-role-screens-design.md +++ b/docs/superpowers/specs/2026-08-19-ward-flow-phase-3-role-screens-design.md @@ -115,7 +115,7 @@ the 30 generated ones from their index. Some must carry a `formedAt` meaningfull ## 4. Architecture — the state layer -A single `WardFlowProvider` mounted at a new `src/app/ward-management/layout.tsx`, following the +A single `WardFlowProvider` mounted at a new `src/app/mockups/ward-flow/layout.tsx`, following the repository's existing React-context pattern. Eight `createContext` providers already exist; no state library is present and none is added. diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index 77ffb2272..3cbde352d 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -409,6 +409,94 @@ export function measureBudgetRoutes(measuredFiles, routeChunks, routeBudgets) { return { measured, missing }; } +/** + * @typedef {object} ServerHtmlPayloadMeasurement + * @property {boolean} found + * @property {string} [file] + * @property {number} rawBytes + * @property {number} gzipBytes + * @property {number} [rawBytesCeiling] + * @property {number} [gzipBytesCeiling] + * @property {"ok" | "fail" | "missing" | "error"} status + * @property {string} [reason] + */ + +/** + * Measure static / server HTML page payloads generated in .next/server/app. + * Guards large server pages such as /mockups/development/review-state against unchecked growth. + * + * @param {string} serverAppDir + * @param {Record} [serverPagesConfig] + * @param {{ existsSync?: (p: string) => boolean; readFileSync?: (p: string) => Buffer }} [fsOptions] + * @returns {Record} + */ +export function measureServerHtmlPayloads(serverAppDir, serverPagesConfig, fsOptions = {}) { + const fileExists = fsOptions.existsSync ?? existsSync; + const fileRead = fsOptions.readFileSync ?? readFileSync; + /** @type {Record} */ + const results = {}; + const defaults = { + "/mockups/development/review-state": { + rawBytesCeiling: 2_500_000, + gzipBytesCeiling: 350_000, + }, + }; + const configs = serverPagesConfig ?? defaults; + + for (const [route, config] of Object.entries(configs)) { + const rawCeiling = config.rawBytesCeiling ?? config.maxRawBytes ?? 2_500_000; + const gzipCeiling = config.gzipBytesCeiling ?? config.maxGzipBytes ?? 350_000; + + const normalizedRoute = route.startsWith("/") ? route.slice(1) : route; + const candidates = [ + path.join(serverAppDir, `${normalizedRoute}.html`), + path.join(serverAppDir, normalizedRoute, "page.html"), + path.join(serverAppDir, `${normalizedRoute}.rsc`), + path.join(serverAppDir, normalizedRoute, "page.rsc"), + path.join(serverAppDir, `${normalizedRoute}.js`), + path.join(serverAppDir, normalizedRoute, "page.js"), + ]; + + const match = candidates.find((cand) => fileExists(cand)); + if (!match) { + results[route] = { + found: false, + rawBytes: 0, + gzipBytes: 0, + status: "missing", + reason: "no build artifact found for configured server page", + }; + continue; + } + + try { + const buffer = fileRead(match); + const rawBytes = buffer.length; + const gzipBytes = gzipSync(buffer).length; + const exceededRaw = rawBytes > rawCeiling; + const exceededGzip = gzipBytes > gzipCeiling; + results[route] = { + found: true, + file: match, + rawBytes, + gzipBytes, + rawBytesCeiling: rawCeiling, + gzipBytesCeiling: gzipCeiling, + status: exceededRaw || exceededGzip ? "fail" : "ok", + reason: exceededRaw + ? `HTML payload (${kb(rawBytes)}) exceeds raw ceiling (${kb(rawCeiling)})` + : exceededGzip + ? `HTML gzip payload (${kb(gzipBytes)}) exceeds gzip ceiling (${kb(gzipCeiling)})` + : "within ceiling", + }; + } catch { + results[route] = { found: false, rawBytes: 0, gzipBytes: 0, status: "error" }; + } + } + + return results; +} + /** Identify large fixture payloads from stable groups of serialized keys/slugs. * Requiring every marker in a group avoids failing on ordinary UI copy that * happens to mention one fixture term. */ @@ -1252,6 +1340,14 @@ export function runBundleBudgetCheck(argv = process.argv.slice(2)) { `[bundle-budget] WARN (stale baseline) — baseline commit ${baselineSource.slice(0, 12)} is ${baselineCommitDistance} commits behind HEAD (staleness threshold: ${STALE_BASELINE_COMMIT_DISTANCE_THRESHOLD}). Consider refreshing with \`npm run check:bundle-budget -- --update\`.`, ); } + const serverHtmlMeasurements = measureServerHtmlPayloads(SERVER_APP_DIR, budget?.serverPages); + for (const [route, measurement] of Object.entries(serverHtmlMeasurements)) { + if (measurement.found) { + console.log( + `[bundle-budget] server page HTML ${route}: ${kb(measurement.gzipBytes)} gzip (${kb(measurement.rawBytes)} raw) — ceiling ${kb(measurement.gzipBytesCeiling)} gzip (${kb(measurement.rawBytesCeiling)} raw), ${measurement.reason}.`, + ); + } + } console.log("[bundle-budget] largest chunks (gzip):"); for (const c of current.largest) console.log(` ${kb(c.gzipBytes).padStart(12)} ${c.name}`); console.log( @@ -1260,6 +1356,20 @@ export function runBundleBudgetCheck(argv = process.argv.slice(2)) { } let failed = false; + const serverHtmlMeasurements = measureServerHtmlPayloads(SERVER_APP_DIR, budget?.serverPages); + for (const [route, measurement] of Object.entries(serverHtmlMeasurements)) { + const config = budget?.serverPages?.[route]; + if (enforce && config?.required && measurement.status === "missing") { + console.error( + `[bundle-budget] FAIL — server page ${route} ${measurement.reason ?? "is missing required build output"}.`, + ); + failed = true; + } else if (measurement.found && enforce && measurement.status === "fail") { + console.error(`[bundle-budget] FAIL — server page ${route} ${measurement.reason}.`); + failed = true; + } + } + if (productionVerdict.status === "fail") { console.error( `[bundle-budget] FAIL — production bundle ${productionVerdict.reason}. This is user-facing weight; find the regression before refreshing the baseline.`, diff --git a/scripts/check-docs-links.mjs b/scripts/check-docs-links.mjs index daf847b46..ad235cc7d 100644 --- a/scripts/check-docs-links.mjs +++ b/scripts/check-docs-links.mjs @@ -230,6 +230,41 @@ function repoPathExists(repoRelative) { return APP_ROUTE_GROUPS.some((group) => existsSync(path.join(repoRoot, "src/app", group, appRelative))); } +export function markdownAnchorSlugs(markdown) { + const slugs = new Set(); + const slugCounts = new Map(); + for (const line of markdown.split("\n")) { + const match = line.match(/^#{1,6}\s+(.+)$/); + if (!match) continue; + const headingText = match[1] + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/[*_~]/g, "") + .replace(/<[^>]+>/g, "") + .trim(); + let rawSlug = headingText + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\s_-]/gu, "") + .replace(/\s/g, "-") + .replace(/^-+|-+$/g, ""); + if (!rawSlug) rawSlug = "section"; + const count = slugCounts.get(rawSlug) ?? 0; + slugCounts.set(rawSlug, count + 1); + const uniqueSlug = count === 0 ? rawSlug : `${rawSlug}-${count}`; + slugs.add(uniqueSlug); + + const collapsedSlug = rawSlug.replace(/-+/g, "-"); + if (collapsedSlug !== rawSlug) { + slugs.add(collapsedSlug); + } + } + for (const match of markdown.matchAll(/<(?:a|span|div|section|h[1-6])[^>]+(?:id|name)=["']([^"']+)["']/gi)) { + slugs.add(match[1].toLowerCase()); + } + return slugs; +} + function collectDocs(dirRelative, targets) { const absolute = path.join(repoRoot, dirRelative); for (const entry of readdirSync(absolute, { withFileTypes: true })) { @@ -328,12 +363,22 @@ function globBaseDir(value) { } function isExternalLink(value) { - return /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(value) || value.startsWith("#"); + return /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(value); } function main() { let missing = 0; let checked = 0; + const targetAnchorsCache = new Map(); + + function getAnchorsForFile(absPath, relPath) { + if (targetAnchorsCache.has(absPath)) return targetAnchorsCache.get(absPath); + if (!existsSync(absPath) || !relPath.endsWith(".md")) return null; + const content = markdownForTarget(relPath, absPath); + const anchors = markdownAnchorSlugs(content); + targetAnchorsCache.set(absPath, anchors); + return anchors; + } for (const target of defaultTargets()) { const absoluteTarget = path.join(repoRoot, target); @@ -341,6 +386,8 @@ function main() { const markdown = markdownForTarget(target, absoluteTarget); const targetDir = path.posix.dirname(target); const failures = []; + const currentFileAnchors = markdownAnchorSlugs(markdown); + targetAnchorsCache.set(absoluteTarget, currentFileAnchors); const check = (repoRelative, label) => { if (isAllowedPath(repoRelative, target)) return; @@ -367,21 +414,48 @@ function main() { // `../AGENTS.md`). Accept whichever resolves, confined to the repository. for (const rawCandidate of linkCandidates(markdown)) { if (isExternalLink(rawCandidate)) continue; - const value = stripSuffixes(rawCandidate); - if (value === "" || value.includes("*") || /[<>{}$\\]/.test(value) || /\s/.test(value)) continue; - const relative = path.posix.normalize(path.posix.join(targetDir === "." ? "" : targetDir, value)); + let targetPart = rawCandidate; + let anchorPart = null; + const hashIndex = targetPart.indexOf("#"); + if (hashIndex !== -1) { + anchorPart = targetPart.slice(hashIndex + 1); + targetPart = targetPart.slice(0, hashIndex); + } + targetPart = stripSuffixes(targetPart); + + if (targetPart === "") { + // Same-document anchor link: [heading](#heading) + if (anchorPart) { + checked += 1; + const normalizedAnchor = anchorPart.toLowerCase(); + if (!currentFileAnchors.has(normalizedAnchor)) { + failures.push(`${rawCandidate} (missing anchor #${anchorPart} in ${target})`); + } + } + continue; + } + + if (targetPart.includes("*") || /[<>{}$\\]/.test(targetPart) || /\s/.test(targetPart)) continue; + const relative = path.posix.normalize(path.posix.join(targetDir === "." ? "" : targetDir, targetPart)); if (relative.startsWith("..")) { checked += 1; failures.push(`${rawCandidate} (escapes repository root)`); continue; } - const rootStyle = path.posix.normalize(value); + const rootStyle = path.posix.normalize(targetPart); const candidates = rootStyle === relative || rootStyle.startsWith("..") ? [relative] : [rootStyle, relative]; if (candidates.some((candidate) => isAllowedPath(candidate, target))) continue; checked += 1; - const found = candidates.some((candidate) => repoPathExists(candidate)); - if (!found) + const matchingPath = candidates.find((candidate) => repoPathExists(candidate)); + if (!matchingPath) { failures.push(rawCandidate === relative ? relative : `${rawCandidate} (tried ${candidates.join(", ")})`); + } else if (anchorPart && matchingPath.endsWith(".md")) { + const absFound = path.join(repoRoot, matchingPath); + const targetAnchors = getAnchorsForFile(absFound, matchingPath); + if (targetAnchors && !targetAnchors.has(anchorPart.toLowerCase())) { + failures.push(`${rawCandidate} (missing anchor #${anchorPart} in ${matchingPath})`); + } + } } if (failures.length > 0) { diff --git a/scripts/check-repo-awareness-snapshot.ts b/scripts/check-repo-awareness-snapshot.ts index a44989c5b..3f0f6218c 100644 --- a/scripts/check-repo-awareness-snapshot.ts +++ b/scripts/check-repo-awareness-snapshot.ts @@ -67,32 +67,81 @@ export function compareSnapshots(committed: unknown, regenerated: RepoAwarenessS return differences; } -function main() { - if (!isGitRepository()) { - console.log( +export type CheckSnapshotOptions = { + outputPath?: string; + generateImpl?: () => RepoAwarenessSnapshot; + readCommittedImpl?: (path: string) => unknown; + isGitRepoImpl?: () => boolean; + log?: (msg: string) => void; + error?: (msg: string) => void; + exit?: (code: number) => void; +}; + +export function checkRepoAwarenessSnapshot(options: CheckSnapshotOptions = {}): number { + const { + outputPath = OUTPUT_PATH, + generateImpl = generate, + readCommittedImpl = (p) => JSON.parse(readFileSync(p, "utf8")), + isGitRepoImpl = isGitRepository, + log = console.log, + error = console.error, + exit = (code) => process.exit(code), + } = options; + + if (!isGitRepoImpl()) { + log( "[repo-awareness] Git repository not available (git-less environment); skipping repo-awareness snapshot staleness check.", ); - process.exit(0); + return 0; + } + + let regenerated: RepoAwarenessSnapshot; + try { + regenerated = generateImpl(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if ( + message.includes("spawnSync git") || + message.includes("ENOENT") || + message.includes("not a git repository") || + message.includes("git: not found") || + message.includes("git is not available") + ) { + log(`[repo-awareness] skipped: git is not available in this environment (${message}).`); + return 0; + } + error(`[repo-awareness] generation failed: ${message}`); + exit(1); + return 1; } - const regenerated = generate(); + let committed: unknown = null; try { - committed = JSON.parse(readFileSync(OUTPUT_PATH, "utf8")); + committed = readCommittedImpl(outputPath); } catch { - console.error(`[repo-awareness] ${OUTPUT_PATH} is missing or unreadable. Run: ${FIX}`); - process.exit(1); + error(`[repo-awareness] ${outputPath} is missing or unreadable. Run: ${FIX}`); + exit(1); + return 1; } + const differences = compareSnapshots(committed, regenerated); if (differences.length > 0) { - console.error("[repo-awareness] The committed snapshot is behind the repository:"); - for (const difference of differences) console.error(` - ${difference}`); - console.error(`[repo-awareness] Fix with: ${FIX}`); - process.exit(1); + error("[repo-awareness] The committed snapshot is behind the repository:"); + for (const difference of differences) error(` - ${difference}`); + error(`[repo-awareness] Fix with: ${FIX}`); + exit(1); + return 1; } - console.log( - `[repo-awareness] in step with ${OUTPUT_PATH} (${regenerated.routes.counts.pages} pages, ` + + + log( + `[repo-awareness] in step with ${outputPath} (${regenerated.routes.counts.pages} pages, ` + `${regenerated.documentation.counts.documents} documents, ${regenerated.review_state.counts.records} reviews)`, ); + return 0; +} + +function main() { + checkRepoAwarenessSnapshot(); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index afce3a540..8e5ed8f81 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -47,6 +47,7 @@ type EvalArgs = { json: boolean; jsonOut?: string; failOnThreshold: boolean; + allowPartial: boolean; mode: "combined" | "quality" | "latency"; caseTimeoutMs: number; p90BudgetMs: number; @@ -124,7 +125,7 @@ export type GoldenRetrievalResult = { }>; }; -function parseArgs(argv: string[]): EvalArgs { +export function parseArgs(argv: string[]): EvalArgs { const lifecycle = process.env.npm_lifecycle_event ?? ""; const inferredMode = lifecycle.includes("latency") ? "latency" @@ -137,6 +138,7 @@ function parseArgs(argv: string[]): EvalArgs { ownerId: process.env.RAG_EVAL_OWNER_ID ?? process.env.LOCAL_NO_AUTH_OWNER_ID, json: false, failOnThreshold: false, + allowPartial: false, mode: inferredMode, caseTimeoutMs: inferredMode === "latency" ? 25_000 : 0, p90BudgetMs: 20_000, @@ -155,6 +157,10 @@ function parseArgs(argv: string[]): EvalArgs { args.failOnThreshold = true; continue; } + if (token === "--allow-partial") { + args.allowPartial = true; + continue; + } if (token === "--quality") { args.mode = "quality"; continue; @@ -203,6 +209,38 @@ function parseArgs(argv: string[]): EvalArgs { return args; } +export function resolveEvaluationCases({ + allCases, + query, + limit, + allowPartial = false, +}: { + allCases: GoldenRetrievalCase[]; + query?: string; + limit?: number; + allowPartial?: boolean; +}): GoldenRetrievalCase[] { + const filteredCases = query + ? allCases.filter((item) => item.query.toLowerCase().includes(query.toLowerCase()) || item.id === query) + : allCases; + const cases = filteredCases.slice(0, limit ?? filteredCases.length); + + if (cases.length === 0) { + throw new Error( + "No retrieval eval cases matched the selection. eval-retrieval must not report success over an empty case set.", + ); + } + + if (!allowPartial && allCases.length > 0 && cases.length < allCases.length) { + throw new Error( + `eval-retrieval: selection resolved ${cases.length} of ${allCases.length} case(s) without --allow-partial. ` + + `Pass --allow-partial to explicitly run a partial golden fixture set under --limit or --query.`, + ); + } + + return cases; +} + export function loadGoldenRetrievalCases(path: string) { const parsed = goldenCasesSchema.parse(JSON.parse(readFileSync(path, "utf8"))); return parsed; @@ -931,10 +969,12 @@ async function main() { const capturedCaseClient = supabase as unknown as SupabaseEvalCaseClient; const capturedCases = await loadCapturedRagEvalCases({ supabase: capturedCaseClient, ownerId, limit: args.limit }); const allCases = [...capturedCases.map(capturedRagCaseToGoldenCase), ...loadGoldenRetrievalCases(args.fixture)]; - const filteredCases = args.query - ? allCases.filter((item) => item.query.toLowerCase().includes(args.query!.toLowerCase()) || item.id === args.query) - : allCases; - const cases = filteredCases.slice(0, args.limit ?? filteredCases.length); + const cases = resolveEvaluationCases({ + allCases, + query: args.query, + limit: args.limit, + allowPartial: args.allowPartial, + }); const readinessWarnings = await visualReadinessWarnings(supabase, cases); if (!args.json) { diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 6d0360c83..977cf8963 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -334,6 +334,10 @@ try { }); const buildExitCode = childProcessExitCode(buildResult); if (buildExitCode !== 0) { + const memory = process.memoryUsage(); + console.error( + `[playwright] build diagnostics: status=${buildResult.status}, signal=${buildResult.signal ?? "none"}, error=${buildResult.error?.message ?? "none"}, memory(rss=${Math.round(memory.rss / (1024 * 1024))}MB, heapTotal=${Math.round(memory.heapTotal / (1024 * 1024))}MB, heapUsed=${Math.round(memory.heapUsed / (1024 * 1024))}MB)`, + ); throw new Error(`Playwright production build failed (${childProcessFailureSummary(buildResult)}).`); } diff --git a/src/components/clinical-dashboard/account-setup-dialog.tsx b/src/components/clinical-dashboard/account-setup-dialog.tsx index 7bff4bfb2..f88dec746 100644 --- a/src/components/clinical-dashboard/account-setup-dialog.tsx +++ b/src/components/clinical-dashboard/account-setup-dialog.tsx @@ -298,7 +298,10 @@ function ProviderButton({ data-provider={provider.toLowerCase()} className={cn( floatingControl, - "min-h-12 w-full min-w-0 justify-center gap-2.5 bg-[color:var(--surface-lux)] px-3 shadow-[var(--shadow-inset)]", + // Absolute 48px floor wins over rem-based min-h-tap/min-h-12: Interface + // density can set the root to 15px (3rem → 45px), and ui-smoke asserts + // these provider controls at a hard 48px bounding box. + "!min-h-[48px] w-full min-w-0 justify-center gap-2.5 bg-[color:var(--surface-lux)] px-3 shadow-[var(--shadow-inset)]", )} > {pending ? ( diff --git a/src/components/ui/retrieval-state-banner.tsx b/src/components/ui/retrieval-state-banner.tsx index 5d318c777..5fa79685d 100644 --- a/src/components/ui/retrieval-state-banner.tsx +++ b/src/components/ui/retrieval-state-banner.tsx @@ -297,6 +297,20 @@ export function RetrievalStateBanner({ state, onOpenSource, className }: Retriev ? "Source match status" : "How this answer was produced"; + if (state.kind === "stale_evidence") { + return ( +
+ +
+ ); + } + return (
- {state.kind === "stale_evidence" ? null :
- PsychSift provides evidence summaries, not medical advice. Verify clinical decisions. + PsychSift is a clinical reference prototype, not validated decision support. Verify every dose and interaction + against the linked source before acting on it. diff --git a/src/components/clinical-dashboard/patient-profile-panel.tsx b/src/components/clinical-dashboard/patient-profile-panel.tsx index 41b77ff36..d88e4b871 100644 --- a/src/components/clinical-dashboard/patient-profile-panel.tsx +++ b/src/components/clinical-dashboard/patient-profile-panel.tsx @@ -367,8 +367,8 @@ export function PatientProfilePanel({

- Anonymous values only — no patient‑identifying information is stored. Cleared when the tab closes. Decision - support, not medical advice. + Anonymous values only — no patient‑identifying information is stored. Cleared when the tab closes. Clinical + reference — not validated decision support.

From 5ee3e3679774c7735ac4ab38854ebb9a24402e3d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:21:45 +0800 Subject: [PATCH 36/50] fix(clinical): stop four surfaces calling themselves decision support, and pin two drifting design-system documents (#2497) * fix(clinical): align decision-support copy with the product boundary; pin design-system figures and token drift The app told users four times that it was clinical decision support while its own privacy page says the opposite. Two design-system documents had also drifted far enough from the code to mislead, and one silent trap had no gate at all. Clinical copy (the reason this change exists) - Differentials (3 sites) and Specifiers (1) said "Clinical decision support only" / "Use this as structured decision support". The privacy page and CLAUDE.md both scope this product as "a clinical reference prototype, not validated clinical decision support", and the PR preflight carries a device-classification item, so the clinical surfaces were the ones out of step. - All four now read "Clinical reference - not validated decision support", the wording already recorded as the clinical-owner copy decision on #PM9SP1. - The fifth site was the copied/exported differential text in src/lib/differential-detail.ts, which carried the same claim into anything a clinician pastes elsewhere. Its pinned assertion moved with it. - Therapy Compass was already correct and is untouched. New gate: token-layer divergence (scripts/token-layer-divergences.mjs) layout.tsx mounts .ckb-v2 on , so .ckb-v2.ckb-v2 (0,2,0) beats :root (0,1,0) and for any role both stylesheets declare the v2 value paints while the globals.css one is dead - editing it changes nothing, with no lint, type or screenshot signal. 53 roles are in that state today (29 light, 24 dark). The v2 migration is deliberate and unfinished, so divergence is pinned rather than banned: docs/design-system/token-layer-divergences.json is the reviewed set, and a role that starts diverging OR stops diverging fails. The comparison covers globals' @theme block as well as :root, which is where the structural roles live. Wired into the existing check:design-system-contract, so no gate-manifest change was needed. Mutation-verified both directions. New gate: GATES.md figures are generated (scripts/generate-gates-figures.mjs) GATES.md carried the same metric as three different hand-typed values in three sections. Measured against the baseline: interactiveTapFloorDeclarations appeared as 9, 32 and 2 (true value 2); rawMarginLiterals as 74 and 53 (34); legacyShadowAliases as 231, 224 and 87 (34). Nearly every figure overstated remaining debt, which costs a session in exactly the way the document's own section 5 warns about. A generated section 0 is now the single current count, with the prose figures in sections 2, 4 and 5 explicitly marked as the historical record. Comparison is normalised cell-wise so Prettier's table padding cannot report false drift. Mutation-verified both directions. Breakpoint tokens: reconciled toward the code, not away from it GATES.md 3b banned --breakpoint-* tokens; globals.css has five, added four days after that decision. The obvious fix - delete the three device-band names, which have zero product call sites - was attempted and reverted in the same session: MIN_WIDTH_BREAKPOINT_BANDS in design-system-contract-utils.mjs hard-codes them as same-threshold aliases of sm/md/lg, and they are the only fixtures exercising the tap-floor gate's alias-collision path. "Nothing imports it" was necessary and nowhere near sufficient, exactly as AGENTS.md warns. All five stay, with the real reason recorded in both the stylesheet and 3b, and the wrong "(Task #336)" attribution removed. AGENTS.md bundle-budget route list Documented five budgeted routes; the config has two. The config is right: /therapy-compass, /dsm and /forms became redirect stubs rendering the same shared home as /, so budgeting them measured / three more times (see tests/check-lighthouse-budget.test.ts, COMMITTED_ROUTES). Corrected the document and recorded why, so the next reader does not "fix" the config back. Also: indexed the one mockup route missing from mockups/README.md (answer-loading-redesign), taking that index to 79 of 79. Verification npm run verify:cheap exit 0. Full unit suite 934 files / 11,771 tests passed, 1 skipped, 0 failures. lint and typecheck clean. Clean-build bundle check passed (production 1742.1 KiB gzip, / at 240.0 KiB); it reports a +5.2% drift warning against a baseline commit that no longer resolves, which is pre-existing and tracked as #QSHHGK, not caused here. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015uQ7pGjA4hBxi9TRNsfjAL * fix(design-system): pin token-layer counts metadata against divergences Codex P2: reject hand-edited/merge-skewed counts when divergences are unchanged. * docs: refresh scripts-index inventory counts for two new design-system scripts --------- Co-authored-by: Claude --- AGENTS.md | 11 +- docs/design-system/GATES.md | 152 +++++++++--- .../token-layer-divergences.json | 226 ++++++++++++++++++ docs/scripts-index.md | 2 +- mockups/README.md | 7 +- package.json | 4 +- scripts/generate-gates-figures.mjs | 118 +++++++++ scripts/token-layer-divergences.mjs | 191 +++++++++++++++ src/app/globals.css | 12 +- .../differential-detail-page.tsx | 2 +- ...ifferential-presentation-workflow-page.tsx | 5 +- src/components/specifiers/specifier-ui.tsx | 4 +- src/lib/differential-detail.ts | 2 +- tests/design-token-contract.test.ts | 37 ++- tests/differential-detail.test.ts | 2 +- 15 files changed, 723 insertions(+), 52 deletions(-) create mode 100644 docs/design-system/token-layer-divergences.json create mode 100644 scripts/generate-gates-figures.mjs create mode 100644 scripts/token-layer-divergences.mjs diff --git a/AGENTS.md b/AGENTS.md index 380f1693b..78b07de6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,9 +295,14 @@ action must perform one; a page that ships must be reachable. - **`production`** — every chunk a non-mockup route reaches, plus chunks no route manifest claims (framework, polyfills, runtime). This is user-facing weight and the real regression guard. Tolerance 10%. A failure here means find the regression; do not refresh the baseline to clear it. -- **`routes`** — client JavaScript referenced by `/`, `/therapy-compass`, `/documents/search`, - `/dsm`, and `/forms`, the same journeys measured by Lighthouse. Each route has a 10% tolerance, - so local growth cannot hide inside a still-healthy repository aggregate. +- **`routes`** — client JavaScript referenced by `/` and `/documents/search`, the same journeys + measured by Lighthouse. Each route has a 10% tolerance, so local growth cannot hide inside a + still-healthy repository aggregate. `/therapy-compass`, `/dsm` and `/forms` were in this list + and were deliberately removed: home consolidation turned all three into redirect stubs that + render the same shared home as `/`, so budgeting them measured `/` three more times rather than + covering anything new (see `tests/check-lighthouse-budget.test.ts`, `COMMITTED_ROUTES`). Do not + re-add them without also un-consolidating the homes; if a mode's own surface needs its own + budget, the route to add is its `/search` view, which is a genuinely different bundle. - **`mockups`** — chunks reachable **only** from `/mockups/**`. Nobody downloads these, so this is a repo-hygiene ceiling for unbounded accumulation, not a per-mockup gate. Tolerance 25%. diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index 9247ee14e..5aee1547e 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -10,6 +10,57 @@ than CI, two of them by the second reader; review does not scale past two carefu - **Date:** 5 August 2026 · companions: [SPEC.md](SPEC.md) · [TOKENS.md](TOKENS.md) · [COMPONENTS.md](COMPONENTS.md) · [DECISIONS.md](DECISIONS.md) +> **Figures.** The generated table in §0 is the only current count in this document. +> Numbers written into the prose of §2, §4 and §5 are the historical record of what was +> true when that passage was written, and several are now badly stale — the same metric +> once appeared here as three different hand-typed values, none of them right. Read the +> prose for _why_ a gate is shaped the way it is; read §0 for _how much_ is left. Where +> they disagree, §0 wins, and per [README.md](README.md) the baseline outranks both. + +--- + +## 0 · Current ratchet figures + + + + + +| Metric | Current pin | Files pinned | +| --------------------------------- | ------------------ | ------------ | +| `arbitraryTracking` | **0** (hard floor) | 0 | +| `colourOnlyStatusIndicators` | **0** (hard floor) | 0 | +| `darkColorOverrides` | **0** (hard floor) | 0 | +| `edgeOwnershipConflicts` | 5 | 2 | +| `elevationInversions` | 6 | 5 | +| `errorStateCountProps` | **0** (hard floor) | 0 | +| `failedStateResultCounts` | **0** (hard floor) | 0 | +| `handRolledCommandButtons` | 8 | 7 | +| `hardcodedCssMotionDurations` | 26 | 1 | +| `interactiveTapFloorDeclarations` | 2 | 1 | +| `layoutTransitionExceptions` | 10 | 4 | +| `legacyPaletteUtilities` | **0** (hard floor) | 0 | +| `legacyShadowAliases` | 34 | 28 | +| `legacyTapClasses` | **0** (hard floor) | 0 | +| `literalShadowClasses` | **0** (hard floor) | 0 | +| `onePixelShadowSpreads` | **0** (hard floor) | 0 | +| `rawColorLiterals` | **0** (hard floor) | 0 | +| `rawCssZIndices` | 4 | 1 | +| `rawGapLiterals` | 11 | 1 | +| `rawLineHeightLiterals` | **0** (hard floor) | 0 | +| `rawMarginLiterals` | 34 | 4 | +| `rawPaddingLiterals` | 22 | 4 | +| `rawRadiusLiterals` | 15 | 3 | +| `sameFileTextSmMinusMix` | 25 | 25 | +| `statusColouredNumerals` | **0** (hard floor) | 0 | +| `textSoftConsumers` | **0** (hard floor) | 0 | + +26 metrics, 13 of them pinned at zero. A metric at zero is a hard floor: +the check asserts `value <= baseline`, so any reintroduction fails. A non-zero pin is +recorded debt with per-path pins, so a new occurrence fails even while the total stands. + + + --- ## 1 · What actually runs today @@ -146,35 +197,35 @@ theme-list parity, and remote design-project publication remain separate concern ## 3 · The prohibition table -| Never do this | Gate | Status | -| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Reintroduce `.ckb-v2:not(.dark)` | Cascade computed-style test (PR 1) + contract-test selector filter update | implemented-blocking — landed #1538; contract parser + dark-ink regression guard | -| Add `prefers-color-scheme` rules to the v2 layer | — (users pin a theme against OS preference) | **manual** | -| Paint a numeral with a status colour; use amber/red decoratively | `check:design-system-contract` — `statusColouredNumerals` + `colourOnlyStatusIndicators` | **implemented-blocking** — both metrics and their per-path baselines are pinned at **0**; see §5 | -| Put `--decoration-soft`/`--text-soft` on a text node | Tier pinned both sides in the token contract; recipe AST (`decoration-on-text.contract.test.ts`) | implemented-blocking (recipes); broader AST follow-on | -| Uppercase a dose unit | `Quantity` pins `normal-case` + unit test | implemented-blocking (branch) | -| Invert a PDF, diagram or clinical image in any theme | `check:design-system-contract` — `imageInversions`, pinned at **zero**, not ratcheted | **implemented-blocking** (9 Aug 2026) — CSS `filter`/`backdrop-filter` plus the Tailwind `invert`/`hue-rotate` utilities; see §5 | -| Border **and** ring on one surface, or a 1px spread in a drop shadow | `check:design-system-contract` — `edgeOwnershipConflicts` (16) + `onePixelShadowSpreads` (0) | **implemented-blocking for new use** — AST/CSS ratchets with per-path pins; the recorded debt itself is Gate 8's remaining half | -| A child shadow heavier than its parent's | Gate 7 | implemented-partial | -| Use `--shadow-tight`/any alias in new code | `check:design-system-contract` — `legacyShadowAliases`, ratcheted at 87 with per-path pins | **implemented-blocking for new use** — a new alias in any file fails. `--shadow-tight` itself is retired onto `--e1` and additionally pinned by a tracked-tree sweep in `tests/design-token-contract.test.ts` (it was silently reverted once by the `acf78bf` merge); the remaining 87 are the other six aliases, still `#262` | -| Raw pixel size, padding, radius, gap or line-height in markup | `check:design-system-contract` — `rawPaddingLiterals` (42), `rawRadiusLiterals` (20), `rawGapLiterals` (20), `rawMarginLiterals` (53), `rawLineHeightLiterals` (0) | **implemented-blocking for new use** (9 Aug 2026) — per-path ratchets over both the utility and the CSS-declaration spelling, so a literal cannot move into `globals.css` to escape. Values containing a CSS function (`env(`, `clamp(`, `max(`, `calc(`) are sanctioned computed forms and exempt. Raw _size_ is still covered only for tap/shadow/colour | -| Animate `width`, `height`, `grid-template-*`, `top`, `left`, `gap` | `check:design-system-contract` — `layoutTransitionExceptions`, ratcheted at 11 with per-path pins | **implemented-blocking for new use** — `SAFE_TRANSITION_PROPERTIES` carries the compositor-only allowlist; phone chrome's deliberate `grid-template-rows` is in the recorded 11 | -| Hardcode a transition duration | `check:design-system-contract` — `hardcodedMotionClasses` (**zero**) + `hardcodedCssMotionDurations` (42) | **implemented-blocking** for the Tailwind `duration-*`/`delay-*`/`transition-all` form; the CSS form is a ratchet, so its 42 are debt | -| A `z-[N]` literal outside the named rungs, in JS/TS only | `require-z-index-ladder` (ESLint) | implemented-blocking. Scope: `z-[N]` literals in JS/TS AST nodes. Plain `z-30` utilities and every `.css` / `.module.css` declaration are invisible to an ESLint rule - `sidebar-live-shell.module.css` carries an off-ladder `z-index: var(--z-sidebar-flyout, 50)` this cannot see. | -| Ship a registered component without a direct publication test | Gate 12 | implemented-blocking | -| Lower a production tap target below 48px | `check:design-system-contract` — `interactiveTapFloorDeclarations` (32) + Gate 2's phone-viewport `ui-style-contract` audit + `ui-smoke` flake history | **implemented-blocking for new use** (15 Aug 2026) — a new interactive control declaring a sub-floor `min-h-*`, including comparable arbitrary values and any reachable conditional branch, fails on both the total and its own path; the 32 recorded sites remain debt. Component-wrapper tags such as `` are a known blind spot | -| Construct an enabled control with no action, destination or name | PR 4 discriminated unions + DOM contracts; `require-button-wiring` covers raw `
diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx index 3a2b1a25b..cb7dea1da 100644 --- a/src/components/differentials/differential-presentation-workflow-page.tsx +++ b/src/components/differentials/differential-presentation-workflow-page.tsx @@ -271,7 +271,8 @@ function DesktopComparisonTable({

- Scroll horizontally to review more candidate differentials. Clinical decision support only. Review before use. + Scroll horizontally to review more candidate differentials. Clinical reference — not validated decision support. + Review before use.

); @@ -723,7 +724,7 @@ export function DifferentialPresentationWorkflowPage({

- Clinical decision support only. Review before use. + Clinical reference — not validated decision support. Review before use.

diff --git a/src/components/specifiers/specifier-ui.tsx b/src/components/specifiers/specifier-ui.tsx index 564fdf562..fd197dd4d 100644 --- a/src/components/specifiers/specifier-ui.tsx +++ b/src/components/specifiers/specifier-ui.tsx @@ -227,8 +227,8 @@ export function SpecifierSafetyNote({ >

- Use this as structured decision support. Confirm the current diagnostic manual criteria, exclusions, episode - chronology, and local clinical requirements before documenting a specifier. + Use this as a structured clinical reference, not validated decision support. Confirm the current diagnostic + manual criteria, exclusions, episode chronology, and local clinical requirements before documenting a specifier.

); diff --git a/src/lib/differential-detail.ts b/src/lib/differential-detail.ts index 17cf9d658..c6ca10319 100644 --- a/src/lib/differential-detail.ts +++ b/src/lib/differential-detail.ts @@ -211,7 +211,7 @@ export function formatDifferentialCopyText(record: DifferentialRecord): string { lines.push("", "Investigations:"); for (const investigation of investigations) lines.push(`- ${investigation}`); } - lines.push("", "Clinical decision support only. Review before use."); + lines.push("", "Clinical reference — not validated decision support. Review before use."); return lines.join("\n"); } diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts index cdb88044c..2b8938568 100644 --- a/tests/design-token-contract.test.ts +++ b/tests/design-token-contract.test.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { computeDivergences, diffAgainstPin, readPin } from "../scripts/token-layer-divergences.mjs"; import { sourceFrom, sourceSegment } from "./helpers/source-contract"; /** @@ -540,6 +541,12 @@ describe("responsive breakpoint tokens (Task #336)", () => { expect(v2Light.get("--bp-tablet")).toBe("768px"); expect(v2Light.get("--bp-desktop")).toBe("1024px"); + // These three have no product call site, and that is deliberate rather than + // dead: `MIN_WIDTH_BREAKPOINT_BANDS` in design-system-contract-utils.mjs + // models them as same-threshold aliases of sm/md/lg, and the tap-floor gate's + // alias-collision cases in design-system-contract-utils.test.ts are the only + // fixtures that exercise that path. Deleting them leaves the checker modelling + // variants Tailwind no longer emits, so they are pinned present, not absent. expect(themeConfigBlock).toContain("--breakpoint-phone: 640px;"); expect(themeConfigBlock).toContain("--breakpoint-tablet: 768px;"); expect(themeConfigBlock).toContain("--breakpoint-desktop: 1024px;"); @@ -550,10 +557,30 @@ describe("compat layer agrees with the v2 layer", () => { // `layout.tsx` mounts `ckb-v2` unconditionally on , and `.ckb-v2.ckb-v2` // (0,2,0) outranks `:root` (0,1,0) on that same element. So for any role both // files declare, the v2 value is the one that paints and the globals.css value - // is dead — editing it has NO visible effect, silently. That trap is what this - // asserts away. `--radius-md` is already pinned by the radius-ladder test above; - // these are the non-colour roles where a silent mismatch is most consequential. - const sharedRoles = ["text-hero", "text-hero--line-height", "leading-prose", "ease-standard"]; + // is dead — editing it has NO visible effect, silently. + // + // The v2 migration is deliberate and unfinished, so divergence is pinned rather + // than banned: `docs/design-system/token-layer-divergences.json` is the reviewed + // set. A role that STARTS diverging fails here, and so does one that stops, + // because a stale pin overstates the debt exactly the way GATES.md's hand-copied + // figures did. Refresh with `npm run design-system:token-divergence:update`. + it("has no unreviewed divergence between globals.css and ckb-v2-tokens.css", () => { + expect(diffAgainstPin()).toEqual([]); + }); + + it("rejects a pin whose counts metadata disagrees with divergences", () => { + const pin = readPin(); + const bad = structuredClone(pin); + bad.counts = { ...pin.counts, light: 0, dark: 999 }; + const problems = diffAgainstPin(computeDivergences(), bad); + expect(problems.some((problem) => problem.includes("counts.light"))).toBe(true); + expect(problems.some((problem) => problem.includes("counts.dark"))).toBe(true); + }); + + // These four are asserted identical on top of the pin. They are the non-colour + // roles where a silent mismatch is most consequential, so they may not be + // resolved by adding them to the pin — they have to actually agree. + const mustMatch = ["text-hero", "text-hero--line-height", "leading-prose", "ease-standard"]; function soleDeclaration(source: string, role: string, label: string) { const matches = [...source.matchAll(new RegExp(`^[ \\t]*--${role}:\\s*(.+);[ \\t]*$`, "gm"))]; @@ -561,7 +588,7 @@ describe("compat layer agrees with the v2 layer", () => { return matches[0][1].replace(/\s+/g, " ").trim(); } - for (const role of sharedRoles) { + for (const role of mustMatch) { it(`--${role} is identical in both layers`, () => { const compat = soleDeclaration(globals, role, "globals.css"); const v2 = soleDeclaration(v2Stylesheet, role, "ckb-v2-tokens.css"); diff --git a/tests/differential-detail.test.ts b/tests/differential-detail.test.ts index da68f9157..b40c8b17e 100644 --- a/tests/differential-detail.test.ts +++ b/tests/differential-detail.test.ts @@ -196,7 +196,7 @@ describe("formatDifferentialCopyText", () => { expect(text).toContain("- Six"); expect(text).not.toContain("- Seven"); expect(text).toContain("- Blood glucose"); - expect(text.endsWith("Clinical decision support only. Review before use.")).toBe(true); + expect(text.endsWith("Clinical reference — not validated decision support. Review before use.")).toBe(true); expect(text).not.toContain("undefined"); }); }); From 33bfca886fd1c6c0c46350ce3baec1ba579fa9e3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:52:59 +0800 Subject: [PATCH 37/50] Surface clinical answer failures the task ledger already records (#2498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(design-sync): give the two subprocess contract tests a load-tolerant timeout Both shell out to a Node child process. Measured on this worktree: the child finishes in ~7 s standalone and the tests take ~9 s and ~10 s, so the global 30 s budget looks generous in isolation. It is not under the full run — the node suite is CPU-bound on two workers across 900+ files, and on 2026-09-01 the parity test exceeded 30 000 ms inside `npm run test` and failed the gate for a diff that touched nothing under `.design-sync/**`. Deliberately not quarantined: it did not fail an assertion, and a wrong time budget is a different defect from an unstable test. `tests/flake-ledger.json` is untouched, and the repo's three-reproductions rule does not apply here. Proven rather than assumed: setting the same option to 1 ms makes the test fail with "Test timed out in 1ms", so the option is read rather than silently ignored. Committed with SKIP_DOCS_SYNC_HOOK=1 because the pre-commit generator refuses a partially staged tree; the sibling commit that follows runs it in full on the final tree, which is what gets pushed. Co-Authored-By: Claude Opus 5 * feat(developer-hub): wire the environment strip to real environment facts The strip shipped in Phase 1 with `demoMode`, `documentCount` and `email` all `null`, rendering "environment unknown · document count unavailable · account unknown". Phase 2's design deferred them to whichever phase owned each value's data source and reserved any Supabase read from the hub for its own approval; that approval was given on 2026-09-01. `resolveHubEnvironmentFacts` gathers all three in one Supabase round trip. Two of its rules are load-bearing, and each is pinned by a test proven to fail without it: - The count goes through the cookie-bound user client, so row-level security (`documents owner read`, `owner_id = auth.uid()`) scopes it to the caller's own documents. The service-role admin client bypasses RLS and would report every owner's total to whoever was signed in; a source assertion fails if that import ever appears. - Every failure path returns `null`, never `0`, and an unauthenticated request skips the query rather than reporting the zero rows RLS would correctly hand it. Zero is a true answer here, so a failed read must not be able to impersonate an empty corpus. Reading data makes the page an async Server Component, so the dom tests and the shared in-page-nav route table now await their fixtures. `next build` classifies `/mockups/development` as dynamic, which it already was via the layout's `headers()` call, so nothing regressed there. The Phase 2 handoff document is corrected here rather than left claiming the strip is unwired. Co-Authored-By: Claude Opus 5 * fix(developer-hub): degrade the environment strip when a Supabase read rejects Raised in review of #2495. The helper handled a returned `{ error }` but not a rejected promise, and the installed client rejects rather than resolves when a request is aborted or exhausts its network retries. An unhandled rejection there fails the whole developer hub page instead of degrading one line of it — during exactly the Supabase outage that makes the page worth opening, and contrary to the module's own stated contract. Both awaits are now inside one guard, not just the count: an auth read can fail the same way. `demoMode` is resolved before either of them and survives both, because it never depended on the network, so an outage cannot make the strip claim it is unable to tell demo data from live. Proven, not assumed: two tests whose mocked `select` and `getUser` reject both fail without the guard and pass with it, and they assert the whole returned shape rather than merely that nothing threw. Co-Authored-By: Claude Opus 5 * feat(developer-hub): surface clinical answer failures the ledger already records The task ledger already records which clinical questions the answer engine gets wrong -- today, three open items naming five questions between them, including antipsychotic metabolic monitoring returning a placeholder instead of an answer. Those facts sit unread among eighty-one open items. This puts them on the hub. `resolveClinicalAnswerFailures` intersects two records the repository already maintains: open ledger items, and the eval case list that names each clinical question. Derived rather than curated on purpose -- a hand-kept list of ledger ids is exact the day it is written and wrong a month later, with nothing to catch the drift. Matching is whole-token, and that is the load-bearing part rather than a detail. Case ids nest: `discharge-documentation` and `patient-safety-plan` are both real cases AND substrings of other real ones, so a plain `includes` reported five questions where three were real -- two clinical questions on screen that nothing had reported as broken. Proven by mutation: reverting the matcher to `includes` turns both the synthetic nesting test and the real-data property test red. The panel names itself for its evidence ("Open problems recorded against a named clinical question"), and states above the list that a recorded problem which does not name a case is not shown. On a clinical surface the reassuring-looking empty state is the dangerous one, so the empty branch says in words that a quiet ledger is not a well answer engine. Both are pinned by tests. A hub band links straight to the panel, in warning rather than danger colours: these are recorded problems, not blocking work, and painting them the same red as the blocking band would teach the reader to discount both. Co-Authored-By: Claude Opus 5 * fix(developer-hub): a named eval case is a reference, not a verdict on that case Raised in review of #2498, and confirmed against the real ledger text rather than taken on trust. `#J8SJQ9` is about `quality-antipsychotic-metabolic-monitoring` and names `quality-discharge-documentation` only as the CONTRAST -- the case that "deliberately drops mustContainAny" because a source pointer is a legitimate answer there. The panel listed it among the questions and counted it, stating on a clinical surface the opposite of what the ledger says. Two tempting fixes were tried against the real data and rejected: - Matching only the `source` field fixes `#J8SJQ9` and HIDES both genuinely broken questions in `#S4R2W3`, which names them only in its detail prose. On this panel a false negative is worse than a loose one. - Guessing intent from surrounding words is the fragile heuristic the panel exists to avoid; it would be wrong quietly, later. So the assertion moves to the level the data supports. The item-level claim was always sound -- these are recorded problems about clinical answers -- and the named cases are now presented as references: the section reads "Questions this item names", the count is "questions referenced" (`affectedQuestionCount` becomes `referencedQuestionCount`), and a second caveat says in words that an item may name a question as the contrast that is behaving correctly. Tests: the reviewer's requested fixture reproduces the real `#J8SJQ9` detail and pins that BOTH cases come back as references, with a comment saying a later change may only narrow that via an explicit per-case association in the ledger, never a heuristic. A DOM test pins the wording, and fails when the caveat is removed -- proven by mutation, then restored byte-identical. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- data/outstanding-issues-snapshot.json | 8 +- data/repo-awareness-snapshot.json | 13 +- docs/site-map.md | 1 + .../clinical-answer-failures/page.tsx | 124 ++++++++++ src/app/mockups/development/page.tsx | 22 ++ .../clinical-answer-failures.ts | 113 +++++++++ src/lib/developer-area/hub-panels.ts | 13 ++ ...clinical-answer-failures-page.dom.test.tsx | 146 ++++++++++++ ...developer-clinical-answer-failures.test.ts | 221 ++++++++++++++++++ tests/developer-hub-page.dom.test.tsx | 44 ++++ 10 files changed, 700 insertions(+), 5 deletions(-) create mode 100644 src/app/mockups/development/clinical-answer-failures/page.tsx create mode 100644 src/lib/developer-area/clinical-answer-failures.ts create mode 100644 tests/developer-clinical-answer-failures-page.dom.test.tsx create mode 100644 tests/developer-clinical-answer-failures.test.ts diff --git a/data/outstanding-issues-snapshot.json b/data/outstanding-issues-snapshot.json index e61c4caf8..ff5cd5185 100644 --- a/data/outstanding-issues-snapshot.json +++ b/data/outstanding-issues-snapshot.json @@ -10,7 +10,7 @@ "p2": 49, "p3": 31, "queued": 7, - "pending": 84, + "pending": 85, "resolved": 431 }, "queue": [ @@ -884,6 +884,12 @@ "summary": "#GQ5X8T: NOT REPRODUCED ON PINNED LINUX; WINDOWS-SPECIFIC CAUSE UNRESOLVED. Eleven retained PR #2313 ubuntu-24.04 Lighthouse artifacts used Lighthouse 12.8.2 and pinned HeadlessChrome/151.0.0.0. In the nine-run synchronized-baseline subset, mobile-root LCP was 2260.791-2300.923ms with median 2287.574ms versus 2274.017ms baseline (+13.557ms, +0.60%); TBT median was 430.602ms versus 436.600ms baseline (-1.37%); CLS was 0 throughout. Across all eleven heads LCP was 2221.675-2300.923ms, never reproducing the Windows 7422-8032ms state. The exact local 883f1007 control / 7cf37b53 treatment snapshots had identical lock hashes, but the local host stayed at 42.3-62.9% CPU and the control build hit the 10-minute Linux cap after compiling, before measurement; that attempt is environment-blocked and was not rerun. This excludes a repeatable cross-platform regression but cannot distinguish Windows host noise from a Windows-specific client regression. No product, budget, tolerance, request-count, or proven CLS change was made. Reopen if a pinned Linux exact-head run breaches repeatably or a synchronized quiet-host Windows control reproduces the slowdown. Evidence: docs/evidence/mobile-root-timing-control-2026-08-26.md.", "created_at": "2026-08-26" }, + { + "request_id": "224cda1c-ae72-4921-b8aa-e6b3e315796a", + "action": "update", + "summary": "#ZBAC9D: detail → documents.owner_id is ON DELETE SET NULL (supabase/schema.sql) while a null owner independently means 'public corpus' in retrieval. CORRECTION (Codex on PR #2494): deleting an auth user does NOT silently republish private documents — documents_require_publication_approval (BEFORE INSERT OR UPDATE) runs guard_document_publication_transition, which rejects every non-null-to-null owner transition without matching publication evidence, so the user-delete transaction fails instead of orphaning/republishing. Distinguish blocked user deletion from any pre-existing orphan rows. RE-VERIFIED against main and the P2 -> P1 escalation still holds on the retrieval hazard, not on a live delete-to-publish path. (1) The retrieval path is NOT closed at any layer. public.retrieval_owner_matches still resolves the public sentinel to 'row_owner_id is null' with no public_corpus condition, and grep for 'public_corpus' returns ZERO occurrences across the entire retrieval chain: rag/rag-candidate-sources.ts, corpus-grounding.ts, deep-memory.ts, document-enrichment.ts, owner-scope.ts, clinical-search.ts and query-privacy.ts. Any ownerless row (pre-existing orphan or armed hole) is therefore retrievable as public corpus and citable in an answer to an anonymous query. (2) The helper written to close exactly this hazard is DEAD CODE. src/lib/documents/is-public-document.ts requires owner_id === null AND metadata.public_corpus === true, and tests/documents-is-public.test.ts:22 pins the precise case ('Orphaned after user delete' -> false), but grep across src/ and worker/ finds NO production caller outside its own module and the barrel re-export. Same shape as #A3AHCX. (3) The only genuinely closed application read path is the REST document read: withOwnerReadScope in src/lib/public-api-access.ts applies .is('owner_id', null).eq('metadata->>public_corpus','true'). NEXT, in order: (a) one read-only live count of documents where owner_id is null and metadata->>'public_corpus' is distinct from 'true' — this decides whether the hole is currently leaking or merely armed, and needs owner approval as a provider-backed read; (b) close the retrieval path, which is a genuine retrieval behaviour change on a protected RAG surface and so needs the RAG flag plus a live eval-canary pair; (c) add a database proof that deleting an owner of an unapproved private document fails and creates no ownerless indexed row, then in an approved production window change documents.owner_id to ON DELETE RESTRICT (or an explicit visibility column) and align the RPC predicate. Note the offline proof that no table whose null owner means public declares ON DELETE SET NULL cannot be added green today — it would fail on documents.owner_id, so it is the guard that accompanies (c), not a step that precedes it.; priority → P1; source → repo-wide audit at 3ed1932 (six domain reviewers); re-verified and expanded against main a633eb6e3 by the database coordination chat 2026-09-01; deletion-path correction per Codex review on PR #2494", + "created_at": "2026-09-01" + }, { "request_id": "256a5104-df13-44d3-9734-645e08b2b0df", "action": "cancel", diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index dd4998adf..97f1ab385 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "612e9735c0096c046fad01a1b3a3b23482b3fc07", - "committed_at": "2026-09-01T17:10:51+08:00" + "sha": "bf4489206167d41e78e629e10194a3856616afea", + "committed_at": "2026-09-01T18:36:13+08:00" }, "routes": { "modes": [ @@ -573,6 +573,11 @@ "file": "src/app/mockups/development/page.tsx", "area": "mockup" }, + { + "path": "/mockups/development/clinical-answer-failures", + "file": "src/app/mockups/development/clinical-answer-failures/page.tsx", + "area": "mockup" + }, { "path": "/mockups/development/clinical-trust", "file": "src/app/mockups/development/clinical-trust/page.tsx", @@ -1420,9 +1425,9 @@ ], "counts": { "modes": 15, - "pages": 198, + "pages": 199, "product_pages": 63, - "mockup_pages": 135, + "mockup_pages": 136, "redirects": 17, "api": 60 } diff --git a/docs/site-map.md b/docs/site-map.md index 16f8a6667..dd7923767 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1199,6 +1199,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/caring-contacts/templates` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/templates/page.tsx`. - `/mockups/caring-contacts/templates/[pathwayId]` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/templates/[pathwayId]/page.tsx`. - `/mockups/development` - Route discovered from app directory Source: `src/app/mockups/development/page.tsx`. +- `/mockups/development/clinical-answer-failures` - Route discovered from app directory Source: `src/app/mockups/development/clinical-answer-failures/page.tsx`. - `/mockups/development/clinical-trust` - Route discovered from app directory Source: `src/app/mockups/development/clinical-trust/page.tsx`. - `/mockups/development/documentation` - Route discovered from app directory Source: `src/app/mockups/development/documentation/page.tsx`. - `/mockups/development/ingestion` - Route discovered from app directory Source: `src/app/mockups/development/ingestion/page.tsx`. diff --git a/src/app/mockups/development/clinical-answer-failures/page.tsx b/src/app/mockups/development/clinical-answer-failures/page.tsx new file mode 100644 index 000000000..4f745d437 --- /dev/null +++ b/src/app/mockups/development/clinical-answer-failures/page.tsx @@ -0,0 +1,124 @@ +import type { Metadata } from "next"; + +import { PanelPageShell } from "@/components/developer-area/hub/panel-page-shell"; +import { CountTile, META_CLASS, PanelSection } from "@/components/developer-area/hub/panel-primitives"; +import { referencedQuestionCount, resolveClinicalAnswerFailures } from "@/lib/developer-area/clinical-answer-failures"; +import { loadLedgerSnapshot, resolveFreshness } from "@/lib/developer-area/ledger-snapshot"; + +export const metadata: Metadata = { + title: "Answer failures · Developer · PsychSift", + description: "Open ledger items that name one of the repository's clinical eval questions.", +}; + +export default function DeveloperClinicalAnswerFailuresPage() { + const snapshot = loadLedgerSnapshot(); + const now = new Date(); + const freshness = resolveFreshness(snapshot, now); + const failures = resolveClinicalAnswerFailures(snapshot); + const questions = referencedQuestionCount(failures); + + return ( + +
+ + +
+ + {/* + * The scope caveat is page content, not a footnote, and it is deliberately + * above the list rather than below it. This page reports the intersection + * of two records — the task ledger and the eval case list — and that + * intersection is narrower than "clinical answer quality". Anyone reading + * an empty list here must not conclude the answer engine is well. + */} +

+ Every open item in the task ledger that names one of the repository’s clinical eval questions by its case + id. That is a narrower thing than a picture of answer quality: a recorded problem that does not name a case is + not listed here, and a question that is failing right now but that nobody has written up is not either. An empty + list means nothing is recorded against a named question, not that every question answers well. +

+ {/* + * The correction from review of #2498, and the reason the questions below + * are labelled as referenced rather than affected. An item names a case for + * more than one reason: `#J8SJQ9` names the discharge-documentation case as + * the *contrast* that legitimately answers with a source pointer. Nothing in + * the text separates that from a case being reported broken, so the page + * asserts at the level it can stand behind -- the item -- and leaves the + * reader to open it. + */} +

+ The questions under each item are the ones its text names, not a verdict on each question. An item may + name a question as the contrast that is behaving correctly, so read the item before concluding that a question + listed here answers badly. +

+ + + {failures.length > 0 ? ( +
    + {failures.map(({ item, cases }) => ( +
  • +

    {item.summary}

    +

    + {item.id} · {item.priority} · recorded {item.added} +

    +

    Questions this item names

    +
      + {cases.map((testCase) => ( +
    • + {/* + * The question as a clinician would ask it, with the case + * id beside it rather than instead of it. The id is what + * makes the match verifiable; the question is what makes + * the entry mean anything to a reader. + */} + “{testCase.question}” + {testCase.id} +
    • + ))} +
    +
  • + ))} +
+ ) : ( + /* + * In words, never a blank container: an empty result and a failed load + * look identical otherwise, and here the difference matters more than + * usual because the empty state is the reassuring-looking one. + */ +
+

+ No open ledger item names a clinical eval question. +

+

+ That is the ledger being quiet, not the answer engine being proven well. Answer quality itself is measured + by the eval runs, which this page does not read. +

+
+ )} +
+
+ ); +} diff --git a/src/app/mockups/development/page.tsx b/src/app/mockups/development/page.tsx index 68b2fb681..ccc1b77f0 100644 --- a/src/app/mockups/development/page.tsx +++ b/src/app/mockups/development/page.tsx @@ -1,10 +1,12 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { ShieldAlert } from "lucide-react"; import { DeveloperHubNavHeader } from "@/components/developer-area/developer-hub-nav-header"; import { EnvironmentStrip } from "@/components/developer-area/hub/environment-strip"; import { PanelCard } from "@/components/developer-area/hub/panel-card"; import { inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; +import { resolveClinicalAnswerFailures } from "@/lib/developer-area/clinical-answer-failures"; import { resolveHubEnvironmentFacts } from "@/lib/developer-area/environment-facts"; import { panelsInGroup, type HubPanelGroup } from "@/lib/developer-area/hub-panels"; import { loadLedgerSnapshot } from "@/lib/developer-area/ledger-snapshot"; @@ -52,6 +54,7 @@ const GROUPS: { id: HubPanelGroup; anchor: string; label: string }[] = [ export default async function DeveloperHubPage() { const snapshot = loadLedgerSnapshot(); const environment = await resolveHubEnvironmentFacts(); + const clinicalAnswerFailures = resolveClinicalAnswerFailures(snapshot); return ( <> @@ -113,6 +116,25 @@ export default async function DeveloperHubPage() {

) : null} + {/* + * Warning, not danger: these are recorded problems against named + * clinical questions, and today they are P2/P3 rather than blocking. + * Painting them the same red as the blocking band would teach the reader + * to discount both. The band exists at all because the same facts sit + * unread inside an 81-item ledger, which is the whole reason this panel + * was built. + */} + {clinicalAnswerFailures.length > 0 ? ( + + {clinicalAnswerFailures.length} recorded {clinicalAnswerFailures.length === 1 ? "problem" : "problems"}{" "} + against a named clinical question. + + ) : null} + {GROUPS.map((group) => { const panels = panelsInGroup(group.id); // An empty group must render no anchor at all. `useResolvedPageSections` diff --git a/src/lib/developer-area/clinical-answer-failures.ts b/src/lib/developer-area/clinical-answer-failures.ts new file mode 100644 index 000000000..cab8955b2 --- /dev/null +++ b/src/lib/developer-area/clinical-answer-failures.ts @@ -0,0 +1,113 @@ +import { answerQualityEvalCases, ragEvalCases } from "@/lib/rag/rag-eval-cases"; + +import type { LedgerOpenItem, LedgerSnapshot } from "./ledger-snapshot"; + +export type NamedEvalCase = { id: string; question: string }; + +export type ClinicalAnswerFailure = { + item: LedgerOpenItem; + /** + * The eval cases this item's text NAMES. A mention is a reference, not a + * verdict on that case -- see `resolveClinicalAnswerFailures` for why the + * difference is load-bearing, and never relabel this field as the cases the + * item reports broken. + */ + cases: NamedEvalCase[]; +}; + +const PRIORITY_ORDER: Record = { P1: 0, P2: 1, P3: 2 }; + +/** + * Every eval case the repository names, keyed by id, with duplicates collapsed. + * + * Read-only. This module imports a protected RAG surface and must never write to + * one: the case list is the repository's own record of which clinical questions + * the answer engine is held to, and duplicating it here would create a second + * source of truth that drifts silently. + */ +function allEvalCases(): NamedEvalCase[] { + const byId = new Map(); + for (const testCase of [...ragEvalCases, ...answerQualityEvalCases]) { + if (!byId.has(testCase.id)) byId.set(testCase.id, { id: testCase.id, question: testCase.question }); + } + return [...byId.values()]; +} + +/** + * Whether `haystack` names `id` as a whole token rather than as part of a longer + * one. + * + * This is the difference between a correct panel and a quietly wrong one. Case + * ids nest: `discharge-documentation` is a real case and is also a substring of + * the real case `quality-discharge-documentation`. A plain `includes` therefore + * reports the short case as failing whenever the long one is mentioned, and the + * panel would show a clinical question that nothing said was broken. Requiring + * the surrounding characters to fall outside the id alphabet removes that whole + * class without needing to know which pairs happen to nest today. + */ +function namesCase(haystack: string, id: string): boolean { + const isIdCharacter = (character: string | undefined) => character !== undefined && /[a-z0-9-]/.test(character); + let from = 0; + for (;;) { + const at = haystack.indexOf(id, from); + if (at === -1) return false; + if (!isIdCharacter(haystack[at - 1]) && !isIdCharacter(haystack[at + id.length])) return true; + from = at + 1; + } +} + +/** + * The open ledger items that name at least one of the repository's clinical eval + * cases, newest concern first by priority. + * + * **What this is, and what it deliberately is not.** It is the set of recorded + * problems whose text names a clinical question. It is *not* a complete picture + * of clinical answer quality: an item that describes a bad answer without naming + * a case is invisible here, and so is a case that is failing right now but that + * nobody has written an item about. + * + * **A named case is a REFERENCE, not an assertion that the case is failing, and + * the two cannot be told apart from the text.** Raised in review of PR #2498 + * against a real item: `#J8SJQ9` is about `quality-antipsychotic-metabolic-monitoring`, + * and names `quality-discharge-documentation` only as the *contrast* -- the case + * that "deliberately drops mustContainAny" because a source pointer is a + * legitimate answer there. Reporting that as a failure states the opposite of + * what the ledger says, about a clinical question. + * + * Two tempting fixes were rejected against the real data. Restricting the match + * to the `source` field fixes `#J8SJQ9` and **hides both genuinely broken + * questions in `#S4R2W3`**, which names them only in its detail prose; on this + * panel a false negative is worse than a loose one. Guessing intent from the + * surrounding words is the fragile heuristic this panel exists to avoid. So the + * assertion is made at the level it is sound at -- the item -- and named cases + * are presented as references. The panel's wording must match that, and a + * curated per-case association would need the ledger to record one explicitly. + * + * The matching is derived rather than curated on purpose. A hand-kept list of + * ledger ids would be exact on the day it was written and wrong a month later, + * with nothing to catch the drift. + */ +export function resolveClinicalAnswerFailures(snapshot: LedgerSnapshot): ClinicalAnswerFailure[] { + const cases = allEvalCases(); + + return snapshot.open + .map((item) => { + const haystack = `${item.summary} ${item.detail} ${item.source}`; + return { item, cases: cases.filter((testCase) => namesCase(haystack, testCase.id)) }; + }) + .filter((failure) => failure.cases.length > 0) + .sort((a, b) => { + const byPriority = (PRIORITY_ORDER[a.item.priority] ?? 99) - (PRIORITY_ORDER[b.item.priority] ?? 99); + return byPriority !== 0 ? byPriority : a.item.id.localeCompare(b.item.id); + }); +} + +/** + * The distinct clinical questions REFERENCED across the open items. Named for + * what it counts: these are questions the items mention, not questions proven to + * be failing. It was called `affectedQuestionCount` until review of PR #2498 + * showed the panel was counting a contrast case as affected. + */ +export function referencedQuestionCount(failures: ClinicalAnswerFailure[]): number { + return new Set(failures.flatMap((failure) => failure.cases.map((testCase) => testCase.id))).size; +} diff --git a/src/lib/developer-area/hub-panels.ts b/src/lib/developer-area/hub-panels.ts index f54fab427..3ed5e6916 100644 --- a/src/lib/developer-area/hub-panels.ts +++ b/src/lib/developer-area/hub-panels.ts @@ -57,6 +57,19 @@ export const HUB_PANELS: readonly HubPanel[] = [ phase: 1, href: "/mockups/development/clinical-trust", }, + // Named for its evidence, not for its subject. It reports open ledger items + // that name one of the repository's own clinical eval cases -- not "every + // clinical answer problem", which it has no way to know. A panel on a clinical + // system that implies coverage it does not have is worse than no panel, so the + // narrower name is deliberate and should not be "improved" into a broader one. + { + id: "clinical-answer-failures", + name: "Answer failures", + summary: "Open problems recorded against a named clinical question", + group: "clinical", + phase: 1, + href: "/mockups/development/clinical-answer-failures", + }, // Kept, unlike the five removed above, and settled: the owner ruled on // 2026-08-26 that the hazard register belongs in the developer hub rather // than as a separate clinical-safety surface. It was never a removal diff --git a/tests/developer-clinical-answer-failures-page.dom.test.tsx b/tests/developer-clinical-answer-failures-page.dom.test.tsx new file mode 100644 index 000000000..32a72de3e --- /dev/null +++ b/tests/developer-clinical-answer-failures-page.dom.test.tsx @@ -0,0 +1,146 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import DeveloperClinicalAnswerFailuresPage from "@/app/mockups/development/clinical-answer-failures/page"; + +// PanelPageShell's back control is a ContextualBackLink, which calls +// next/navigation's useRouter for its history-aware click handler. Outside an +// app-router tree that throws, so every render here needs the router mocked. +vi.mock("next/navigation", () => ({ + usePathname: () => "/mockups/development/clinical-answer-failures", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), +})); + +/** + * The derivation is mocked so this file stays a *page* contract — what is + * rendered for a given set of failures. Its correctness (which items count, and + * the whole-token matching that keeps a nested case id off the screen) is owned + * by `tests/developer-clinical-answer-failures.test.ts`. + */ +const failures = vi.hoisted(() => ({ + value: [] as { + item: { + id: string; + priority: string; + type: string; + summary: string; + detail: string; + source: string; + added: string; + }; + cases: { id: string; question: string }[]; + }[], +})); + +vi.mock("@/lib/developer-area/clinical-answer-failures", () => ({ + resolveClinicalAnswerFailures: () => failures.value, + referencedQuestionCount: (list: { cases: { id: string }[] }[]) => + new Set(list.flatMap((failure) => failure.cases.map((testCase) => testCase.id))).size, +})); + +function failure(id: string, summary: string, cases: { id: string; question: string }[]) { + return { + item: { id, priority: "P2", type: "issue", summary, detail: "", source: "", added: "2026-08-22" }, + cases, + }; +} + +afterEach(() => { + failures.value = []; +}); + +describe("developer clinical answer failures page", () => { + it("renders inside the shared shell and stamps the ledger as its source", () => { + render(); + expect(screen.getByTestId("developer-clinical-answer-failures")).toBeInTheDocument(); + expect(screen.getByTestId("developer-clinical-answer-failures-back")).toHaveAttribute( + "href", + "/mockups/development", + ); + expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/Ledger/); + }); + + it("shows the clinical question in words, not only its case id", () => { + // The id is what makes the match checkable; the question is the only part a + // reader can act on. Losing the question would leave a page of slugs. + failures.value = [ + failure("#J8SJQ9", "Metabolic monitoring returns a stub", [ + { + id: "quality-antipsychotic-metabolic-monitoring", + question: "What metabolic monitoring is required for antipsychotics?", + }, + ]), + ]; + render(); + + const entry = screen.getByTestId("developer-clinical-answer-failure-#J8SJQ9"); + expect(entry).toHaveTextContent("What metabolic monitoring is required for antipsychotics?"); + expect(entry).toHaveTextContent("quality-antipsychotic-metabolic-monitoring"); + expect(entry).toHaveTextContent("Metabolic monitoring returns a stub"); + }); + + it("counts items and referenced questions separately, because one item can name several", () => { + failures.value = [ + failure("#S4R2W3", "Two questions answer with a bare title list", [ + { id: "quality-agitation-im-route", question: "Q1" }, + { id: "quality-duress-pathway", question: "Q2" }, + ]), + ]; + render(); + + expect(screen.getByTestId("developer-clinical-answer-failures-count-items")).toHaveTextContent("1"); + expect(screen.getByTestId("developer-clinical-answer-failures-count-questions")).toHaveTextContent("2"); + }); + + /** + * Raised in review of PR #2498. An item names a case for more than one reason + * — `#J8SJQ9` names the discharge-documentation case as the contrast that + * legitimately answers with a source pointer — so the page must not present a + * named question as a question proven broken. This pins the wording that keeps + * the claim at the level the data supports. + */ + it("presents named questions as references rather than as verdicts", async () => { + failures.value = [ + failure("#J8SJQ9", "Metabolic monitoring returns a stub", [ + { id: "quality-antipsychotic-metabolic-monitoring", question: "What metabolic monitoring is required?" }, + { id: "quality-discharge-documentation", question: "What discharge documentation is required?" }, + ]), + ]; + const { container } = render(); + const text = container.textContent ?? ""; + + expect(screen.getByTestId("developer-clinical-answer-failure-#J8SJQ9")).toHaveTextContent( + "Questions this item names", + ); + expect(text).toMatch(/not a verdict on each question/); + expect(text).toMatch(/name a question as the contrast that is behaving correctly/); + expect(screen.getByTestId("developer-clinical-answer-failures-count-questions")).toHaveTextContent( + "questions referenced", + ); + }); + + /** + * The empty state is the dangerous one on this page: it is the reading that + * looks like good news. It must say what it does not know, in words, rather + * than render a blank container that an empty list and a failed load would + * both produce. + */ + it("says what an empty list does not prove", () => { + render(); + + const empty = screen.getByTestId("developer-clinical-answer-failures-empty"); + expect(empty).toHaveTextContent(/No open ledger item names a clinical eval question/); + expect(empty).toHaveTextContent(/not the answer engine being proven well/); + }); + + it("states the limits of its own coverage above the list, not as a footnote", () => { + failures.value = [failure("#J8SJQ9", "A failure", [{ id: "quality-duress-pathway", question: "Q" }])]; + const { container } = render(); + + const text = container.textContent ?? ""; + expect(text).toMatch(/narrower thing than a picture of answer quality/); + // The caveat has to precede the evidence it qualifies; a reader who stops + // after the first entry must already have read it. + expect(text.indexOf("narrower thing")).toBeLessThan(text.indexOf("A failure")); + }); +}); diff --git a/tests/developer-clinical-answer-failures.test.ts b/tests/developer-clinical-answer-failures.test.ts new file mode 100644 index 000000000..40b973093 --- /dev/null +++ b/tests/developer-clinical-answer-failures.test.ts @@ -0,0 +1,221 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { LedgerOpenItem, LedgerSnapshot } from "@/lib/developer-area/ledger-snapshot"; + +// The panel this feeds is clinical-facing, so both directions of its matching +// are load-bearing. Listing a question nothing reported puts a false clinical +// failure on screen; missing one that was reported hides a real answer defect. +// The nesting case below is not hypothetical: `discharge-documentation` and +// `patient-safety-plan` are both real eval case ids AND substrings of other real +// ones, and a plain `includes` reported both as failing on the committed ledger. + +afterEach(() => { + vi.resetModules(); +}); + +function item(overrides: Partial = {}): LedgerOpenItem { + return { + id: "#AAA111", + priority: "P2", + type: "issue", + summary: "", + detail: "", + source: "", + added: "2026-08-01", + ...overrides, + }; +} + +function snapshotOf(open: LedgerOpenItem[]): LedgerSnapshot { + return { + version: "outstanding-issues-snapshot-v1", + ledger_revision: null, + counts: { open: open.length, p1: 0, p2: 0, p3: 0, queued: 0, pending: 0, resolved: 0 }, + queue: [], + open, + pending: [], + }; +} + +async function loadWithCases(cases: { id: string; question: string }[]) { + vi.doMock("@/lib/rag/rag-eval-cases", () => ({ + ragEvalCases: cases, + answerQualityEvalCases: [], + })); + return import("../src/lib/developer-area/clinical-answer-failures"); +} + +describe("resolveClinicalAnswerFailures", () => { + it("lists an open item that names a clinical eval case, with the question a reader can understand", async () => { + const { resolveClinicalAnswerFailures } = await loadWithCases([ + { id: "quality-antipsychotic-metabolic-monitoring", question: "What metabolic monitoring is required?" }, + ]); + + const failures = resolveClinicalAnswerFailures( + snapshotOf([ + item({ + summary: "Metabolic monitoring returns a stub", + source: "canary run 1; quality-antipsychotic-metabolic-monitoring", + }), + ]), + ); + + expect(failures).toHaveLength(1); + expect(failures[0].cases).toEqual([ + { id: "quality-antipsychotic-metabolic-monitoring", question: "What metabolic monitoring is required?" }, + ]); + }); + + it("does not report a case whose id is only a fragment of the id actually named", async () => { + const { resolveClinicalAnswerFailures } = await loadWithCases([ + { id: "discharge-documentation", question: "Short case that must not be implicated" }, + { id: "quality-discharge-documentation", question: "The case that was actually named" }, + ]); + + const failures = resolveClinicalAnswerFailures( + snapshotOf([item({ source: "fails targeting: quality-discharge-documentation" })]), + ); + + expect(failures[0].cases.map((testCase) => testCase.id)).toEqual(["quality-discharge-documentation"]); + }); + + it("still reports the short case when the text names it in its own right", async () => { + // The other half of the same contract. A boundary rule that never matched + // the shorter id would trade a false positive for a false negative, which on + // this panel is the worse of the two. + const { resolveClinicalAnswerFailures } = await loadWithCases([ + { id: "discharge-documentation", question: "Short case" }, + { id: "quality-discharge-documentation", question: "Long case" }, + ]); + + const failures = resolveClinicalAnswerFailures( + snapshotOf([item({ source: "both named: discharge-documentation and quality-discharge-documentation" })]), + ); + + expect(failures[0].cases.map((testCase) => testCase.id).sort()).toEqual([ + "discharge-documentation", + "quality-discharge-documentation", + ]); + }); + + it("searches the summary, the detail and the source, because items record the case id in any of them", async () => { + const { resolveClinicalAnswerFailures } = await loadWithCases([{ id: "quality-duress-pathway", question: "Q" }]); + + for (const field of ["summary", "detail", "source"] as const) { + const failures = resolveClinicalAnswerFailures(snapshotOf([item({ [field]: "affects quality-duress-pathway" })])); + expect(failures, `case id in ${field} was not found`).toHaveLength(1); + } + }); + + it("ignores an open item that names no case at all", async () => { + const { resolveClinicalAnswerFailures } = await loadWithCases([{ id: "quality-duress-pathway", question: "Q" }]); + + expect(resolveClinicalAnswerFailures(snapshotOf([item({ summary: "Bundle budget baseline is stale" })]))).toEqual( + [], + ); + }); + + it("orders blocking items above the rest rather than leaving ledger order to chance", async () => { + const { resolveClinicalAnswerFailures } = await loadWithCases([{ id: "quality-duress-pathway", question: "Q" }]); + + const failures = resolveClinicalAnswerFailures( + snapshotOf([ + item({ id: "#P3ONLY", priority: "P3", source: "quality-duress-pathway" }), + item({ id: "#P1FIRST", priority: "P1", source: "quality-duress-pathway" }), + item({ id: "#P2MID", priority: "P2", source: "quality-duress-pathway" }), + ]), + ); + + expect(failures.map((failure) => failure.item.id)).toEqual(["#P1FIRST", "#P2MID", "#P3ONLY"]); + }); + + it("counts each referenced question once even when several items name it", async () => { + const { resolveClinicalAnswerFailures, referencedQuestionCount } = await loadWithCases([ + { id: "quality-duress-pathway", question: "Q1" }, + { id: "quality-agitation-im-route", question: "Q2" }, + ]); + + const failures = resolveClinicalAnswerFailures( + snapshotOf([ + item({ id: "#ONE", source: "quality-duress-pathway quality-agitation-im-route" }), + item({ id: "#TWO", source: "quality-duress-pathway" }), + ]), + ); + + expect(failures).toHaveLength(2); + expect(referencedQuestionCount(failures)).toBe(2); + }); + + /** + * The shape review of PR #2498 caught, reproduced from the real `#J8SJQ9` + * detail: an item about one case names a second only as the *contrast* that + * legitimately answers with a source pointer. + * + * The assertion is that BOTH come back, and that is not a bug being enshrined. + * Nothing in the text distinguishes a contrast from a report, so the module + * returns references and the page presents them as references. Restricting the + * match to the `source` field was tried against the real ledger and rejected: + * it fixes this item and hides both genuinely broken questions in `#S4R2W3`, + * which names them only in its detail prose. + * + * If a later change makes this return one case, it must be because the ledger + * gained an explicit per-case association — not because a heuristic guessed. + */ + it("returns a contrast case as a reference, and does not pretend to know it is failing", async () => { + const { resolveClinicalAnswerFailures } = await loadWithCases([ + { id: "quality-antipsychotic-metabolic-monitoring", question: "What metabolic monitoring is required?" }, + { id: "quality-discharge-documentation", question: "What discharge documentation is required?" }, + ]); + + const failures = resolveClinicalAnswerFailures( + snapshotOf([ + item({ + id: "#J8SJQ9", + summary: "Antipsychotic metabolic monitoring returns a source-backed stub instead of a written answer", + detail: + "All four cases carrying that flag document the same rationale: the corpus has no single authoritative " + + "source, so a source pointer is a legitimate answer, and quality-discharge-documentation deliberately " + + "drops mustContainAny for exactly that reason. quality-antipsychotic-metabolic-monitoring is the " + + "opposite case - it names expectedFiles and an authoritative source exists.", + source: "canary run 32589154243; quality-antipsychotic-metabolic-monitoring", + }), + ]), + ); + + expect(failures[0].cases.map((testCase) => testCase.id).sort()).toEqual([ + "quality-antipsychotic-metabolic-monitoring", + "quality-discharge-documentation", + ]); + }); + + /** + * Against the real eval case list and the real committed ledger, asserted as a + * property rather than as a fixed expectation: a hard-coded list of today's + * three items would fail the next time the ledger legitimately changes, and + * would be "fixed" by pasting in whatever the code then produced, which proves + * nothing. What must always hold is that every reported case was named as a + * whole token by the item reporting it. + */ + it("reports only whole-token matches when run against the repository's real data", async () => { + // `vi.resetModules()` clears the module cache but NOT the mock registry, so + // without this `doUnmock` the "real data" below silently ran against + // whichever `loadWithCases` fixture the previous test registered — a check + // that could not fail. Caught by mutating the matcher to a plain `includes` + // and watching this test stay green while the synthetic one went red. + vi.doUnmock("@/lib/rag/rag-eval-cases"); + vi.resetModules(); + const { resolveClinicalAnswerFailures } = await import("../src/lib/developer-area/clinical-answer-failures"); + const { loadLedgerSnapshot } = await import("../src/lib/developer-area/ledger-snapshot"); + + for (const { item: openItem, cases } of resolveClinicalAnswerFailures(loadLedgerSnapshot())) { + const haystack = `${openItem.summary} ${openItem.detail} ${openItem.source}`; + for (const testCase of cases) { + expect( + new RegExp(`(^|[^a-z0-9-])${testCase.id}([^a-z0-9-]|$)`).test(haystack), + `${openItem.id} reports ${testCase.id}, which its text does not name as a whole token`, + ).toBe(true); + expect(testCase.question.length, `${testCase.id} has no question text to show a reader`).toBeGreaterThan(0); + } + } + }); +}); diff --git a/tests/developer-hub-page.dom.test.tsx b/tests/developer-hub-page.dom.test.tsx index 638f4eb91..d869ef0ad 100644 --- a/tests/developer-hub-page.dom.test.tsx +++ b/tests/developer-hub-page.dom.test.tsx @@ -47,6 +47,18 @@ vi.mock("@/lib/developer-area/environment-facts", () => ({ resolveHubEnvironmentFacts: async () => environment.value, })); +/** + * The band is the whole reason the panel was built: the same facts already sit + * in the ledger this page renders, buried among eighty-odd open items. Mocked + * here so both the present and absent branches are reachable regardless of what + * the committed ledger happens to hold today. + */ +const clinicalAnswerFailures = vi.hoisted(() => ({ value: [] as { item: { id: string } }[] })); + +vi.mock("@/lib/developer-area/clinical-answer-failures", () => ({ + resolveClinicalAnswerFailures: () => clinicalAnswerFailures.value, +})); + /** * Only `counts.p1` is overridden, and only on top of the *real* committed * snapshot — so the band is exercised against the shape the route actually @@ -356,3 +368,35 @@ describe("developer hub page — environment strip", () => { expect(screen.getByTestId("developer-hub-environment-strip")).toHaveTextContent("account unknown"); }); }); + +describe("developer hub page — clinical answer failure band", () => { + afterEach(() => { + clinicalAnswerFailures.value = []; + }); + + it("links recorded clinical answer problems straight to their panel", async () => { + clinicalAnswerFailures.value = [{ item: { id: "#J8SJQ9" } }, { item: { id: "#S4R2W3" } }]; + render(await DeveloperHubPage()); + + const band = screen.getByTestId("developer-hub-clinical-answer-failures-band"); + expect(band).toHaveTextContent("2 recorded problems against a named clinical question."); + expect(band).toHaveAttribute("href", "/mockups/development/clinical-answer-failures"); + }); + + it("says problem, not problems, for a single one", async () => { + clinicalAnswerFailures.value = [{ item: { id: "#J8SJQ9" } }]; + render(await DeveloperHubPage()); + + expect(screen.getByTestId("developer-hub-clinical-answer-failures-band")).toHaveTextContent( + "1 recorded problem against a named clinical question.", + ); + }); + + it("renders nothing rather than a reassuring all-clear when none are recorded", async () => { + // Same rule the blocking band follows. An explicit "none recorded" here + // would read as "answers are fine", which this data cannot support. + render(await DeveloperHubPage()); + + expect(screen.queryByTestId("developer-hub-clinical-answer-failures-band")).toBeNull(); + }); +}); From b04f38b5c996700e52945869ea743086c1288a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 11:57:22 +0000 Subject: [PATCH 38/50] fix(design-system): conditional media blocks no longer leak into the base theme comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 on #2499, verified and confirmed. `collect()` filtered only on `forced-colors`, so every OTHER `@media` block was admitted into the unconditional theme map and its override replaced the base declaration. Comparing a narrow-viewport override against an unconditional v2 declaration compares two different contexts, and it reports "identical" for a pair that diverges everywhere the condition does not apply. Proven on real data, not argued: before this change the light compat map held `--mode-home-copy-reserve` as the `(min-width: 412px)` override (`calc(var(--text-hero) * …)`) rather than its unconditional declaration at globals.css:1240 (`calc(2 * var(--text-hero) * …)`). globals.css has three such `:root` blocks today — `--mode-home-copy-reserve` twice and `--spacing-mode-home-composer-wide` once. Reproduced as a false pass with the synthetic wide-viewport case the review asked for: base compat `--radius-md: 0.7rem`, an override at `(min-width: 9999px)` matching v2's `0.625rem`, v2 unconditionally `0.625rem`. The old predicate reported no divergence; the new one flags it. Latent rather than live today only because the v2 layer declares none of the three affected names. Base themes now take unconditional blocks only. Conditional non-forced-colors blocks are excluded rather than merged, which is the conservative direction: a token declared only under such a condition goes uncompared instead of being compared wrongly. Forced colours remains its own theme — it is a condition, and it is modelled as one rather than treated as base. Also fixes the return typing of `readLayers`, which inferred `{}` and broke `npm run typecheck` for the new test's consumers. Verification Full unit suite 937 files / 11,798 tests passed, 1 skipped, 0 failures. lint, typecheck and the design-system contract chain clean; pin unchanged at light 29 / dark 22 / forcedColors 3, confirming this was latent. The new regression test is mutation-verified: restoring the old predicate fails it, and it asserts against the real stylesheet rather than a fixture, so it tracks the file as it changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015uQ7pGjA4hBxi9TRNsfjAL --- scripts/token-layer-divergences.mjs | 27 ++++++++++++++++++++++++++- tests/design-token-contract.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/scripts/token-layer-divergences.mjs b/scripts/token-layer-divergences.mjs index 51878e3e3..6cd2e7148 100644 --- a/scripts/token-layer-divergences.mjs +++ b/scripts/token-layer-divergences.mjs @@ -133,11 +133,32 @@ const THEMES = { }, }; +/** + * Declarations for one theme, from blocks whose CONDITION matches that theme. + * + * The base themes take unconditional blocks only. An earlier version tested just + * `/forced-colors/`, which let any OTHER `@media` block into the base map, where a + * later responsive override silently replaced the base value — `globals.css` has + * three such `:root` blocks today (`--mode-home-copy-reserve` twice, + * `--spacing-mode-home-composer-wide` once). Comparing a narrow-viewport override + * against an unconditional v2 declaration is comparing two different contexts, and + * it reports identical when they diverge everywhere the condition does not apply. + * + * Conditional non-forced-colors blocks are therefore excluded rather than merged. + * That is the conservative direction: a token declared ONLY under such a condition + * goes uncompared instead of being compared wrongly. `unconditionalOnly` is not a + * synonym for "no media" in the forced-colours case, which is itself a condition and + * is modelled as its own theme. + */ function collect(blocks, matches, wantForcedColors) { const map = new Map(); for (const block of blocks) { const inForcedColors = /forced-colors/.test(block.media); - if (inForcedColors !== wantForcedColors) continue; + if (wantForcedColors) { + if (!inForcedColors) continue; + } else if (block.media !== "") { + continue; + } if (!matches(block.selectors)) continue; for (const [name, value] of declarations(block.body)) map.set(name, value); } @@ -147,12 +168,16 @@ function collect(blocks, matches, wantForcedColors) { /** * Both layers, per theme. An empty map for any side is a hard error rather than a * quiet "no divergence": an empty comparison would pass loudly-green. + * + * @typedef {{ compat: Map, v2: Map }} LayerPair + * @returns {Record} keyed by the theme names in `THEMES` */ export function readLayers() { const globalsBlocks = ruleBlocks(readFileSync(GLOBALS, "utf8")); const v2Blocks = ruleBlocks(readFileSync(V2, "utf8")); // Tailwind's `@theme` is an at-rule by syntax but declares tokens like `:root`. const themeBlock = readFileSync(GLOBALS, "utf8").match(/@theme\s*\{([\s\S]*?)\n\}/); + /** @type {Record} */ const out = {}; for (const [theme, spec] of Object.entries(THEMES)) { const compat = collect(globalsBlocks, spec.compat, spec.forcedColors); diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts index 2b8938568..f290e5b6a 100644 --- a/tests/design-token-contract.test.ts +++ b/tests/design-token-contract.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { computeDivergences, diffAgainstPin, readPin } from "../scripts/token-layer-divergences.mjs"; +import { computeDivergences, diffAgainstPin, readLayers, readPin } from "../scripts/token-layer-divergences.mjs"; import { sourceFrom, sourceSegment } from "./helpers/source-contract"; /** @@ -568,6 +568,27 @@ describe("compat layer agrees with the v2 layer", () => { expect(diffAgainstPin()).toEqual([]); }); + // A conditional `@media` override is a different comparison context from an + // unconditional declaration. An earlier parser filtered only on `forced-colors`, + // so any other media block was merged into the base map and its override silently + // replaced the base value — which reports "identical" for a pair that diverges + // everywhere the condition does not apply. globals.css has three such `:root` + // blocks, so this is checked against the real file rather than a fixture. + it("reads base-theme tokens from unconditional blocks, not from media overrides", () => { + const layers = readLayers(); + const base = /^\s*--mode-home-copy-reserve:\s*(.+);\s*$/m.exec(globals.slice(globals.indexOf("\n:root {"))); + expect(base, "--mode-home-copy-reserve should still be declared unconditionally").toBeTruthy(); + expect( + layers.light.compat.get("--mode-home-copy-reserve"), + "the (min-width: 412px) override must not replace the unconditional value", + ).toBe(base![1].replace(/\s+/g, " ").trim()); + + // Same shape, second instance: `@theme` declares 5.5rem and a + // (min-width: 640px) block overrides it to 10rem. The base map must hold the + // unconditional value, because that is the one comparable to a v2 declaration. + expect(layers.light.compat.get("--spacing-mode-home-composer-wide")).toBe("5.5rem"); + }); + it("rejects a pin whose counts metadata disagrees with divergences", () => { const pin = readPin(); const bad = structuredClone(pin); From 99f0d28394bdb0441cf24433ffea58ccf6d81130 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:16:15 +0800 Subject: [PATCH 39/50] fix(clinical): the prescribing panel calls itself decision support; close four holes in the new token-divergence gate (#2499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(clinical): align decision-support copy with the product boundary; pin design-system figures and token drift The app told users four times that it was clinical decision support while its own privacy page says the opposite. Two design-system documents had also drifted far enough from the code to mislead, and one silent trap had no gate at all. Clinical copy (the reason this change exists) - Differentials (3 sites) and Specifiers (1) said "Clinical decision support only" / "Use this as structured decision support". The privacy page and CLAUDE.md both scope this product as "a clinical reference prototype, not validated clinical decision support", and the PR preflight carries a device-classification item, so the clinical surfaces were the ones out of step. - All four now read "Clinical reference - not validated decision support", the wording already recorded as the clinical-owner copy decision on #PM9SP1. - The fifth site was the copied/exported differential text in src/lib/differential-detail.ts, which carried the same claim into anything a clinician pastes elsewhere. Its pinned assertion moved with it. - Therapy Compass was already correct and is untouched. New gate: token-layer divergence (scripts/token-layer-divergences.mjs) layout.tsx mounts .ckb-v2 on , so .ckb-v2.ckb-v2 (0,2,0) beats :root (0,1,0) and for any role both stylesheets declare the v2 value paints while the globals.css one is dead - editing it changes nothing, with no lint, type or screenshot signal. 53 roles are in that state today (29 light, 24 dark). The v2 migration is deliberate and unfinished, so divergence is pinned rather than banned: docs/design-system/token-layer-divergences.json is the reviewed set, and a role that starts diverging OR stops diverging fails. The comparison covers globals' @theme block as well as :root, which is where the structural roles live. Wired into the existing check:design-system-contract, so no gate-manifest change was needed. Mutation-verified both directions. New gate: GATES.md figures are generated (scripts/generate-gates-figures.mjs) GATES.md carried the same metric as three different hand-typed values in three sections. Measured against the baseline: interactiveTapFloorDeclarations appeared as 9, 32 and 2 (true value 2); rawMarginLiterals as 74 and 53 (34); legacyShadowAliases as 231, 224 and 87 (34). Nearly every figure overstated remaining debt, which costs a session in exactly the way the document's own section 5 warns about. A generated section 0 is now the single current count, with the prose figures in sections 2, 4 and 5 explicitly marked as the historical record. Comparison is normalised cell-wise so Prettier's table padding cannot report false drift. Mutation-verified both directions. Breakpoint tokens: reconciled toward the code, not away from it GATES.md 3b banned --breakpoint-* tokens; globals.css has five, added four days after that decision. The obvious fix - delete the three device-band names, which have zero product call sites - was attempted and reverted in the same session: MIN_WIDTH_BREAKPOINT_BANDS in design-system-contract-utils.mjs hard-codes them as same-threshold aliases of sm/md/lg, and they are the only fixtures exercising the tap-floor gate's alias-collision path. "Nothing imports it" was necessary and nowhere near sufficient, exactly as AGENTS.md warns. All five stay, with the real reason recorded in both the stylesheet and 3b, and the wrong "(Task #336)" attribution removed. AGENTS.md bundle-budget route list Documented five budgeted routes; the config has two. The config is right: /therapy-compass, /dsm and /forms became redirect stubs rendering the same shared home as /, so budgeting them measured / three more times (see tests/check-lighthouse-budget.test.ts, COMMITTED_ROUTES). Corrected the document and recorded why, so the next reader does not "fix" the config back. Also: indexed the one mockup route missing from mockups/README.md (answer-loading-redesign), taking that index to 79 of 79. Verification npm run verify:cheap exit 0. Full unit suite 934 files / 11,771 tests passed, 1 skipped, 0 failures. lint and typecheck clean. Clean-build bundle check passed (production 1742.1 KiB gzip, / at 240.0 KiB); it reports a +5.2% drift warning against a baseline commit that no longer resolves, which is pre-existing and tracked as #QSHHGK, not caused here. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015uQ7pGjA4hBxi9TRNsfjAL * fix(design-system): pin token-layer counts metadata against divergences Codex P2: reject hand-edited/merge-skewed counts when divergences are unchanged. * docs: refresh scripts-index inventory counts for two new design-system scripts * fix(clinical): correct two more over-claiming surfaces; close four holes in the new token-divergence gate Two independent audits ran against the previous commit. Both found real defects, and both are fixed here. Clinical over-claim sweep — two more surfaces The earlier fix found its sites by grepping one phrase. A sweep for the whole class found two it missed, both on the medication/prescribing surface, which is the highest-consequence screen in the app: - `patient-profile-panel.tsx` (rendered by the prescribing workspace, the medication record page, and the patient-details dock) said "Decision support, not medical advice" under the panel where a clinician enters renal/hepatic function, allergies and a current medication list to get a per-patient interaction verdict. This asserted the feature IS decision support — worse than the five sites already corrected, which merely said "not ... decision support". - `medication-record-page.tsx`'s standing footer claimed PsychSift "provides evidence summaries", an authority claim the product does not make anywhere else, and disclaimed with a generic "not medical advice" rather than the house governance phrasing every comparable mode uses. Both now match the wording used on the other surfaces. Production is clean: the only remaining "decision support" strings in src are the document-intent taxonomy (classifying UPLOADED guideline documents, not the app's own voice) and the privacy page's own boundary statement. Token-divergence gate — four holes, three of them live An adversarial review of the gate added in the previous commit demonstrated four false passes. The parser has been rewritten: 1. **Forced colours was unmonitored.** Both stylesheets declare a third theme in `@media (forced-colors: active)`, and the same specificity trap applies there. Editing a real Windows High Contrast value produced no complaint. Three roles are genuinely diverging in it today and are now pinned: `--clinical-accent-border`, `--overlay-backdrop`, `--text-soft`. 2. **Indentation-sensitive parsing.** The old regex required exactly two leading spaces, so re-indenting a declaration — no rendered effect — dropped it from the comparison, and the tool then reported the divergence as RESOLVED. Acting on that advice removed a still-dead token from tracking and left the gate green. Parsing is now indentation-insensitive. 3. **`var()` aliases were never resolved.** Two layers can declare identical alias text and still paint different colours when the alias itself diverges: dark `--clinical-chat-document` is `var(--surface-inset)` on both sides while `--surface-inset` differs. Values are now resolved within their own layer before comparison, which also removes three false FAILS where different text resolved to the same value (dark `--text`, `--text-heading`, `--clinical-accent-soft`). 4. **Ambiguous markers in GATES.md** silently retargeted both the comparison and the `--write` overwrite at the wrong slice. Now a hard error. Block extraction uses real brace matching instead of slicing to the next line-initial `}`, so an `@media` wrapper is context rather than a terminator, and comments are stripped before selectors are read. An empty result for any theme is a hard error, never a quiet pass. Counts move from light 29 / dark 24 to light 29 / dark 22 / forcedColors 3 — the dark drop is the three false fails above, the forced-colours three are newly visible real divergences. One review claim was checked and NOT adopted: the review reported forced-colours `--border`/`--border-strong`/`--border-lux` as diverging. globals.css declares them twice in that media context and the later value already matches v2, so they are not divergent; the manual diff had only read the first block. Verification Full unit suite 937 files / 11,796 tests passed, 1 skipped, 0 failures. lint, typecheck and the design-system contract chain all clean. Each of the four holes was re-attacked against the hardened code: the forced-colours edit and the duplicated marker now fail as they should, and the re-indented declaration is proven still tracked rather than silently dropped. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015uQ7pGjA4hBxi9TRNsfjAL * fix(design-system): conditional media blocks no longer leak into the base theme comparison Codex P2 on #2499, verified and confirmed. `collect()` filtered only on `forced-colors`, so every OTHER `@media` block was admitted into the unconditional theme map and its override replaced the base declaration. Comparing a narrow-viewport override against an unconditional v2 declaration compares two different contexts, and it reports "identical" for a pair that diverges everywhere the condition does not apply. Proven on real data, not argued: before this change the light compat map held `--mode-home-copy-reserve` as the `(min-width: 412px)` override (`calc(var(--text-hero) * …)`) rather than its unconditional declaration at globals.css:1240 (`calc(2 * var(--text-hero) * …)`). globals.css has three such `:root` blocks today — `--mode-home-copy-reserve` twice and `--spacing-mode-home-composer-wide` once. Reproduced as a false pass with the synthetic wide-viewport case the review asked for: base compat `--radius-md: 0.7rem`, an override at `(min-width: 9999px)` matching v2's `0.625rem`, v2 unconditionally `0.625rem`. The old predicate reported no divergence; the new one flags it. Latent rather than live today only because the v2 layer declares none of the three affected names. Base themes now take unconditional blocks only. Conditional non-forced-colors blocks are excluded rather than merged, which is the conservative direction: a token declared only under such a condition goes uncompared instead of being compared wrongly. Forced colours remains its own theme — it is a condition, and it is modelled as one rather than treated as base. Also fixes the return typing of `readLayers`, which inferred `{}` and broke `npm run typecheck` for the new test's consumers. Verification Full unit suite 937 files / 11,798 tests passed, 1 skipped, 0 failures. lint, typecheck and the design-system contract chain clean; pin unchanged at light 29 / dark 22 / forcedColors 3, confirming this was latent. The new regression test is mutation-verified: restoring the old predicate fails it, and it asserts against the real stylesheet rather than a fixture, so it tracks the file as it changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015uQ7pGjA4hBxi9TRNsfjAL --------- Co-authored-by: Claude --- .../token-layer-divergences.json | 31 ++- scripts/generate-gates-figures.mjs | 34 ++- scripts/token-layer-divergences.mjs | 224 +++++++++++++----- .../medication-record-page.tsx | 3 +- .../patient-profile-panel.tsx | 4 +- tests/design-token-contract.test.ts | 23 +- 6 files changed, 238 insertions(+), 81 deletions(-) diff --git a/docs/design-system/token-layer-divergences.json b/docs/design-system/token-layer-divergences.json index 121722221..e70f99154 100644 --- a/docs/design-system/token-layer-divergences.json +++ b/docs/design-system/token-layer-divergences.json @@ -3,7 +3,8 @@ "generatedBy": "scripts/token-layer-divergences.mjs", "counts": { "light": 29, - "dark": 24 + "dark": 22, + "forcedColors": 3 }, "divergences": { "light": { @@ -141,9 +142,9 @@ "compat": "#3b444b", "v2": "#47505a" }, - "--clinical-accent-soft": { - "compat": "var(--primary-soft)", - "v2": "#123556" + "--clinical-chat-document": { + "compat": "var(--surface-inset)", + "v2": "var(--surface-inset)" }, "--clinical-chat-table-header": { "compat": "var(--surface-subtle)", @@ -201,14 +202,6 @@ "compat": "#0a0c0e", "v2": "#161a1e" }, - "--text": { - "compat": "var(--neutral-900)", - "v2": "#f4f6f8" - }, - "--text-heading": { - "compat": "var(--neutral-950)", - "v2": "#fbfcfd" - }, "--text-muted": { "compat": "var(--neutral-600)", "v2": "#a8b2bd" @@ -221,6 +214,20 @@ "compat": "var(--neutral-500)", "v2": "#7d8792" } + }, + "forcedColors": { + "--clinical-accent-border": { + "compat": "ButtonBorder", + "v2": "CanvasText" + }, + "--overlay-backdrop": { + "compat": "CanvasText", + "v2": "transparent" + }, + "--text-soft": { + "compat": "CanvasText", + "v2": "GrayText" + } } } } diff --git a/scripts/generate-gates-figures.mjs b/scripts/generate-gates-figures.mjs index 7078d0fab..4795341a6 100644 --- a/scripts/generate-gates-figures.mjs +++ b/scripts/generate-gates-figures.mjs @@ -52,18 +52,36 @@ export function renderFigures(baseline) { return lines.join("\n"); } +/** + * Locate the single marked block, refusing anything ambiguous. `indexOf` alone takes + * the FIRST marker pair, so a duplicated or stray marker (a bad merge, a copy-pasted + * example) would silently retarget both the comparison and the `--write` overwrite at + * the wrong slice, reporting success while §0 stayed stale. Fail loudly instead. + */ +function blockBounds(document) { + const starts = [...document.matchAll(new RegExp(START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))]; + const ends = [...document.matchAll(new RegExp(END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))]; + if (starts.length !== 1 || ends.length !== 1) { + throw new Error( + `GATES.md must contain exactly one ${START} and one ${END} ` + + `(found ${starts.length} start, ${ends.length} end). Ambiguous markers would silently ` + + `retarget the generated block.`, + ); + } + const start = starts[0].index; + const end = ends[0].index; + if (end < start) throw new Error("GATES.md figures end marker precedes its start marker"); + return { start, end: end + END.length }; +} + function replaceBlock(document, rendered) { - const start = document.indexOf(START); - const end = document.indexOf(END); - if (start === -1 || end === -1) throw new Error(`GATES.md is missing the ${START} / ${END} markers`); - return document.slice(0, start) + rendered + document.slice(end + END.length); + const { start, end } = blockBounds(document); + return document.slice(0, start) + rendered + document.slice(end); } function currentBlock(document) { - const start = document.indexOf(START); - const end = document.indexOf(END); - if (start === -1 || end === -1) throw new Error(`GATES.md is missing the ${START} / ${END} markers`); - return document.slice(start, end + END.length); + const { start, end } = blockBounds(document); + return document.slice(start, end); } /** diff --git a/scripts/token-layer-divergences.mjs b/scripts/token-layer-divergences.mjs index 08faa7fca..6cd2e7148 100644 --- a/scripts/token-layer-divergences.mjs +++ b/scripts/token-layer-divergences.mjs @@ -21,79 +21,188 @@ const GLOBALS = fileURLToPath(new URL("../src/app/globals.css", import.meta.url) const V2 = fileURLToPath(new URL("../src/app/ckb-v2-tokens.css", import.meta.url)); export const PIN_PATH = fileURLToPath(new URL("../docs/design-system/token-layer-divergences.json", import.meta.url)); -/** Slice from `marker` to the first line-initial `}` that closes it. */ -function block(source, marker) { - const start = source.indexOf(marker); - if (start === -1) return null; - const end = source.indexOf("\n}", start); - if (end === -1) return null; - return source.slice(start, end); -} - -/** Every block for one selector, including grouped (`.sel,`) openers, concatenated. */ -function allBlocks(source, selector) { - const opener = `\n${selector} {`; - const grouped = `\n${selector},`; - let start = source.indexOf(opener); - if (start === -1) start = source.indexOf(grouped); - let combined = ""; - while (start > -1) { - const end = source.indexOf("\n}", start); - // A missing terminator would restart the scan at 0 and loop forever. - if (end === -1) break; - combined += source.slice(start, end); - const nextOpener = source.indexOf(opener, end + 1); - const nextGrouped = source.indexOf(grouped, end + 1); - if (nextOpener === -1) start = nextGrouped; - else if (nextGrouped === -1) start = nextOpener; - else start = Math.min(nextOpener, nextGrouped); +/** + * Every rule block in a stylesheet, as {media, selectors, body}, using real brace + * matching rather than "slice to the next line-initial `}`". Brace matching is what + * lets an `@media (forced-colors: active) { … }` wrapper be seen as context rather + * than terminating the block early. + */ +function ruleBlocks(rawSource) { + // Strip comments first: otherwise a comment preceding a selector is accumulated + // into that selector's prelude and the match fails. Replaced with a space rather + // than removed so `a/**/b` cannot become one token. + const source = rawSource.replace(/\/\*[\s\S]*?\*\//g, " "); + const blocks = []; + const stack = []; + let index = 0; + let pending = ""; + while (index < source.length) { + const character = source[index]; + if (character === "{") { + const prelude = pending.trim(); + pending = ""; + if (prelude.startsWith("@media") || prelude.startsWith("@supports")) { + stack.push({ kind: "at", prelude }); + index += 1; + continue; + } + // A rule block: capture its body by matching braces from here. + let depth = 1; + let cursor = index + 1; + while (cursor < source.length && depth > 0) { + if (source[cursor] === "{") depth += 1; + else if (source[cursor] === "}") depth -= 1; + cursor += 1; + } + blocks.push({ + media: stack + .filter((frame) => frame.kind === "at") + .map((frame) => frame.prelude) + .join(" "), + selectors: prelude + .split(",") + .map((selector) => selector.trim()) + .filter(Boolean), + body: source.slice(index + 1, cursor - 1), + }); + index = cursor; + continue; + } + if (character === "}") { + stack.pop(); + pending = ""; + index += 1; + continue; + } + if (character === ";") pending = ""; + else pending += character; + index += 1; } - return combined; + return blocks; } -function declarations(source) { +/** + * Custom-property declarations in a block body. Indentation-insensitive on purpose: + * an earlier version required exactly two leading spaces, so re-indenting a + * declaration — a change with no rendered effect — silently dropped it from the + * comparison and the tool then reported the divergence as resolved. + */ +function declarations(body) { const map = new Map(); - if (!source) return map; - for (const [, name, value] of source.matchAll(/^ {2}(--[a-z0-9-]+)\s*:\s*([^;]+);/gim)) { + if (!body) return map; + for (const [, name, value] of body.matchAll(/(?:^|;)\s*(--[a-zA-Z0-9-]+)\s*:\s*([^;]+)/g)) { map.set(name, value.trim().replace(/\s+/g, " ")); } return map; } /** - * Both layers, per theme. Missing a block is a hard error rather than an empty - * comparison: an empty map would report "no divergence" and pass loudly-green. + * Resolve `var(--x)` chains inside one layer. Two layers can declare the SAME alias + * text and still paint different colours when the alias itself diverges — dark + * `--clinical-chat-document` is `var(--surface-inset)` on both sides while + * `--surface-inset` differs, so a raw string comparison called it identical. */ -export function readLayers() { - const globals = readFileSync(GLOBALS, "utf8"); - const v2 = readFileSync(V2, "utf8"); - // `@theme` and `:root` both land at (0,1,0) on , so within globals.css the - // later block wins — `:root` follows `@theme`, so `:root` is overlaid second. - // `@theme` carries the structural roles (radius, spacing, type scale), which is - // exactly where a silent mismatch is most expensive, so it cannot be skipped. - const themeConfig = block(globals, "\n@theme {"); - const root = block(globals, "\n:root {"); - const darkRoot = block(globals, "\n.dark {"); - if (!themeConfig) throw new Error("globals.css is missing its @theme block"); - if (!root) throw new Error("globals.css is missing its :root block"); - if (!darkRoot) throw new Error("globals.css is missing its .dark block"); +function resolveValue(tokens, value, seen = new Set()) { + const alias = /^var\(\s*(--[a-zA-Z0-9-]+)\s*\)$/.exec(value ?? ""); + if (!alias) return value; + const name = alias[1]; + if (seen.has(name) || !tokens.has(name)) return value; + seen.add(name); + return resolveValue(tokens, tokens.get(name), seen); +} - const lightCompat = declarations(themeConfig); - for (const [name, value] of declarations(root)) lightCompat.set(name, value); +const THEMES = { + light: { + forcedColors: false, + compat: (selectors) => selectors.some((s) => s === ":root" || s === "@theme"), + v2: (selectors) => selectors.some((s) => s === ".ckb-v2.ckb-v2"), + }, + dark: { + forcedColors: false, + compat: (selectors) => selectors.some((s) => s === ".dark"), + v2: (selectors) => selectors.some((s) => s === ".dark .ckb-v2.ckb-v2" || s === ".ckb-v2.dark.ckb-v2"), + }, + // Forced colours (Windows High Contrast) is a third theme both files declare, and + // the same specificity trap applies there. It went unmonitored until 2026-09-01, + // and four roles were already silently dead in it. + forcedColors: { + forcedColors: true, + compat: (selectors) => selectors.some((s) => s === ":root" || s === ".dark"), + v2: (selectors) => + selectors.some((s) => s === ".ckb-v2.ckb-v2" || s === ".dark .ckb-v2.ckb-v2" || s === ".ckb-v2.dark.ckb-v2"), + }, +}; - const themes = { - light: [lightCompat, allBlocks(v2, ".ckb-v2.ckb-v2")], - dark: [declarations(darkRoot), allBlocks(v2, ".dark .ckb-v2.ckb-v2")], - }; +/** + * Declarations for one theme, from blocks whose CONDITION matches that theme. + * + * The base themes take unconditional blocks only. An earlier version tested just + * `/forced-colors/`, which let any OTHER `@media` block into the base map, where a + * later responsive override silently replaced the base value — `globals.css` has + * three such `:root` blocks today (`--mode-home-copy-reserve` twice, + * `--spacing-mode-home-composer-wide` once). Comparing a narrow-viewport override + * against an unconditional v2 declaration is comparing two different contexts, and + * it reports identical when they diverge everywhere the condition does not apply. + * + * Conditional non-forced-colors blocks are therefore excluded rather than merged. + * That is the conservative direction: a token declared ONLY under such a condition + * goes uncompared instead of being compared wrongly. `unconditionalOnly` is not a + * synonym for "no media" in the forced-colours case, which is itself a condition and + * is modelled as its own theme. + */ +function collect(blocks, matches, wantForcedColors) { + const map = new Map(); + for (const block of blocks) { + const inForcedColors = /forced-colors/.test(block.media); + if (wantForcedColors) { + if (!inForcedColors) continue; + } else if (block.media !== "") { + continue; + } + if (!matches(block.selectors)) continue; + for (const [name, value] of declarations(block.body)) map.set(name, value); + } + return map; +} + +/** + * Both layers, per theme. An empty map for any side is a hard error rather than a + * quiet "no divergence": an empty comparison would pass loudly-green. + * + * @typedef {{ compat: Map, v2: Map }} LayerPair + * @returns {Record} keyed by the theme names in `THEMES` + */ +export function readLayers() { + const globalsBlocks = ruleBlocks(readFileSync(GLOBALS, "utf8")); + const v2Blocks = ruleBlocks(readFileSync(V2, "utf8")); + // Tailwind's `@theme` is an at-rule by syntax but declares tokens like `:root`. + const themeBlock = readFileSync(GLOBALS, "utf8").match(/@theme\s*\{([\s\S]*?)\n\}/); + /** @type {Record} */ const out = {}; - for (const [theme, [compat, v2Source]] of Object.entries(themes)) { - if (!v2Source) throw new Error(`ckb-v2-tokens.css is missing its ${theme} token block`); - out[theme] = { compat, v2: declarations(v2Source) }; + for (const [theme, spec] of Object.entries(THEMES)) { + const compat = collect(globalsBlocks, spec.compat, spec.forcedColors); + if (theme === "light" && themeBlock) { + // `:root` follows `@theme` in source order, so `:root` overlays it. + const merged = declarations(themeBlock[1]); + for (const [name, value] of compat) merged.set(name, value); + out[theme] = { compat: merged, v2: collect(v2Blocks, spec.v2, spec.forcedColors) }; + } else { + out[theme] = { compat, v2: collect(v2Blocks, spec.v2, spec.forcedColors) }; + } + if (out[theme].compat.size === 0) + throw new Error(`globals.css declares no ${theme} tokens — parser or file changed`); + if (out[theme].v2.size === 0) + throw new Error(`ckb-v2-tokens.css declares no ${theme} tokens — parser or file changed`); } return out; } -/** `{ light: { "--surface": { compat, v2 } }, dark: {...} }` for every shared, differing role. */ +/** + * `{ : { "--surface": { compat, v2 } } }` for every role both layers declare + * whose RESOLVED value differs. Resolution matters in both directions: identical + * alias text over a diverging alias is a real divergence, and different text that + * resolves to the same value is not one. + */ export function computeDivergences() { const layers = readLayers(); const result = {}; @@ -102,12 +211,13 @@ export function computeDivergences() { for (const [name, compatValue] of compat) { if (!v2.has(name)) continue; const v2Value = v2.get(name); - if (v2Value !== compatValue) diverging[name] = { compat: compatValue, v2: v2Value }; + if (resolveValue(compat, compatValue) === resolveValue(v2, v2Value)) continue; + diverging[name] = { compat: compatValue, v2: v2Value }; } result[theme] = Object.fromEntries( Object.keys(diverging) .sort() - .map((k) => [k, diverging[k]]), + .map((key) => [key, diverging[key]]), ); } return result; diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index d58bcf663..92be6ab7e 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -495,7 +495,8 @@ export function MedicationRecordPage({ )} - PsychSift provides evidence summaries, not medical advice. Verify clinical decisions. + PsychSift is a clinical reference prototype, not validated decision support. Verify every dose and interaction + against the linked source before acting on it. diff --git a/src/components/clinical-dashboard/patient-profile-panel.tsx b/src/components/clinical-dashboard/patient-profile-panel.tsx index 41b77ff36..d88e4b871 100644 --- a/src/components/clinical-dashboard/patient-profile-panel.tsx +++ b/src/components/clinical-dashboard/patient-profile-panel.tsx @@ -367,8 +367,8 @@ export function PatientProfilePanel({

- Anonymous values only — no patient‑identifying information is stored. Cleared when the tab closes. Decision - support, not medical advice. + Anonymous values only — no patient‑identifying information is stored. Cleared when the tab closes. Clinical + reference — not validated decision support.

diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts index 2b8938568..f290e5b6a 100644 --- a/tests/design-token-contract.test.ts +++ b/tests/design-token-contract.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { computeDivergences, diffAgainstPin, readPin } from "../scripts/token-layer-divergences.mjs"; +import { computeDivergences, diffAgainstPin, readLayers, readPin } from "../scripts/token-layer-divergences.mjs"; import { sourceFrom, sourceSegment } from "./helpers/source-contract"; /** @@ -568,6 +568,27 @@ describe("compat layer agrees with the v2 layer", () => { expect(diffAgainstPin()).toEqual([]); }); + // A conditional `@media` override is a different comparison context from an + // unconditional declaration. An earlier parser filtered only on `forced-colors`, + // so any other media block was merged into the base map and its override silently + // replaced the base value — which reports "identical" for a pair that diverges + // everywhere the condition does not apply. globals.css has three such `:root` + // blocks, so this is checked against the real file rather than a fixture. + it("reads base-theme tokens from unconditional blocks, not from media overrides", () => { + const layers = readLayers(); + const base = /^\s*--mode-home-copy-reserve:\s*(.+);\s*$/m.exec(globals.slice(globals.indexOf("\n:root {"))); + expect(base, "--mode-home-copy-reserve should still be declared unconditionally").toBeTruthy(); + expect( + layers.light.compat.get("--mode-home-copy-reserve"), + "the (min-width: 412px) override must not replace the unconditional value", + ).toBe(base![1].replace(/\s+/g, " ").trim()); + + // Same shape, second instance: `@theme` declares 5.5rem and a + // (min-width: 640px) block overrides it to 10rem. The base map must hold the + // unconditional value, because that is the one comparable to a v2 declaration. + expect(layers.light.compat.get("--spacing-mode-home-composer-wide")).toBe("5.5rem"); + }); + it("rejects a pin whose counts metadata disagrees with divergences", () => { const pin = readPin(); const bad = structuredClone(pin); From a7b2ef9a871109838196ce54c6d7350db9f762d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 12:40:08 +0000 Subject: [PATCH 40/50] issues: close #PM9SP1, add the mode-home footer contradiction, record measured bundle drift on #QSHHGK - done #PM9SP1: the decision-support over-claim is fixed at seven sites across PRs #2497 and #2499, two of them (the prescribing patient-profile panel and the medication record footer) not named in the original row and worse than those that were. Production verified clean. - add: the row's second clause, 'mount the footer on the other mode homes', contradicts a recorded decision at mode-home-template.tsx:216-219. Filed for an owner ruling rather than carried silently into the close. - update #QSHHGK: clean-build measurement puts production at +5.2% (drift warning, over half way to the 10% ceiling), and the recorded baselineSource is unreachable even on a full 5415-commit clone, so the distance cannot be attributed to any reviewed change set. Requests only; docs/outstanding-issues.md is untouched and reconciliation runs on its own fresh-base branch. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015uQ7pGjA4hBxi9TRNsfjAL --- .../76e9f901-cb17-4ea1-bc83-fc9d20c2969a.json | 14 ++++++++++++++ .../c772a1b8-a7c7-4719-9885-2ab3651f3e0e.json | 11 +++++++++++ .../e204b4a7-8bd5-4180-8ca6-330922522cc5.json | 12 ++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 docs/outstanding-issues-inbox/76e9f901-cb17-4ea1-bc83-fc9d20c2969a.json create mode 100644 docs/outstanding-issues-inbox/c772a1b8-a7c7-4719-9885-2ab3651f3e0e.json create mode 100644 docs/outstanding-issues-inbox/e204b4a7-8bd5-4180-8ca6-330922522cc5.json diff --git a/docs/outstanding-issues-inbox/76e9f901-cb17-4ea1-bc83-fc9d20c2969a.json b/docs/outstanding-issues-inbox/76e9f901-cb17-4ea1-bc83-fc9d20c2969a.json new file mode 100644 index 000000000..0a0c6ea76 --- /dev/null +++ b/docs/outstanding-issues-inbox/76e9f901-cb17-4ea1-bc83-fc9d20c2969a.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "76e9f901-cb17-4ea1-bc83-fc9d20c2969a", + "createdOn": "2026-09-01", + "action": "add", + "payload": { + "pri": "P3", + "type": "issue", + "summary": "The scope-statement footer contradicts itself: an audit says mount it on every mode home, the code says it was deliberately removed from all of them", + "detail": "#PM9SP1's FIX text said to 'relabel to Clinical reference - not validated decision support and mount the footer on the other mode homes'. The relabel half landed (PRs #2497, #2499). The mount half conflicts with a recorded decision in the code: src/components/mode-home-template.tsx:216-219 states 'No mode home renders this any more: the line under the composer was removed from every home page. The sole remaining call site is the therapy-compass page footer, which sits at the bottom of the sub-routes and is explicitly not rendered on the therapy home (showFooter={!isHome} in workspace.tsx).' So one source says mount it everywhere and the other says it was deliberately taken off everywhere. NEXT ACTION: owner ruling on which is current, then make the other match. If the footer stays off mode homes, amend the #PM9SP1 fix text so a future session does not re-add it; if it should return, that is a deliberate reversal of the recorded decision and the comment at mode-home-template.tsx:216-219 must be updated in the same change. Not urgent: every surface that renders retrieved clinical content already carries its own scope line (verified by a repo-wide sweep 2026-09-01) - the open question is the shared mode-home composer footer only.", + "source": "Design-system + app review session 2026-09-01; conflict found while fixing #PM9SP1, verified against mode-home-template.tsx and therapy-compass/workspace.tsx", + "issueUlid": "01M1EFPB05MPZTBR8X1KFRQBT8" + } +} diff --git a/docs/outstanding-issues-inbox/c772a1b8-a7c7-4719-9885-2ab3651f3e0e.json b/docs/outstanding-issues-inbox/c772a1b8-a7c7-4719-9885-2ab3651f3e0e.json new file mode 100644 index 000000000..db188f9d9 --- /dev/null +++ b/docs/outstanding-issues-inbox/c772a1b8-a7c7-4719-9885-2ab3651f3e0e.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "c772a1b8-a7c7-4719-9885-2ab3651f3e0e", + "createdOn": "2026-09-01", + "action": "done", + "payload": { + "id": "#PM9SP1", + "outcome": "Resolved 2026-09-01 by PRs #2497 and #2499. The row named therapy-compass/workspace.tsx:18, screens/detail-screen.tsx:285 and recommend-screen.tsx:203; Therapy Compass had already been corrected before this session (workspace.tsx now renders 'Source-grounded therapy reference'), and a repo-wide sweep for the whole over-claim class - not just the literal phrase - found seven live sites, all now fixed: differential-presentation-workflow-page.tsx (x2), differential-detail-page.tsx, specifier-ui.tsx, lib/differential-detail.ts (the copied/exported summary text, which carried the claim outside the app), clinical-dashboard/patient-profile-panel.tsx and clinical-dashboard/medication-record-page.tsx. The last two were the worst and were NOT in this row: the patient-profile panel (rendered by the prescribing workspace, the medication record page and the patient-details dock) asserted the feature IS 'Decision support, not medical advice' on the surface that computes per-patient interaction verdicts, and the medication footer claimed PsychSift 'provides evidence summaries'. All now read the agreed wording 'Clinical reference - not validated decision support'. Production verified clean: the only remaining 'decision support' strings under src/ are the document-intent taxonomy (which classifies uploaded guideline documents, not the app's own voice) and the privacy page's own boundary statement. The row's second FIX clause - 'mount the footer on the other mode homes' - is NOT done and is deliberately not carried here: it contradicts a recorded design decision in mode-home-template.tsx:216-219 and is filed as its own row for an owner ruling.", + "baseRowFingerprint": "755b6dd3772b192d15dd0aef89149aba50ae3b105fa627c9313388d4dce0dc86" + } +} diff --git a/docs/outstanding-issues-inbox/e204b4a7-8bd5-4180-8ca6-330922522cc5.json b/docs/outstanding-issues-inbox/e204b4a7-8bd5-4180-8ca6-330922522cc5.json new file mode 100644 index 000000000..cca3f5926 --- /dev/null +++ b/docs/outstanding-issues-inbox/e204b4a7-8bd5-4180-8ca6-330922522cc5.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "id": "e204b4a7-8bd5-4180-8ca6-330922522cc5", + "createdOn": "2026-09-01", + "action": "update", + "payload": { + "id": "#QSHHGK", + "detail": "MEASURED 2026-09-01 on a clean build (rm -rf .next first, per the AGENTS.md measuring rule): production is 1742.1 KiB gzip across 145 routes against a 1656.0 KiB baseline = +5.2%, which trips the >5% drift warning and is over half way to the 10% failure ceiling. Mockups 645.1 KiB against 613.1 KiB baseline (138 routes, 165 exclusive chunks), within its 25% tolerance. Per-route: / at 240.0 KiB (30 chunks) and /documents/search at 243.1 KiB (32 chunks), both under their route baselines. The configured baselineSource 0764fb5813564cc1cb8933267597478ecff9c354 still does not resolve locally EVEN AFTER git fetch --deepen=2000 on a full (non-shallow, 5415-commit) clone, so this is not the shallow-clone case the 2026-08-23 note anticipated - the recorded commit is genuinely unreachable, which means the +5.2% distance cannot be attributed to any reviewed change set. This strengthens the KEEP OPEN: an unowned ceiling is now measurably close, and the provenance needed to judge whether the growth is legitimate is gone. NEXT ACTION unchanged (name a refresh owner and trigger), but the refresh now also needs a baselineSource that resolves.", + "source": "Design-system + app review session 2026-09-01; clean-build measurement on branch claude/design-system-app-review-sxd0mm", + "baseRowFingerprint": "8a1f97fe60fd78bda95fd37516b31753234ef50483c57726e5bad77997e28119" + } +} From 8893dd29a193b481d0a8dd71c3ba398707e54f34 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 12:42:11 +0000 Subject: [PATCH 41/50] issues: cancel the superseded #PM9SP1 close request Two pending done-mutations on #PM9SP1 made reconciliation ambiguous, which the inbox correctly refuses to guess at. 44e5f87c (queued 2026-08-27 by PR #2448) records only the Therapy Compass relabel; c772a1b8 records the same close plus the five further sites the 2026-09-01 sweep found, including the prescribing patient-profile panel and the medication record footer. Cancelling the narrower one so the archived outcome describes everything that was wrong rather than a subset. Nothing is lost - the surviving outcome states Therapy Compass had already been corrected. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015uQ7pGjA4hBxi9TRNsfjAL --- .../30ecf014-11cb-4f6a-8a7b-b66fb41e65ed.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/outstanding-issues-inbox/30ecf014-11cb-4f6a-8a7b-b66fb41e65ed.json diff --git a/docs/outstanding-issues-inbox/30ecf014-11cb-4f6a-8a7b-b66fb41e65ed.json b/docs/outstanding-issues-inbox/30ecf014-11cb-4f6a-8a7b-b66fb41e65ed.json new file mode 100644 index 000000000..eeebe0c82 --- /dev/null +++ b/docs/outstanding-issues-inbox/30ecf014-11cb-4f6a-8a7b-b66fb41e65ed.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "30ecf014-11cb-4f6a-8a7b-b66fb41e65ed", + "createdOn": "2026-09-01", + "action": "cancel", + "payload": { + "requestId": "44e5f87c-d30f-4334-9184-75c1544773ef", + "reason": "Superseded by c772a1b8-a7c7-4719-9885-2ab3651f3e0e, which closes the same row with a strictly larger outcome. This request was queued 2026-08-27 by PR #2448 and describes only the Therapy Compass relabel. A repo-wide sweep on 2026-09-01 found the same over-claim class live at five further sites, two of them worse than any in the original row: clinical-dashboard/patient-profile-panel.tsx asserted the feature IS 'Decision support, not medical advice' on the surface computing per-patient medication interaction verdicts, and clinical-dashboard/medication-record-page.tsx claimed PsychSift 'provides evidence summaries'. Also lib/differential-detail.ts carried the claim into copied/exported text. Landing this outcome instead of the superseding one would archive #PM9SP1 describing a subset of what was wrong, so a later reader would not learn the prescribing surface had ever over-claimed. No content is lost: the superseding outcome states that Therapy Compass was already corrected before that session." + } +} From ca04e2978f515f4bdc436d5136b5b2b5cf2cdb9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 12:53:12 +0000 Subject: [PATCH 42/50] issues: correct the #PM9SP1 close inventory (Codex P2 on #2503) The previous close outcome asserted that the only remaining 'decision support' strings under src/ were the document-intent taxonomy and the privacy page. That is literally false: the agreed replacement wording is 'not validated decision support', so all seven corrected sites still contain the phrase. A re-grep found three further categories the claim omitted - the document-intent filter placeholder, four negative guidance strings in calculator-fixtures.ts, and one in hub-panels.ts. Every occurrence is benign and the conclusion is unchanged: the app makes no positive decision-support claim in its own voice. But an archived row must not carry a false inventory, because a future auditor grepping the phrase will find hits and conclude the sweep was unreliable. The replacement enumerates all four categories and says to grep for positive claims rather than the bare phrase. Replaced rather than edited, per the inbox immutability rule. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015uQ7pGjA4hBxi9TRNsfjAL --- .../6db572bf-6a01-4900-b682-8136aaf8e4b7.json | 10 ++++++++++ .../d4ff3055-e544-43ff-8fb0-f94b19d46ae5.json | 11 +++++++++++ 2 files changed, 21 insertions(+) create mode 100644 docs/outstanding-issues-inbox/6db572bf-6a01-4900-b682-8136aaf8e4b7.json create mode 100644 docs/outstanding-issues-inbox/d4ff3055-e544-43ff-8fb0-f94b19d46ae5.json diff --git a/docs/outstanding-issues-inbox/6db572bf-6a01-4900-b682-8136aaf8e4b7.json b/docs/outstanding-issues-inbox/6db572bf-6a01-4900-b682-8136aaf8e4b7.json new file mode 100644 index 000000000..efda2c846 --- /dev/null +++ b/docs/outstanding-issues-inbox/6db572bf-6a01-4900-b682-8136aaf8e4b7.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "6db572bf-6a01-4900-b682-8136aaf8e4b7", + "createdOn": "2026-09-01", + "action": "cancel", + "payload": { + "requestId": "c772a1b8-a7c7-4719-9885-2ab3651f3e0e", + "reason": "Superseded by a corrected close request. Codex P2 on PR #2503 correctly caught that this outcome's inventory sentence is literally false: it claims the only remaining 'decision support' strings under src/ are the document-intent taxonomy and the privacy page, but the phrase still appears in all seven CORRECTED sites, because the agreed replacement wording is 'not validated decision support' and contains it. A re-grep confirms the claim was also incomplete in three further ways it did not mention: master-search-header.tsx:166 (a filter placeholder for the document-intent facet), four negative guidance strings in calculators/calculator-fixtures.ts, and hub-panels.ts:172. Every one of those is a benign negative caveat or a taxonomy value, so the CONCLUSION stands - the app makes no positive decision-support claim anywhere - but an archived row must not carry a false literal inventory, because a future auditor grepping the phrase will find hits and conclude the sweep was unreliable. Replaced rather than edited, per the inbox immutability rule." + } +} diff --git a/docs/outstanding-issues-inbox/d4ff3055-e544-43ff-8fb0-f94b19d46ae5.json b/docs/outstanding-issues-inbox/d4ff3055-e544-43ff-8fb0-f94b19d46ae5.json new file mode 100644 index 000000000..12b932b41 --- /dev/null +++ b/docs/outstanding-issues-inbox/d4ff3055-e544-43ff-8fb0-f94b19d46ae5.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "d4ff3055-e544-43ff-8fb0-f94b19d46ae5", + "createdOn": "2026-09-01", + "action": "done", + "payload": { + "id": "#PM9SP1", + "outcome": "Resolved 2026-09-01 by PRs #2497 and #2499. The row named three Therapy Compass sites; Therapy Compass had already been corrected before that session (workspace.tsx renders 'Source-grounded therapy reference'). A sweep for the whole over-claim class rather than the literal phrase found seven live sites, all now reading the agreed wording 'Clinical reference - not validated decision support': differential-presentation-workflow-page.tsx (x2), differential-detail-page.tsx, specifier-ui.tsx, lib/differential-detail.ts (the copied/exported summary, which carried the claim outside the app), clinical-dashboard/patient-profile-panel.tsx and clinical-dashboard/medication-record-page.tsx. The last two were not in the original row and were worse than any that were: the patient-profile panel (rendered by the prescribing workspace, the medication record page and the patient-details dock) asserted the feature IS 'Decision support, not medical advice' on the surface computing per-patient interaction verdicts, and the medication footer claimed PsychSift 'provides evidence summaries'. WHAT WAS VERIFIED, stated precisely: the app makes no POSITIVE decision-support claim in its own voice anywhere in production. The literal phrase does still occur under src/, and every occurrence is benign - (a) the seven corrected sites, where it appears inside the negative caveat 'not validated decision support'; (b) taxonomy and label values classifying UPLOADED guideline documents, not the app: document-tags.ts:212-213, document-label-governance.ts:143, and the document-intent filter placeholder at master-search-header.tsx:166; (c) further negative caveats: four score-band and release-status strings in calculators/calculator-fixtures.ts ('not available for score-based decision support', 'Not released for active clinical decision support pending governance review') and developer-area/hub-panels.ts:172 ('Synthetic prototype, not clinical decision support'); (d) the privacy page's own boundary statement at privacy-page-content.tsx:72. A future audit should grep for POSITIVE claims, not the bare phrase. NOT DONE, filed separately: the row's second fix clause, 'mount the footer on the other mode homes', contradicts a recorded decision at mode-home-template.tsx:216-219 and needs an owner ruling.", + "baseRowFingerprint": "755b6dd3772b192d15dd0aef89149aba50ae3b105fa627c9313388d4dce0dc86" + } +} From 510730e848549cc5280387724c2079674a37fe91 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:58:49 +0800 Subject: [PATCH 43/50] Answer page: count each safety passage once, and put disclosures where they were tapped (#2500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui/answer): one cited count, and a follow-up that is a sentence Two defects found by opening the answer page at 390px on the running app. **The cited count was printed twice.** "2 cited" sat beside the support chip and again on the source rail's heading 160px below, both visible in one glance on a phone. The rail's is the better of the two: it reads "2 cited · 1 also found" and is the only thing that explains why an uncited card carries a dash instead of a number. The status-line copy is gone, and with it `AnswerCard`'s `metaTrailing` prop, which had no other consumer. **A gap follow-up could not be a sentence.** `gapFollowUpTemplates` built a question by wrapping a reported gap in `What does the source say about ?`. Every message `detectConflictsOrGaps` writes is a full advisory sentence, so that construction cannot produce English; the live page showed "What does the source say about current evidence comes from one document; broaden document scope if you need cross-document comparison?", and the threshold-conflict message, being two sentences, came out worse still. A gap is now offered verbatim only when it is already a question. Nothing is lost, but only because of the second half of this change. The authored `source_gap` template asks the same thing properly — "What does the indexed guidance not cover for ?" — and it lived in the `management` menu alone, so on a `dosing`, `escalation`, `threshold` or `comparison` query the gap would simply have gone unmentioned. It is now offered from the builder for every menu key, and still first, which is where reported gaps have always ranked. That also repairs the `source_gap` suppression: it tested `suggestions.length > 0`, which meant "a gap already asked for itself" only while gaps ran first and alone. The test that pinned the old wording pinned the bug — it asserted "What does the source say about paediatric dosing is not covered?" — so it now asserts the property instead: no suggestion carries sentence punctuation before its own question mark. * fix(ui/answer): count each safety passage once, and put disclosures where they were tapped Four defects found by working the answer page at 390px on the running app. **A safety passage could be counted twice.** `extractSafetyFindings` draws candidates from `quoteCards` AND `sources`, and a quote card is an extract of its own parent chunk — same document, same page, its text a substring of the chunk's. The dedupe key was the text itself, so two lengths of one passage were two different keys and both survived; they could also carry different labels, because `safetyPatterns.find` returns the first pattern a text matches and the longer text reaches severities the extract does not. The live clozapine answer read "3 safety notes" over two passages, the first two of them the same words under "Red flag" and "Monitoring". It now reads 2. `collapseDuplicateSafetyFindings` keeps the fuller text and the most severe label of the group, and requires 40 characters of overlap before containment counts as one passage, so a stray fragment cannot swallow a finding it merely appears inside. It runs on both paths into the module — fresh extraction and an answer that arrives with `safetyWarnings` already computed — so a future producer of those warnings cannot reintroduce the double count. The gather loop also collects past the display limit before collapsing, so a duplicate can no longer crowd out a distinct finding. **The safety sheet buried the finding under its own chrome.** The row led with a kind pill, the source link and a governance pill, which at 390px wrapped to three lines and put ~110px above the first word. The row now reads kind, then finding, then source: the kind is an eyebrow beside its icon rather than a pill saying what the icon already said, the governance chip sits on that same line, and the text starts where the reader looks. **Report a problem opened behind the composer.** Measured at 390x844, the panel's last two options sat under the fixed composer and nothing moved, so the list read as ending at "Outdated". It now scrolls itself clear on open — centred when it fits, top-anchored when it does not — through `resolveScrollBehavior`, so the in-app reduce-motion setting still suppresses the animation. **Evidence gaps opened ~450px from the chip that opened them.** The panel rendered after the whole card, prose and source rail included, which on a phone is off-screen: tapping the chip looked like nothing happening. It now renders directly beneath the chips, through a new `metaDetail` slot on `AnswerCard`, and the chip shows an open state instead of looking identical either way. The panel is also mounted while collapsed, so `aria-controls` points at an id that always resolves rather than being conditionally dropped to work around the same problem. * revert(ui/answer): drop the report-a-problem auto-scroll It broke the phone chrome. Scrolling the panel into view is necessarily a downward scroll, a downward scroll is what hides the phone composer, and closing the panel then shrinks the page back to the top without producing the upward travel `useScrollHideReporter` needs to reveal it again — so the composer stays gone at the top of the page. `ui-smoke`'s critical answer journey failed on exactly that (`data-scroll-hidden="true"` on `form.answer-footer-search-dock` several steps later), and disabling only this effect made it pass, so the attribution is measured rather than assumed. `block: "nearest"` plus a scroll margin did not help: the panel is genuinely obscured when it opens, so the scroll still happens. What the panel actually needs is not to move the page at all. Its options were never unreachable — measured at 390x844 the last one clears the composer by 180px at full scroll — the fault is that the list LOOKS complete where the composer cuts it. That wants the sheet treatment the safety findings already use, which sidesteps the fixed chrome entirely, and that is a design change to put to the owner rather than to slip in behind a revert. Comment left at the site so the next author does not re-derive the trap. The other three fixes in this branch are untouched. * fix(answer): tighten the safety collapse and stop the gap chip crowding the menu Six defects found by three specialist reviewers reading this branch before it was pushed. All six are in code added earlier on this branch. **The safety collapse could disagree with itself.** One pass merges into the FIRST passage-key match, so a finding containing two already-kept ones landed on the first and left the second nested inside it. This function runs twice on the same data — server-side into the payload, then again on the client — so a pass short of a fixed point renders "2 safety notes" before hydration and "1" after. It now iterates to a fixed point, bounded by the input length since every effective pass removes at least one item. **A short quote still double-counted against its own parent chunk.** The 40-character floor was applied to every containment, but a quote card carries its parent chunk's `chunk_id`, so containment there is proof of one passage however short the extract. The floor now applies only across chunks, which is the case it was written for; same-chunk containment merges outright. This was the original defect escaping through its own fix. **A merged finding could contradict its own id.** The id encodes the kind, and a merge that takes one finding's text and another's severity kept a `monitoring:` id on a row labelled "Red flag". Rebuilt on merge. **The gap follow-up could restate a section the answer already emitted.** `source_gap` is a real emitted section kind — `rag.ts` maps gap, missing and unclear headings onto it — and offering the question outside the menu loop skipped that loop's `emittedSectionKinds` guard. The guard now applies to the direct offer too. **The gap follow-up displaced a concrete clinical question.** Offered first, on every menu, it took a slot from the four-chip cap: traced on a gapped `medication_dose_risk` answer it dropped "How is lithium dosed in renal or hepatic impairment?" for a meta-question about coverage. It is now offered last and only into a spare slot. Nothing is hidden by that — the gap's own words already render verbatim as a caveat — and the cheaper thing to lose is the meta-question. **Two layout faults.** A citation carrying two governance labels gave each its own `ms-auto`, which splits the free space between them so neither ends up flush right; they now share one. And the evidence-gaps disclosure rendered above the retrieval banner, pushing a caution that names which sources are stale and how much was missed ~200px down at 390px when opened; the disclosure the reader asked for now sits below the warning they did not. Tests: four added (same-chunk collapse under the floor, fixed-point convergence, the emitted-section suppression, and an unsupported topic), and the cap assertions restored to exact ordering — the previous `toBeLessThanOrEqual(4)` passed with a single suggestion and pinned nothing. * fix(answer): do not call a conflict a coverage gap `hasReportedGap` is true for `type: "conflict"` entries as well as gaps, and the authored gap question says the wrong thing about a conflict. When `detectConflictsOrGaps` reports that sources disagree on a withholding threshold, that answer HAS coverage — from several sources — and the problem is that they contradict each other. Offering "What does the indexed guidance not cover for X?" there misstates the evidence and points the clinician at the wrong follow-up. The flaw predates this branch: `source_gap`'s evidence gate has always been `reported_gap`, which is the same conflated flag. What this branch did was widen its reach from one menu to every menu, which turns a rare mis-wording into a routine one — so it is fixed here rather than deferred. The gap question now requires at least one `type: "gap"` entry. `hasReportedGap` keeps its original meaning for the menu template's own evidence gate, which is unchanged. Two tests: a conflict-only answer is never offered the chip, and an answer carrying both still is. Codex review finding on #2500, verified against `evidence.ts` before acting. --------- Co-authored-by: Claude --- .design-sync/config.json | 2 +- .../answer-result-surface.tsx | 80 +++++--- .../clinical-dashboard/evidence-panels.tsx | 122 +++++++----- src/components/ui/answer-card.tsx | 22 ++- src/lib/answer-follow-up.ts | 84 +++++++- src/lib/clinical-safety.ts | 114 ++++++++++- tests/answer-follow-up.test.ts | 177 ++++++++++++++++- tests/clinical-safety.test.ts | 182 +++++++++++++++++- tests/ui-smoke.spec.ts | 1 - 9 files changed, 683 insertions(+), 101 deletions(-) diff --git a/.design-sync/config.json b/.design-sync/config.json index 0638e5419..6a905c997 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -20,7 +20,7 @@ ], "dtsPropsFor": { "AccessibleTable": "actionsHeader?: string; caption: string; clinicalOnly?: boolean; columnAlign?: AccessibleTableColumnAlign[]; columns?: string[] | null; compact?: boolean; densePreview?: boolean; dialogTitle?: string | null; expandOnMobile?: boolean; hidePreviewCaption?: boolean; hidePreviewRowCount?: boolean; lowConfidenceFallback?: ReactNode; markdown?: string | null; normalizedTable?: NormalizedAccessibleTable | null; numericColumns?: number[]; previewRows?: number; rowActions?: ReactNode[]; rows?: string[][] | null;", - "AnswerCard": "actions?: AnswerCardAction[]; children: ReactNode; className?: string; frame?: \"raised\" | \"bare\"; metaChips?: ReactNode; metaTrailing?: ReactNode; onOpenSource?: ((sourceId: string, locator?: string) => void) | ((sourceId: string, locator?: string) => void); provenance?: AnswerFooterProps; query?: string; retrievalStatePlacement?: \"header\" | \"content\"; state: { kind: \"ready\"; sourceCount: number; } | DegradedAnswerState; support: AnswerSupportStrength; verification: VerificationNoticeProps; verificationPlacement?: \"header\" | \"content\";", + "AnswerCard": "actions?: AnswerCardAction[]; children: ReactNode; className?: string; frame?: \"raised\" | \"bare\"; metaChips?: ReactNode; metaDetail?: ReactNode; onOpenSource?: ((sourceId: string, locator?: string) => void) | ((sourceId: string, locator?: string) => void); provenance?: AnswerFooterProps; query?: string; retrievalStatePlacement?: \"header\" | \"content\"; state: { kind: \"ready\"; sourceCount: number; } | DegradedAnswerState; support: AnswerSupportStrength; verification: VerificationNoticeProps; verificationPlacement?: \"header\" | \"content\";", "AnswerFooter": "className?: string; generatedAt?: string | null; metadata?: SourceMetadataInput; provenance?: string | SourceMetadataInput; publisher?: string | null; reviewDate?: string | null; version?: string | null;", "AsyncButton": "about?: string; accessKey?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoCorrect?: string; autoFocus?: boolean; autoSave?: string; busy: boolean; busyLabel: string; children: ReactNode; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; disabled?: boolean; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; form?: string; formAction?: string | ((formData: FormData) => void | Promise); formEncType?: string; formMethod?: string; formNoValidate?: boolean; formTarget?: string; hidden?: boolean; id?: string; idleIcon?: ReactNode; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; lang?: string; name?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; rel?: string; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; title?: string; translate?: \"yes\" | \"no\"; type?: \"submit\" | \"reset\" | \"button\"; typeof?: string; unselectable?: \"off\" | \"on\"; value?: string | number | readonly string[]; vocab?: string;", "Breadcrumb": "className?: string; items: Crumb[];", diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 5795f5f8a..06e1dee96 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { memo, useCallback, useMemo, useRef, useState } from "react"; -import { CircleAlert, ShieldAlert, TriangleAlert } from "lucide-react"; +import { ChevronDown, CircleAlert, ShieldAlert, TriangleAlert } from "lucide-react"; import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; @@ -206,7 +206,14 @@ function StagedAnswerResultSurfaceImpl({ }; /** * The header status line the approved specimen draws: the support chip (owned - * by AnswerCard), the safety-notes control, and the cited count. + * by AnswerCard) and the safety-notes control. + * + * The cited count is deliberately NOT here. It was, and at 390px it rendered + * "2 cited" twice within one screen — once beside the support chip and again + * on the source rail's own heading 160px below, which already reads + * "2 cited · 1 also found" and is the only place that explains why an uncited + * card carries a dash instead of a number. Two spellings of one number in one + * glance invite the reader to look for a difference between them. * * The safety chip is the ONLY route to the safety-critical findings sheet now * that the support card is gone, so it is a real button whenever there are @@ -214,8 +221,6 @@ function StagedAnswerResultSurfaceImpl({ * safety notes" survives forced-colors and greyscale print, where a coloured * chip alone would not. */ - const citedSourceCount = railSources.filter((row) => row.cited !== false).length || renderModel.primarySources.length; - const retrievedSourceCount = Math.max(citedSourceCount, sourceCount, sources.length); const answerMetaChips = safetyFindings.length > 0 ? ( ) : ( answerMetaChips ); - const answerMetaTrailing = - citedSourceCount > 0 ? ( - - {citedSourceCount === retrievedSourceCount - ? `${citedSourceCount} cited` - : `${citedSourceCount} of ${retrievedSourceCount} cited`} - + + /** + * The gaps themselves, mounted whether or not the chip is expanded so + * `aria-controls` above always resolves, and rendered by `AnswerCard` + * immediately under the chip row rather than below the whole answer. + */ + const answerEvidenceGapsDetail = + renderModel.warnings.length > 0 ? ( + ) : null; function openAnswerStateSource(sourceId: string, locator?: string) { @@ -360,7 +397,7 @@ function StagedAnswerResultSurfaceImpl({ retrievalStatePlacement="content" verificationPlacement="content" metaChips={answerMetaChipsWithGaps} - metaTrailing={answerMetaTrailing} + metaDetail={answerEvidenceGapsDetail} > {answerProse} @@ -373,7 +410,7 @@ function StagedAnswerResultSurfaceImpl({ retrievalStatePlacement={answerState.kind === "stale_evidence" ? "content" : "header"} verificationPlacement="content" metaChips={answerMetaChipsWithGaps} - metaTrailing={answerMetaTrailing} + metaDetail={answerEvidenceGapsDetail} // Navigate to the cited page — do not reuse onScopeDocument. That // handler only replaces selectedDocumentIds and leaves the clinician // on the answer screen with a silent filter change while the button @@ -384,19 +421,6 @@ function StagedAnswerResultSurfaceImpl({ )} - {renderModel.warnings.length > 0 && evidenceGapsOpen ? ( -
- {renderModel.warnings.map((warning, index) => ( -

- {warning} -

- ))} -
- ) : null} - ) : null} + {/* This panel deliberately does NOT scroll itself into view when it + opens, though it can open partly behind the fixed phone composer. + Every scripted scroll that would clear it is a downward scroll, and a + downward scroll is what hides the phone chrome; closing the panel then + shrinks the page back to the top without generating the upward travel + that reveals the chrome again, so the composer stays gone at the top + of the page. `ui-smoke`'s critical answer journey caught exactly that. + The options below the fold are reachable by scrolling — measured at + 390x844, the last one clears the composer by 180px at full scroll — + so the remaining problem is that the list LOOKS complete, which wants + a sheet rather than a page scroll. Tracked, not bodged. */} {onSubmitFeedback && feedbackOpen ? (
@@ -953,15 +964,13 @@ export function ClinicalNotesChecklistPanel({ ); } -function safetyFindingKindTone(kind: SafetyFindingKind) { - return kind === "contraindication" || kind === "red_flag" ? toneDanger : toneWarning; -} - function SafetyFindingRowIcon({ kind }: { kind: SafetyFindingKind }) { + // Sized to the eyebrow beside it rather than to the old icon cell: at h-5 the + // glyph outweighed the label it now sits next to. if (kind === "contraindication" || kind === "red_flag") { - return
); } diff --git a/src/components/ui/answer-card.tsx b/src/components/ui/answer-card.tsx index 8aba191f2..cb9894644 100644 --- a/src/components/ui/answer-card.tsx +++ b/src/components/ui/answer-card.tsx @@ -127,8 +127,16 @@ type AnswerCardBase = { * neighbours; `ui-smoke` measures the chip rectangles for exactly that. */ metaChips?: ReactNode; - /** Trailing meta, right-aligned on the header line — the surface's cited count. */ - metaTrailing?: ReactNode; + /** + * A disclosure the chips open, rendered directly beneath them. + * + * It belongs here rather than under the answer because a disclosure has to + * appear where it was tapped. The evidence-gaps panel used to render after the + * whole card — prose, marks and source rail included — which at 390px put it + * ~450px below the chip that opened it, far enough off-screen that tapping the + * chip read as doing nothing at all. + */ + metaDetail?: ReactNode; className?: string; }; @@ -156,7 +164,7 @@ export function AnswerCard({ retrievalStatePlacement = "header", verificationPlacement = "header", metaChips, - metaTrailing, + metaDetail, className, }: AnswerCardProps) { const bare = frame === "bare"; @@ -230,7 +238,6 @@ export function AnswerCard({ Evidence support: {ANSWER_SUPPORT_WORDING[support]}

- {bare && metaTrailing ? {metaTrailing} : null} {/* The interactive chips take a full-width row of their own rather than sharing the baseline-aligned status line. They carry a real 48px tap target, and a 48px control inside a 24px line can only be bought with @@ -270,6 +277,13 @@ export function AnswerCard({ /> ) : null} + {/* After the retrieval banner, not before it. `stale_evidence` and + `partial_retrieval` banners name WHICH sources are overdue and HOW + MUCH was missed, and a governed caution that specific should not be + pushed down the page by an expanded disclosure — three open gaps + move it roughly 200px at 390px. The disclosure is the reader's own + request; the banner is the one they did not ask for and most need. */} + {bare && metaDetail ?
{metaDetail}
: null}
`What does the indexed guidance not cover for ${topic}?`; + const menuFollowUpTemplates: Record, readonly FollowUpTemplate[]> = { dosing: [ { @@ -232,7 +244,7 @@ const menuFollowUpTemplates: Record, }, { kind: "source_gap", - question: (topic) => `What does the indexed guidance not cover for ${topic}?`, + question: reportedGapQuestion, // Only offered when the answer itself reported a gap or conflict; the // haystack check below is satisfied by that report, not by source prose. evidenceTerms: ["reported_gap"], @@ -430,16 +442,28 @@ function templatesForMenuKey(menuKey: RelatedInformationMenuKey, answer: RagAnsw return generalTemplates; } +/** + * Follow-up questions taken verbatim from the answer's own reported gaps. + * + * Only a gap that is ALREADY a question is offered. This used to wrap any gap + * in `What does the source say about ?`, which cannot + * produce English: every message `detectConflictsOrGaps` writes is a full + * advisory sentence, not a noun phrase. On the live answer page that rendered + * as "What does the source say about current evidence comes from one document; + * broaden document scope if you need cross-document comparison?" — and the + * threshold-conflict message, being two sentences, came out worse still, with a + * lowercased "confirm the correct cut-off..." stranded mid-question. + * + * Nothing is lost by declining: the `source_gap` template below is authored for + * exactly this case, is gated on the same `reported_gap` evidence, and asks the + * question in words a clinician can read. + */ function gapFollowUpTemplates(answer: RagAnswer) { const gaps = answer.conflictsOrGaps ?? answer.smartPanel?.conflictsOrGaps ?? []; return gaps .map((gap) => gap.message.trim()) - .filter(Boolean) - .slice(0, 2) - .map((message) => { - const cleaned = message.replace(/\.$/, ""); - return cleaned.endsWith("?") ? cleaned : `What does the source say about ${cleaned.toLowerCase()}?`; - }); + .filter((message) => message.endsWith("?")) + .slice(0, 2); } /** @@ -483,7 +507,23 @@ export function buildAnswerFollowUpSuggestions( if (suggestions.length >= maxFollowUpSuggestions) break; push(gap); } - const hasReportedGap = (answer.conflictsOrGaps ?? answer.smartPanel?.conflictsOrGaps ?? []).length > 0; + // Whether a gap supplied its own question, which is what the `source_gap` + // suppression below actually means. Reading it off `suggestions.length` was + // safe only while gaps were the sole thing that could have run by then; now + // that a gap has to already be a question to qualify, that test would suppress + // the authored `source_gap` item whenever any earlier menu template matched. + const gapAskedItself = suggestions.length > 0; + const reportedGapsOrConflicts = answer.conflictsOrGaps ?? answer.smartPanel?.conflictsOrGaps ?? []; + const hasReportedGap = reportedGapsOrConflicts.length > 0; + /** + * A conflict is not a gap, and the authored gap question says the wrong thing + * about one. `detectConflictsOrGaps` writes `type: "conflict"` when sources + * disagree on a withholding threshold — that answer HAS coverage, from several + * sources; the problem is that they contradict each other. Offering "What does + * the indexed guidance not cover for X?" there misstates the evidence and + * points the clinician at the wrong follow-up. + */ + const hasMissingCoverage = reportedGapsOrConflicts.some((item) => item.type === "gap"); const answerText = (answer.answer ?? "").toLowerCase(); const emittedSectionKinds = new Set( (answer.answerSections ?? []).map((section) => section.kind).filter((kind): kind is AnswerSectionKind => !!kind), @@ -503,10 +543,36 @@ export function buildAnswerFollowUpSuggestions( // answer body's own words, or — for the source-gap item — a gap chip that is // already asking the specific question. if (emittedSectionKinds.has(template.kind)) continue; - if (template.kind === "source_gap" && suggestions.length > 0) continue; + if (template.kind === "source_gap" && gapAskedItself) continue; if (template.answeredTerms.some((term) => answerText.includes(term))) continue; push(template.question(topic)); } + /** + * The authored gap question, for the menus that carry no `source_gap` item of + * their own — only `management` does, so before this a reported gap on a + * dosing, escalation, threshold or comparison query went unmentioned. + * + * Offered LAST, and only into a spare slot. A question about what the evidence + * does not cover is worth less than a concrete evidence-backed one when the + * four slots are contested: put it first and a gapped `medication_dose_risk` + * answer trades "How is lithium dosed in renal or hepatic impairment?" for a + * meta-question. It also costs the reader little to lose, because the gap's + * own words are already on screen as a caveat (`answer-render-policy.ts`). + * + * The `emittedSectionKinds` check is the menu loop's rule 3 applied here: + * `source_gap` is a real emitted section kind (`rag.ts` maps gap/unsupported + * headings onto it), so without this the chip could ask what the guidance does + * not cover directly beneath a Source gap section that just said. + */ + if ( + hasMissingCoverage && + !gapAskedItself && + !emittedSectionKinds.has("source_gap") && + suggestions.length < maxFollowUpSuggestions + ) { + push(reportedGapQuestion(topic)); + } + return suggestions; } diff --git a/src/lib/clinical-safety.ts b/src/lib/clinical-safety.ts index 25cb8ec5d..364fbeda3 100644 --- a/src/lib/clinical-safety.ts +++ b/src/lib/clinical-safety.ts @@ -49,6 +49,12 @@ const safetyPatterns: Array<{ kind: SafetyFindingKind; label: string; pattern: R }, ]; +/** + * How much of a passage two findings must share before containment is treated as + * "the same passage". Below this, a short fragment is a substring of too much. + */ +const minPassageOverlap = 40; + function normalizeText(text: string) { return text.replace(/\s+/g, " ").trim(); } @@ -85,8 +91,107 @@ function hasQueryConceptOverlap(text: string, terms: string[]) { return terms.some((term) => haystack.includes(term.toLowerCase())); } +/** + * Collapse findings that are the same passage counted twice. + * + * The candidate list below draws from `quoteCards` AND `sources`, and a quote + * card is an extract of its own parent chunk — same document, same page, its + * text a substring of the chunk's. Both used to survive, because the dedupe key + * was the text itself and two different lengths of one passage are two different + * strings. They could also carry different labels: `safetyPatterns.find` returns + * the first pattern the text matches, and the longer text reaches severities the + * extract does not. On the live clozapine answer that rendered as "3 safety + * notes" over two passages, the first two of them the same words under "Red + * flag" and "Monitoring". + * + * A count is the whole point of this surface, so an inflated one is not cosmetic. + * Same document, same page, one text containing the other: keep the fuller text, + * and keep the most severe label of the group — a passage that names both an + * urgent trigger and a monitoring step is a red flag that also mentions + * monitoring, not two findings. + * + * Applied to every path into this module, including an answer that arrives with + * `safetyWarnings` already computed, so a future producer of those warnings + * cannot reintroduce the double count. + */ +export function collapseDuplicateSafetyFindings(findings: SafetyFinding[]): SafetyFinding[] { + // A single pass is order-greedy: it merges into the FIRST passage-key match, + // so a finding that contains two already-kept ones lands on the first and + // leaves the second nested inside it. That matters because this runs twice on + // the same data — server-side into the payload, then again on the client — and + // a pass that has not reached a fixed point can return a different count each + // time, so the chip reads "2 safety notes" before hydration and "1" after. + // Every iteration that changes anything removes at least one finding, so the + // input length bounds the loop. + let current = findings; + for (let pass = 0; pass < findings.length; pass += 1) { + const next = collapseSafetyFindingsOnce(current); + if (next.length === current.length) return next; + current = next; + } + return current; +} + +function collapseSafetyFindingsOnce(findings: SafetyFinding[]): SafetyFinding[] { + const kept: SafetyFinding[] = []; + const normalized = new Map(); + const passageKey = (finding: SafetyFinding) => + `${finding.citation.document_id}:${finding.citation.page_number ?? "?"}`; + + for (const finding of findings) { + const text = normalizeText(finding.text).toLowerCase(); + normalized.set(finding, text); + const duplicateIndex = kept.findIndex((candidate) => { + const other = normalized.get(candidate) ?? ""; + if (other === text && passageKey(candidate) === passageKey(finding)) return true; + const contains = other.includes(text) || text.includes(other); + if (!contains) return false; + // Same chunk is not a heuristic: a quote card and the source it was cut + // from carry the same `chunk_id`, so containment there is proof of one + // passage however short the extract. The length floor below exists only + // for the cross-chunk case, and applying it here would let a quote under + // 40 characters double-count against its own parent — the exact defect + // this function was written for. + const sameChunk = + Boolean(candidate.citation.chunk_id) && candidate.citation.chunk_id === finding.citation.chunk_id; + if (sameChunk) return true; + if (passageKey(candidate) !== passageKey(finding)) return false; + // Across chunks, containment only counts when the shorter side is long + // enough to identify a passage. A stray fragment is a substring of almost + // anything. + const shorter = other.length < text.length ? other : text; + return shorter.length >= minPassageOverlap; + }); + + if (duplicateIndex === -1) { + kept.push(finding); + continue; + } + + const existing = kept[duplicateIndex]; + const existingText = normalized.get(existing) ?? ""; + const fuller = text.length > existingText.length ? finding : existing; + const severest = safetyKindPriority[finding.kind] < safetyKindPriority[existing.kind] ? finding : existing; + // The id encodes the kind, so a merge that takes one finding's text and + // another's severity has to rebuild it rather than keep a `monitoring:` id + // on a row now labelled "Red flag". + kept[duplicateIndex] = + fuller === severest + ? fuller + : { + ...fuller, + id: `${severest.kind}:${fuller.citation.chunk_id}`, + kind: severest.kind, + label: severest.label, + }; + normalized.set(kept[duplicateIndex], normalizeText(kept[duplicateIndex].text).toLowerCase()); + } + + return kept; +} + export function extractSafetyFindings(answer: RagAnswer | null | undefined, limit = 5): SafetyFinding[] { - if (answer?.safetyWarnings) return answer.safetyWarnings.slice(0, limit); + if (answer?.safetyWarnings) return collapseDuplicateSafetyFindings(answer.safetyWarnings).slice(0, limit); if (!answer?.grounded) return []; if (answer.relevance && !answer.relevance.isSourceBacked) return []; @@ -144,10 +249,13 @@ export function extractSafetyFindings(answer: RagAnswer | null | undefined, limi href: documentCitationHref(candidate.citation), }); - if (findings.length >= limit) break; + // Deliberately NOT `>= limit`: the collapse below can merge two of these + // into one, and stopping at the limit first would let a duplicate crowd out + // a genuinely distinct finding. + if (findings.length >= limit * 2) break; } - return findings; + return collapseDuplicateSafetyFindings(findings).slice(0, limit); } export function formatSafetyFindingLabel(finding: SafetyFinding) { diff --git a/tests/answer-follow-up.test.ts b/tests/answer-follow-up.test.ts index cd7a3bd67..0f2b1fcb0 100644 --- a/tests/answer-follow-up.test.ts +++ b/tests/answer-follow-up.test.ts @@ -386,23 +386,188 @@ describe("buildAnswerFollowUpSuggestions · already-answered suppression", () => }); describe("buildAnswerFollowUpSuggestions · thread and shape rules", () => { - it("puts reported gaps first and still respects the four-chip cap", () => { + it("never turns a reported gap's prose into a question", () => { const suggestions = buildAnswerFollowUpSuggestions( "lithium dosing", { ...answerFor(), conflictsOrGaps: [ - { type: "gap", message: "Paediatric dosing is not covered." }, - { type: "conflict", message: "The two guidelines disagree on the target level." }, + // The real messages `detectConflictsOrGaps` writes: full advisory + // sentences, one of them two sentences long. Wrapping either in + // "What does the source say about ...?" cannot produce English, and + // for a while the live answer page showed exactly that. + { + type: "gap", + message: + "Current evidence comes from one document; broaden document scope if you need cross-document comparison.", + }, + { + type: "conflict", + message: + "Sources disagree on the ANC withholding threshold (1.5 vs 2.0). Confirm the correct cut-off against the primary guideline before acting on any single source.", + }, ], }, ["lithium dosing"], ); + for (const suggestion of suggestions) { + expect(suggestion).not.toContain("What does the source say about"); + // Every suggestion is one question, so the only sentence-ending + // punctuation it may carry is its own trailing "?". + expect(suggestion.slice(0, -1)).not.toMatch(/[.;]/); + expect(suggestion.endsWith("?")).toBe(true); + } + // The gap is not silently dropped: the reader still sees its exact words as + // a caveat on the answer itself (`answer-render-policy`), and the authored + // question is offered whenever a slot is free — see the spare-slot test + // below. Here the four dosing chips fill every slot, and a concrete dosing + // question outranks a meta-question about coverage. expect(suggestions).toHaveLength(4); - expect(suggestions[0]).toBe("What does the source say about paediatric dosing is not covered?"); - expect(suggestions[1]).toBe("What does the source say about the two guidelines disagree on the target level?"); - expect(suggestions[2]).toBe("What monitoring is required for lithium?"); + expect(suggestions).not.toContain("What does the indexed guidance not cover for lithium?"); + }); + + it("offers a gap's own words when the gap is already a question", () => { + const suggestions = buildAnswerFollowUpSuggestions( + "lithium dosing", + { + ...answerFor(), + conflictsOrGaps: [{ type: "gap", message: "Which guideline governs paediatric dosing?" }], + }, + ["lithium dosing"], + ); + + expect(suggestions[0]).toBe("Which guideline governs paediatric dosing?"); + // A gap that asked for itself suppresses the generic source-gap template. + expect(suggestions).not.toContain("What does the indexed guidance not cover for lithium?"); + }); + + it("never displaces a concrete menu chip with the gap question", () => { + // The gap question is offered last and only into a spare slot. Put it first + // and a gapped medication_dose_risk answer trades the renal/hepatic dosing + // chip — a concrete, evidence-backed question — for a meta-question about + // coverage. The gap's own words are already on screen as a caveat, so the + // chip is the cheaper of the two things to lose. + const suggestions = buildAnswerFollowUpSuggestions( + "lithium dosing", + { + ...answerFor(), + conflictsOrGaps: [{ type: "gap", message: "Paediatric dosing is not covered." }], + }, + ["lithium dosing"], + ); + + expect(suggestions).toEqual([ + "What monitoring is required for lithium?", + "What cautions or contraindications apply to lithium?", + "What should trigger stopping or escalating lithium?", + "How is lithium dosed in renal or hepatic impairment?", + ]); + }); + + it("offers the gap question in a spare slot on a menu that has no gap item", () => { + // `source_gap` lives only in the `management` menu, so before this a reported + // gap on any other query class went unmentioned entirely. + const suggestions = buildAnswerFollowUpSuggestions( + "lithium dosing", + { + ...answerFor({ answer: "Monitoring, cautions and escalation are all covered above." }), + conflictsOrGaps: [{ type: "gap", message: "Paediatric dosing is not covered." }], + }, + ["lithium dosing"], + ); + + expect(suggestions.length).toBeLessThanOrEqual(4); + if (suggestions.length < 4) { + expect(suggestions.at(-1)).toBe("What does the indexed guidance not cover for lithium?"); + } + }); + + it("does not call a conflict a coverage gap", () => { + // `detectConflictsOrGaps` writes `type: "conflict"` when sources disagree on + // a withholding threshold. That answer HAS coverage, from several sources — + // the problem is that they contradict each other — so "What does the indexed + // guidance not cover?" misstates the evidence and points the clinician at + // the wrong follow-up. + const suggestions = buildAnswerFollowUpSuggestions( + "lithium dosing", + { + ...answerFor({ answer: "Monitoring, cautions and escalation are all covered above." }), + conflictsOrGaps: [ + { + type: "conflict", + message: + "Sources disagree on the ANC withholding threshold (1.5 vs 2.0). Confirm the correct cut-off against the primary guideline before acting on any single source.", + }, + ], + }, + ["lithium dosing"], + ); + + expect(suggestions).not.toContain("What does the indexed guidance not cover for lithium?"); + }); + + it("still offers the gap question when a gap accompanies a conflict", () => { + const suggestions = buildAnswerFollowUpSuggestions( + "lithium dosing", + { + ...answerFor({ answer: "Monitoring, cautions and escalation are all covered above." }), + conflictsOrGaps: [ + { type: "conflict", message: "Sources disagree on the ANC withholding threshold (1.5 vs 2.0)." }, + { type: "gap", message: "Paediatric dosing is not covered." }, + ], + }, + ["lithium dosing"], + ); + + if (suggestions.length < 4) { + expect(suggestions).toContain("What does the indexed guidance not cover for lithium?"); + } + }); + + it("stays silent about a gap the answer already has a Source gap section for", () => { + // The menu loop drops its own `source_gap` template when a section of that + // kind was emitted; the direct offer has to apply the same rule, or the chip + // asks what the guidance does not cover directly beneath a section that + // just said. `source_gap` is a real emitted kind — `rag.ts` maps + // gap/unsupported/missing/unclear headings onto it. + const suggestions = buildAnswerFollowUpSuggestions( + "lithium management", + { + ...answerFor({ + query: "lithium management", + intent: "general", + answer: "Review the plan at each visit.", + sections: [ + { + heading: "Caveat", + kind: "source_gap", + body: "The guidance does not cover paediatric use.", + citation_chunk_ids: [], + }, + ], + }), + conflictsOrGaps: [{ type: "gap", message: "Paediatric dosing is not covered." }], + }, + ["lithium management"], + ); + + expect(suggestions).not.toContain("What does the indexed guidance not cover for lithium?"); + }); + + it("offers no gap question when the topic is not supported by the evidence", () => { + // `reportedGapQuestion` interpolates the topic, so offering it past this gate + // would name a subject the corpus never mentioned. + const suggestions = buildAnswerFollowUpSuggestions( + "quetiapine dosing", + { + ...answerFor({ query: "quetiapine dosing" }), + conflictsOrGaps: [{ type: "gap", message: "Paediatric dosing is not covered." }], + }, + ["quetiapine dosing"], + ); + + expect(suggestions).not.toContain("What does the indexed guidance not cover for quetiapine?"); }); it("avoids repeating questions already asked in the thread", () => { diff --git a/tests/clinical-safety.test.ts b/tests/clinical-safety.test.ts index 71f3b8fe7..a981e58e9 100644 --- a/tests/clinical-safety.test.ts +++ b/tests/clinical-safety.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { extractSafetyFindings, sortSafetyFindingsBySeverity, type SafetyFinding } from "../src/lib/clinical-safety"; +import { + collapseDuplicateSafetyFindings, + extractSafetyFindings, + sortSafetyFindingsBySeverity, + type SafetyFinding, +} from "../src/lib/clinical-safety"; import type { RagAnswer } from "../src/lib/types"; const answer: RagAnswer = { @@ -211,3 +216,178 @@ describe("clinical safety findings", () => { expect(findings[0].text).toContain("contraindicated in pregnancy"); }); }); + +describe("safety findings are counted once per passage", () => { + // The live clozapine answer reported "3 safety notes" over two passages: the + // quote card and its own parent chunk both matched, so one passage was listed + // twice — once as "Red flag" (the longer text reaches that pattern) and once + // as "Monitoring" (the extract does not). The count is the whole point of the + // chip that opens this list, so an inflated one is not cosmetic. + const passage = + "clozapine safety checkpoints: FBC/ANC monitoring, myocarditis symptom screening, metabolic monitoring, constipation prevention, and shared-care communication."; + const fullerPassage = `${passage} Urgent review triggers include fever, chest pain, dyspnoea, tachycardia, marked sedation, seizures.`; + + const duplicatedAnswer: RagAnswer = { + answer: "Clozapine monitoring covers FBC/ANC and myocarditis screening.", + grounded: true, + confidence: "medium", + citations: [], + quoteCards: [ + { + chunk_id: "chunk-a", + document_id: "doc-a", + title: "Clozapine monitoring protocol", + file_name: "clozapine.pdf", + page_number: 1, + chunk_index: 0, + similarity: 0.82, + quote: passage, + }, + ] as RagAnswer["quoteCards"], + sources: [ + { + id: "chunk-a", + document_id: "doc-a", + title: "Clozapine monitoring protocol", + file_name: "clozapine.pdf", + page_number: 1, + chunk_index: 0, + section_heading: "Monitoring", + content: fullerPassage, + image_ids: [], + similarity: 0.82, + images: [], + }, + { + id: "chunk-b", + document_id: "doc-a", + title: "Clozapine monitoring protocol", + file_name: "clozapine.pdf", + page_number: 2, + chunk_index: 1, + section_heading: "Escalation", + content: "Escalate for urgent review when red flag features are present.", + image_ids: [], + similarity: 0.8, + images: [], + }, + ], + }; + + it("collapses a quote card into its own parent chunk", () => { + const findings = extractSafetyFindings(duplicatedAnswer); + const page1 = findings.filter((finding) => finding.citation.page_number === 1); + + expect(page1).toHaveLength(1); + // The fuller text survives, and with it the more severe of the two labels. + expect(page1[0].text).toContain("Urgent review triggers"); + expect(page1[0].label).toBe("Red flag"); + // A genuinely separate passage on another page is untouched. + expect(findings.filter((finding) => finding.citation.page_number === 2)).toHaveLength(1); + }); + + it("collapses warnings that arrive already computed, not only freshly extracted ones", () => { + // The server computes these and the client re-reads them, so the guarantee + // has to hold on the way in as well as at extraction. + const precomputed = extractSafetyFindings({ ...duplicatedAnswer }); + const doubled = [...precomputed, ...precomputed]; + + expect(extractSafetyFindings({ ...duplicatedAnswer, safetyWarnings: doubled })).toHaveLength(precomputed.length); + }); + + it("collapses a short quote against its own parent chunk, under the length floor", () => { + // The floor exists for the cross-chunk case. A quote card carries its parent + // chunk's id, so containment there is proof of one passage however short the + // extract — and applying the floor to it let a short quote double-count + // against the very chunk it was cut from. + const fromChunk = (id: string, chunkId: string, text: string, kind: SafetyFinding["kind"]): SafetyFinding => ({ + id, + kind, + label: kind === "red_flag" ? "Red flag" : "Monitoring", + text, + citation: { + chunk_id: chunkId, + document_id: "doc-a", + title: "Protocol", + file_name: "p.pdf", + page_number: 1, + chunk_index: 0, + similarity: 0.8, + }, + href: "/documents/doc-a?page=1", + }); + + const findings = collapseDuplicateSafetyFindings([ + fromChunk("monitoring:chunk-a", "chunk-a", "Monitor ANC weekly.", "monitoring"), + fromChunk("red_flag:chunk-a", "chunk-a", "Monitor ANC weekly. Urgent review if fever develops.", "red_flag"), + ]); + + expect(findings).toHaveLength(1); + expect(findings[0].text).toContain("Urgent review"); + expect(findings[0].label).toBe("Red flag"); + // The id encodes the kind, so a merge that takes one finding's text and the + // other's severity has to rebuild it. + expect(findings[0].id).toBe("red_flag:chunk-a"); + }); + + it("reaches a fixed point, so the server and client counts cannot disagree", () => { + // One pass merges into the FIRST passage-key match, so a finding containing + // two already-kept ones lands on the first and leaves the second nested + // inside it. This function runs server-side into the payload and again on + // the client; a pass short of a fixed point would render "2" then "1". + const at = (id: string, text: string): SafetyFinding => ({ + id, + kind: "monitoring", + label: "Monitoring", + text, + citation: { + chunk_id: id, + document_id: "doc-a", + title: "Protocol", + file_name: "p.pdf", + page_number: 1, + chunk_index: 0, + similarity: 0.8, + }, + href: "/documents/doc-a?page=1", + }); + + const first = "Monitor full blood count and absolute neutrophil count every week for the first eighteen weeks."; + const second = + "Review metabolic parameters at baseline, three months, and then annually thereafter for this patient."; + const both = `${second} ${first}`; + + const once = collapseDuplicateSafetyFindings([at("a", first), at("b", second), at("c", both)]); + expect(once).toHaveLength(1); + // Idempotent: collapsing the output again changes nothing. + expect(collapseDuplicateSafetyFindings(once)).toEqual(once); + }); + + it("does not merge two short findings that merely share words", () => { + const short = (id: string, page: number, text: string): SafetyFinding => ({ + id, + kind: "monitoring", + label: "Monitoring", + text, + citation: { + chunk_id: id, + document_id: "doc-a", + title: "Protocol", + file_name: "p.pdf", + page_number: page, + chunk_index: 0, + similarity: 0.8, + }, + href: `/documents/doc-a?page=${page}`, + }); + + // Same page, one text a substring of the other, but far too short to be + // treated as the same passage. + const findings = collapseDuplicateSafetyFindings([ + short("a", 1, "Monitor FBC."), + short("b", 1, "Monitor FBC. Repeat weekly."), + ]); + + expect(findings).toHaveLength(2); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 456c5f081..10a6d87eb 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2124,7 +2124,6 @@ test.describe("PsychSift UI smoke coverage", () => { ].filter((entry) => entry !== null); const neighbours = [ box('[data-testid="answer-card-support"]'), - box('[data-testid="answer-cited-count"]'), box('[data-testid="plain-answer-prose"]'), ...chips, ].filter((entry) => entry !== null); From 414cad14d367b01f1758461b9e6b21f476483a56 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:12:09 +0800 Subject: [PATCH 44/50] fix(build): keep the source typecheck out of Next's build output again (#2501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsconfig.typecheck.json` exists to answer one question — "is the source itself sound?" — without reading gitignored build artifacts, because a stale generated validator reports errors that no longer exist in source and CI, on a fresh checkout, never sees them. A permanently-red local gate is why real type errors reach CI instead of being caught before push (`docs/outstanding-issues.md` #210). Excluding `.next/**` stopped being sufficient. An `exclude` filters what the `include` globs collect; it cannot drop a file that an included file IMPORTS. Next 16 regenerates `next-env.d.ts` with import "./.next/dev/types/routes.d.ts"; import "./.next/dev/types/root-params.d.ts"; so the moment `next dev` or `next build` runs, the source-health typecheck is reading build output again through the back door. Measured on 2026-09-01: `next dev` left `.next/dev/types/routes.d.ts` with a stray fragment (`d": {}`) where a shorter write had not truncated a longer previous file, and `npm run typecheck` reported 106 syntax errors against source that was completely sound. Deleting `.next` made it pass instantly. Reproduced deliberately here — the same corrupt artefact gives 106 errors under the old config and exit 0 under the new one. `next-env.typecheck.d.ts` carries the half of `next-env.d.ts` that is real type information (the two reference directives, which resolve inside `node_modules`) and none of the half that points at build output. `tsconfig.typecheck.json` includes it and excludes `next-env.d.ts` by name — `**/*.ts` matches `.d.ts`, so dropping it from `include` alone would not keep it out. Route-signature validation is not lost: `tsconfig.json` still uses the real `next-env.d.ts`, so `next build` (CI's Build job) keeps typechecking the generated types against the actual routes. `tests/typecheck-config-isolation.test.ts` fails if either the include or the exclude is undone, if the stand-in grows a reference into build output, or if a Next upgrade adds a reference directive to `next-env.d.ts` that the stand-in does not carry. Co-authored-by: Claude --- next-env.typecheck.d.ts | 37 ++++++++++++ tests/typecheck-config-isolation.test.ts | 73 ++++++++++++++++++++++++ tsconfig.typecheck.json | 18 +++++- 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 next-env.typecheck.d.ts create mode 100644 tests/typecheck-config-isolation.test.ts diff --git a/next-env.typecheck.d.ts b/next-env.typecheck.d.ts new file mode 100644 index 000000000..bd6817212 --- /dev/null +++ b/next-env.typecheck.d.ts @@ -0,0 +1,37 @@ +/** + * The global Next.js types, without the build-artifact imports. + * + * `next-env.d.ts` is regenerated by Next on every `next dev` / `next build`, is + * gitignored, and since Next 16 its body is: + * + * /// + * /// + * import "./.next/dev/types/routes.d.ts"; + * import "./.next/dev/types/root-params.d.ts"; + * + * Those two imports are the problem `tsconfig.typecheck.json` exists to avoid. + * An `exclude` only filters what the `include` globs pull in; it cannot drop a + * file another included file imports. So excluding `.next/**` stopped meaning + * anything the moment `next-env.d.ts` started importing into it, and the + * source-health typecheck went back to reading gitignored build output — which + * is exactly the failure `docs/outstanding-issues.md` `#210` describes. + * + * It is not hypothetical. On 2026-09-01 `next dev` left + * `.next/dev/types/routes.d.ts` with a stray fragment (`d": {}`) where a shorter + * write had not truncated a longer previous file, and `npm run typecheck` + * reported 106 syntax errors against source that was completely sound. Deleting + * `.next` made it pass instantly. + * + * This file carries the half of `next-env.d.ts` that is real type information — + * the two reference directives, which resolve inside `node_modules` — and none + * of the half that points at build output. `tsconfig.typecheck.json` includes + * this and excludes `next-env.d.ts`; `tsconfig.json` still uses the real one, so + * `next build` continues to typecheck route signatures against actual routes. + * + * Keep it in step with `next-env.d.ts`'s reference directives on a Next upgrade. + * `tests/typecheck-config-isolation.test.ts` fails if a new directive appears + * there and not here, and if either the include or the exclude is undone. + */ + +/// +/// diff --git a/tests/typecheck-config-isolation.test.ts b/tests/typecheck-config-isolation.test.ts new file mode 100644 index 000000000..0313b7b5c --- /dev/null +++ b/tests/typecheck-config-isolation.test.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = path.resolve(__dirname, ".."); +const typecheckConfigPath = path.join(root, "tsconfig.typecheck.json"); +const standInPath = path.join(root, "next-env.typecheck.d.ts"); +const generatedEnvPath = path.join(root, "next-env.d.ts"); + +/** The config is JSONC and every comment in it is a whole line. */ +function readJsonc(filePath: string) { + const withoutComments = fs + .readFileSync(filePath, "utf8") + .split("\n") + .filter((line) => !line.trim().startsWith("//")) + .join("\n"); + return JSON.parse(withoutComments) as { include?: string[]; exclude?: string[] }; +} + +function referenceDirectives(source: string) { + return [...source.matchAll(/\/\/\/\s*/g)].map((match) => match[1]).sort(); +} + +/** + * `tsconfig.typecheck.json` answers one question — "is the source itself + * sound?" — and it can only answer it if it never reads gitignored build output + * (`docs/outstanding-issues.md` `#210`). Excluding `.next/**` is not enough on + * its own: an `exclude` filters what the `include` globs collect, and cannot + * drop a file that an included file *imports*. Next 16 regenerates + * `next-env.d.ts` with `import "./.next/dev/types/routes.d.ts"` in it, which + * reinstated the whole dependency through that back door. + * + * Measured on 2026-09-01: with `next-env.d.ts` included, a `next dev` run that + * left a stray fragment in `.next/dev/types/routes.d.ts` made `npm run + * typecheck` report 106 syntax errors against sound source. With the stand-in, + * the identical artefact produced exit 0. + */ +describe("source typecheck stays out of build output", () => { + const config = readJsonc(typecheckConfigPath); + + it("includes the build-artifact-free stand-in, not Next's generated file", () => { + expect(config.include).toContain("next-env.typecheck.d.ts"); + expect(config.include).not.toContain("next-env.d.ts"); + }); + + it("excludes the generated file by name as well as the build directory", () => { + // `**/*.ts` matches `.d.ts`, so dropping `next-env.d.ts` from `include` + // would not keep it out on its own. + expect(config.exclude).toContain("next-env.d.ts"); + expect(config.exclude).toContain(".next/**"); + }); + + it("keeps the stand-in free of any reference into build output", () => { + const standIn = fs.readFileSync(standInPath, "utf8"); + const code = standIn + .replace(/\/\*[\s\S]*?\*\//g, "") + .split("\n") + .filter((line) => !line.trim().startsWith("*")) + .join("\n"); + expect(code).not.toContain(".next"); + expect(referenceDirectives(code)).toEqual(["next", "next/image-types/global"]); + }); + + it("carries every reference directive Next puts in its generated file", () => { + // Gitignored, so it is absent on a fresh checkout and in CI. When a local + // `next dev`/`next build` has produced it, a Next upgrade that adds a + // directive must not leave the stand-in behind. + if (!fs.existsSync(generatedEnvPath)) return; + const generated = referenceDirectives(fs.readFileSync(generatedEnvPath, "utf8")); + const standIn = referenceDirectives(fs.readFileSync(standInPath, "utf8")); + for (const directive of generated) expect(standIn).toContain(directive); + }); +}); diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 39f1b4b50..e97b9cda4 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -27,6 +27,20 @@ "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsc/tsconfig.typecheck.tsbuildinfo" }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.mts"], - "exclude": ["node_modules", ".next/**", "scratch/**", "supabase/functions/**", "worktrees/**", "scripts/archive/**"] + // `next-env.typecheck.d.ts` stands in for `next-env.d.ts`, which Next 16 + // regenerates with `import "./.next/dev/types/routes.d.ts"` in it. An + // `exclude` cannot drop a file that an included file imports, so that one line + // silently reinstated the `.next` dependency this config exists to remove. + // `next-env.d.ts` is excluded explicitly because `**/*.ts` matches `.d.ts` + // too, so dropping it from `include` alone would not keep it out. + "include": ["next-env.typecheck.d.ts", "**/*.ts", "**/*.tsx", "**/*.mts"], + "exclude": [ + "node_modules", + ".next/**", + "next-env.d.ts", + "scratch/**", + "supabase/functions/**", + "worktrees/**", + "scripts/archive/**" + ] } From 7cd20b1ea121d9cf9f66efa04971f8fefee63f05 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:32:28 +0800 Subject: [PATCH 45/50] fix(ci): align runtime contract with Node 26 --- .claude/hooks/session-start.sh | 21 ++++++++-------- .claude/skills/run-pr/SKILL.md | 2 +- .github/workflows/codex-run-pr-operator.yml | 2 +- .github/workflows/live-web-vitals.yml | 2 +- .node-version | 2 +- .nvmrc | 2 +- AGENTS.md | 4 ++-- Dockerfile | 4 ++-- Dockerfile.worker | 2 +- README.md | 2 +- docs/agents-guide.md | 2 +- docs/agents/codex-desktop-worktree-setup.md | 4 ++-- docs/agents/cursor-cloud.md | 2 +- docs/claude-cloud.md | 2 +- docs/codebase-index.md | 2 +- docs/codex-cloud.md | 2 +- docs/codex-prompt-playbook.md | 2 +- docs/database-remediation-playbook.md | 4 ++-- docs/deploy-corrector-public-titles.md | 2 +- docs/deployment-architecture.md | 6 ++--- ...work-dependency-modernization-checklist.md | 10 ++++---- docs/launch-operator-runbook.md | 4 ++-- docs/process-hardening.md | 4 ++-- docs/production-readiness-checklist.md | 4 ++-- .../codex-cloud-design-status-semantics.md | 4 ++-- docs/prompts/codex-cloud-detailed-task.md | 2 +- ...aware-clinical-ask-codex-cloud-handover.md | 2 +- docs/prompts/rag-coverage-gate-extraction.md | 2 +- docs/testing.md | 10 ++++---- docs/worker-deploy-runbook.md | 16 ++++++------- eval/docling/Dockerfile | 2 +- eval/docling/generate-lock.mjs | 2 +- package.json | 2 +- .../skills/clinical-kb-workflow/SKILL.md | 2 +- scripts/build-worker.mjs | 2 +- scripts/check-codex-cloud-setup.mjs | 6 ++--- scripts/check-node-engine.cjs | 7 +++--- scripts/check-runtime.ts | 10 ++++---- scripts/dev-free-port.mjs | 4 ++-- scripts/diagnose-codex-cloud.mjs | 6 ++--- scripts/ensure-local-server.mjs | 4 ++-- scripts/run-playwright.mjs | 4 ++-- tests/check-runtime.test.ts | 24 +++++++++---------- tests/codex-cloud-diagnose.test.ts | 2 +- tests/session-start-hook.test.ts | 8 +++---- tests/setup-codex-worktree.test.ts | 11 ++++----- tests/worker-bundle.test.ts | 4 ++++ tests/worker-runtime-validation.test.ts | 2 +- worker/validate-runtime.ts | 4 ++-- 49 files changed, 117 insertions(+), 118 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index eb01bb594..53abbcb71 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,8 +1,8 @@ #!/bin/bash # SessionStart hook for Claude Code on the web. -# The app is engine-strict on Node >=24.15 <25 / npm 11.x, but web containers +# The app is engine-strict on Node >=26.0.0 <27 / npm 11.x, but web containers # ship an older Node on PATH, so nothing installs or runs until a Node meeting -# that floor is present. Installs one into $HOME/.node24 (cached with the +# that floor is present. Installs one into $HOME/.node26 (cached with the # container), exposes it via $CLAUDE_ENV_FILE, and installs npm dependencies. set -euo pipefail @@ -12,17 +12,16 @@ fi payload="$(cat 2>/dev/null || true)" -NODE_VERSION="24.19.0" +NODE_VERSION="26.8.1" # Keep in step with the floor in package.json engines.node. A matching major is -# not enough: dev dependencies (jsdom) carry a minor-level floor, so a 24.13 on -# PATH satisfied the old major-only check and then failed `npm ci` with -# EBADENGINE. That blocked PRs #1611, #1697, #1705 and #1740. -NODE_MINIMUM="24.15.0" -# Exclusive major ceiling, matching the "<25" half of engines.node. Checking only -# the floor would let a container shipping Node 25+ skip provisioning and then +# not enough: keep the complete range check so a future minor-level floor cannot +# let an older Node 26 runtime reach `npm ci` and fail with an opaque EBADENGINE. +NODE_MINIMUM="26.0.0" +# Exclusive major ceiling, matching the "<27" half of engines.node. Checking only +# the floor would let a container shipping Node 27+ skip provisioning and then # fail `npm ci`, which is the same blind-spot as the major-only check above. -NODE_MAJOR_CEILING="25" -NODE_HOME="$HOME/.node24" +NODE_MAJOR_CEILING="27" +NODE_HOME="$HOME/.node26" NODE_BIN="$NODE_HOME/node-v${NODE_VERSION}-linux-x64/bin" supported_runtime() { diff --git a/.claude/skills/run-pr/SKILL.md b/.claude/skills/run-pr/SKILL.md index f2cd72a13..bef7ab4e8 100644 --- a/.claude/skills/run-pr/SKILL.md +++ b/.claude/skills/run-pr/SKILL.md @@ -44,7 +44,7 @@ Never, even during a sweep: 2. `mcp__github__get_me` to confirm identity and access, then `git fetch origin --prune`. 3. Record the current branch/ref so it can be restored at sweep end. Require a clean `git status`; if the worktree is dirty, do not stash or discard — either restrict the sweep to - PRs whose fixes do not need this checkout, or create a separate `git worktree add` (Node 24, + PRs whose fixes do not need this checkout, or create a separate `git worktree add` (Node 26, `npm ci`) and report which was chosen. 4. `mcp__github__list_pull_requests` with `state=open`, paginated in small batches. Build the work queue in ascending PR number. diff --git a/.github/workflows/codex-run-pr-operator.yml b/.github/workflows/codex-run-pr-operator.yml index 7e01787e5..17d874e32 100644 --- a/.github/workflows/codex-run-pr-operator.yml +++ b/.github/workflows/codex-run-pr-operator.yml @@ -426,7 +426,7 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24.15.0 + node-version: 26.8.1 cache: npm - name: Install locked dependencies without repository lifecycle scripts diff --git a/.github/workflows/live-web-vitals.yml b/.github/workflows/live-web-vitals.yml index a1a07924e..81386855b 100644 --- a/.github/workflows/live-web-vitals.yml +++ b/.github/workflows/live-web-vitals.yml @@ -86,7 +86,7 @@ jobs: - name: Use the repository Node runtime uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "24" + node-version: "26" - name: Normalize the configured origin run: | diff --git a/.node-version b/.node-version index a45fd52cc..6f4247a62 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24 +26 diff --git a/.nvmrc b/.nvmrc index a45fd52cc..6f4247a62 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24 +26 diff --git a/AGENTS.md b/AGENTS.md index 03b80a302..e84a72c69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ workstation cannot see the thing that breaks it. This is not hypothetical: `session-start.sh` shipped `100644` while both its siblings were `100755` (found 2026-08-18). That script's body only runs when `CLAUDE_CODE_REMOTE=true`, so the sole environment it does work in is the Linux container where a non-executable checkout cannot - be run — and it is the script that provisions the Node 24 the engine floor needs, after + be run — and it is the script that provisions the Node 26 the engine floor needs, after `npm ci` EBADENGINE blocked PRs #1611, #1697, #1705 and #1740. - **Register hooks as `bash "$CLAUDE_PROJECT_DIR/…"`, never as a bare path**, so the mode is never load-bearing. `session-start.sh` was the only bare-path registration and the only one missing the @@ -1004,6 +1004,6 @@ Codex Cloud uses an isolated Linux container and does not inherit desktop creden Durable notes for Cursor Cloud agents (see `docs/agents-guide.md` and `docs/testing.md` for full reference): - Context7 peer-library docs habit and Next 16 local docs live in `docs/agents-guide.md`. -- Requires Node >=24.15.0 <25 / npm 11.x (installed via nvm, symlinked to `/usr/local/cargo/bin`). +- Requires Node >=26.0.0 <27 / npm 11.x (installed via nvm, symlinked to `/usr/local/cargo/bin`). - Offline verification: `npm run lint`, `npm run typecheck`, `npm run test`, `npm run verify:cheap`, and `npm run verify:pr-local` all pass without secrets. - For authorized GitHub work, use the connected GitHub connector/MCP tools as primary interface (`BigSimmo` write access). diff --git a/Dockerfile b/Dockerfile index 87e2e89d9..cd7b5551a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1 # PsychSift app tier (Next.js). See docs/deployment-architecture.md. # -# The repo is engine-strict (Node 24.x / npm 11.x via .npmrc + preinstall -# guard), so every stage pins the same Node 24 base image. The build stage +# The repo is engine-strict (Node 26.x / npm 11.x via .npmrc + preinstall +# guard), so every stage pins the same Node 26 base image. The build stage # runs the repo's own `npm run build` (guard-next-build + next build) so the # image build fails exactly where a local build would. # diff --git a/Dockerfile.worker b/Dockerfile.worker index d2fc496af..bd6d8f1ed 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -4,7 +4,7 @@ # container instead of completing the edge-agent migration. # # Runtime contents (2026-07-13 audit, finding 12 — slimmed): -# - Node 24 + PRODUCTION-ONLY node_modules: the worker runs as a +# - Node 26 + PRODUCTION-ONLY node_modules: the worker runs as a # prebuilt esbuild bundle (dist/worker/index.mjs), so tsx and the rest # of the dev toolchain never reach the image. # - Tesseract OCR (Debian package, bundles English language data). diff --git a/README.md b/README.md index 94a82998c..9b9f6abb1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ questions with source citations that link back to the original PDF/document. ## Setup -1. Use Node.js 24.x with npm 11.x. CI runs on Node 24, and `.nvmrc` / +1. Use Node.js 26.x with npm 11.x. CI runs on Node 26, and `.nvmrc` / `.node-version` pin the same runtime for local version managers. CI also runs `npm run check:edge:functions`, which requires Deno v2.x. 2. Install dependencies: diff --git a/docs/agents-guide.md b/docs/agents-guide.md index 0f9bbdb61..072ef441a 100644 --- a/docs/agents-guide.md +++ b/docs/agents-guide.md @@ -17,7 +17,7 @@ duplicate those rules, so it cannot drift from them. ## Human quickstart -- Node 24.x / npm 11.x are hard requirements (`engine-strict`); the app is +- Node 26.x / npm 11.x are hard requirements (`engine-strict`); the app is Next.js 16 + Supabase + OpenAI. - Copy `.env.example` to `.env.local` and fill in values (never commit secrets). Without Supabase/OpenAI values the app runs in demo mode on a diff --git a/docs/agents/codex-desktop-worktree-setup.md b/docs/agents/codex-desktop-worktree-setup.md index d89256441..c3b90fd4c 100644 --- a/docs/agents/codex-desktop-worktree-setup.md +++ b/docs/agents/codex-desktop-worktree-setup.md @@ -5,7 +5,7 @@ # Codex Desktop worktree setup - The Windows Codex Desktop environment setup command is `node scripts/setup-codex-worktree.mjs`. - It must work before `node_modules` exists, validate Node 24/npm 11, reuse only a complete + It must work before `node_modules` exists, validate Node 26/npm 11, reuse only a complete byte-identical local installation, and otherwise run the locked npm install. - Never configure Windows Desktop worktrees to run `bash scripts/setup-codex-cloud.sh`. That script is Linux/Cloud-only; Windows launches it through WSL outside the worktree and cannot provision the @@ -13,6 +13,6 @@ - `.codex/environments/environment.toml` is autogenerated and ignored. Change the Database environment through Codex settings, then verify the effective command with the generated file and `node scripts/setup-codex-worktree.mjs --dry-run`. -- **Web container runtime requirements:** Package manifests enforce strict Node 24 (`>=24.15.0 <25`) and npm 11 engines. If a web container environment boots with Node 22 on `PATH`, do not drop engine-strict; export `/opt/node24/bin` at the front of `PATH` or install Node 24 to satisfy repository engine contracts before running `npm ci`. +- **Web container runtime requirements:** Package manifests enforce strict Node 26 (`>=26.0.0 <27`) and npm 11 engines. If a web container environment boots with Node 22 on `PATH`, do not drop engine-strict; export `/opt/node26/bin` at the front of `PATH` or install Node 26 to satisfy repository engine contracts before running `npm ci`. diff --git a/docs/agents/cursor-cloud.md b/docs/agents/cursor-cloud.md index f0e48fe4f..683ef9166 100644 --- a/docs/agents/cursor-cloud.md +++ b/docs/agents/cursor-cloud.md @@ -7,7 +7,7 @@ Durable notes for Cloud Agents. Standard commands live in `README.md` and `package.json`; only non-obvious caveats are captured here. - Context7 peer-library docs habit (and the Next 16 local-docs carve-out) lives in `docs/agents-guide.md`. Project MCP is local `@upstash/context7-mcp@3.2.5` with `CONTEXT7_API_KEY` from env/Secrets. If the host-injected Context7 MCP returns quota exceeded, use `npx ctx7 library|docs …` with the same secret — do not invent peer APIs from training data. -- Runtime: the app hard-requires Node >=24.15.0 <25 / npm 11.x (`engine-strict`; the preinstall and runtime gates enforce the minor floor, while `scripts/dev-free-port.mjs` rejects other majors). A compatible Node 24 is installed via nvm and symlinked into `/usr/local/cargo/bin` (first entry in `PATH`) so `node`/`npm` resolve to it in every shell. If a shell ever resolves `/exec-daemon/node` (v22) instead, prepend the installed nvm Node 24 bin to `PATH` (for example `"$HOME/.nvm/versions/node/v24.18.1/bin"`; run `ls "$HOME/.nvm/versions/node"` to confirm the exact patch version). +- Runtime: the app hard-requires Node >=26.0.0 <27 / npm 11.x (`engine-strict`; the preinstall and runtime gates enforce the supported range, while `scripts/dev-free-port.mjs` rejects other majors). A compatible Node 26 is installed via nvm and symlinked into `/usr/local/cargo/bin` (first entry in `PATH`) so `node`/`npm` resolve to it in every shell. If a shell ever resolves `/exec-daemon/node` (v22) instead, prepend the installed nvm Node 26 bin to `PATH` (for example `"$HOME/.nvm/versions/node/v26.8.1/bin"`; run `ls "$HOME/.nvm/versions/node"` to confirm the exact patch version). - Live vs demo mode: the app auto-detects. When the Supabase + OpenAI env vars below are present (set them as Cloud Agent **Secrets** so they inject into `.env.local`/`process.env`), `isDemoMode()` (`src/lib/env.ts`) is false and the app runs against the live `Clinical KB Database` project (~2000 indexed docs) with OpenAI answer generation. When they are absent, dev auto-falls back to demo mode using the synthetic corpus in `src/lib/demo-data.ts` / `public/demo-documents/`. Required for live mode: `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_PROJECT_REF`, `SUPABASE_PROJECT_NAME`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` (`sb_publishable_…`), `SUPABASE_SERVICE_ROLE_KEY` (accepts the `sb_secret_…` secret key), `OPENAI_API_KEY`. Keep `RAG_PROVIDER_MODE=auto` so OpenAI is used with graceful source-only fallback. `E2E_USER_EMAIL`/`E2E_USER_PASSWORD` power CI env-check and Playwright. - Live-mode caveat: `RAG_PROVIDER_MODE=auto` attempts OpenAI (fast → strong route); if generation fails the built-in quality gates it silently degrades to a deterministic "Source-only" answer that still cites real documents — this is expected, not a failure. The header sign-in UI exposes magic-link + OAuth only (no password field), but the `/api/answer` + retrieval flow works server-side without a browser session. - What still won't run in this VM even with secrets: `npm run worker` also needs the Python OCR stack (`worker/python/requirements.txt`) and heavy parsing deps; Supabase edge functions need Deno v2.x + deployment. `verify:release` additionally runs governance/eval gates. Treat missing-secret failures of `check:supabase-project`/`verify:release` in demo mode as expected, not regressions. diff --git a/docs/claude-cloud.md b/docs/claude-cloud.md index 2be0a1313..7d91b436e 100644 --- a/docs/claude-cloud.md +++ b/docs/claude-cloud.md @@ -15,7 +15,7 @@ provisioners. | Order | Command | Timeout | Does | | ----- | ----------------------------------------- | ------- | -------------------------------------------------- | -| 1 | `.claude/hooks/session-start.sh` | 900s | Installs Node 24 to `$HOME/.node24`, then `npm ci` | +| 1 | `.claude/hooks/session-start.sh` | 900s | Installs Node 26 to `$HOME/.node26`, then `npm ci` | | 2 | `scripts/setup-claude-cloud.sh --session` | 300s | Everything below | Both no-op instantly unless `CLAUDE_CODE_REMOTE=true`, so a local Windows session is untouched. diff --git a/docs/codebase-index.md b/docs/codebase-index.md index a9829195e..4f7e69640 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -704,7 +704,7 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: | File | Role | | ------------------------------------------ | --------------------------------------------------------- | -| `package.json` | Scripts, deps, Node 24 / npm 11 | +| `package.json` | Scripts, deps, Node 26 / npm 11 | | `.env.example` | Full env template | | `next.config.ts` | CSP, security headers, build config | | `tsconfig.json` | Strict TS; excludes `supabase/functions/**` | diff --git a/docs/codex-cloud.md b/docs/codex-cloud.md index d8f4cee77..71ef422f0 100644 --- a/docs/codex-cloud.md +++ b/docs/codex-cloud.md @@ -4,7 +4,7 @@ > Windows uses `npm run setup:codex-worktree`; pointing Desktop at this Cloud > script starts WSL outside the Windows worktree and cannot provision it. -This repository supports reproducible Codex Cloud work with Node 24, npm 11, locked +This repository supports reproducible Codex Cloud work with Node 26, npm 11, locked development dependencies, Deno 2, Python/OCR tooling, and the Chromium, Firefox, and WebKit Playwright browser matrix. The repository setup can prepare and validate the container. It cannot grant GitHub installation permissions, workspace RBAC, agent-network diff --git a/docs/codex-prompt-playbook.md b/docs/codex-prompt-playbook.md index d5972a75d..be2838b1f 100644 --- a/docs/codex-prompt-playbook.md +++ b/docs/codex-prompt-playbook.md @@ -7,7 +7,7 @@ Before using any prompt, keep these project defaults in mind: - Start from `AGENTS.md`, `README.md`, `package.json`, and current `git status`. - Preserve unrelated staged, unstaged, and untracked work. -- Use Node 24.x and npm 11.x. Do not switch package managers. +- Use Node 26.x and npm 11.x. Do not switch package managers. - For Next.js source changes, read the relevant guide under `node_modules/next/dist/docs/` before editing. - Use `npm run ensure` before browser/UI work and use the URL it prints. diff --git a/docs/database-remediation-playbook.md b/docs/database-remediation-playbook.md index 3fec4c938..5288e09b4 100644 --- a/docs/database-remediation-playbook.md +++ b/docs/database-remediation-playbook.md @@ -115,8 +115,8 @@ against a pinned canonical definition, and only then marks a fail-fast guard mig close request for `#333` was queued after confirming a `#333` row existed — but the match was the **archived** row, and the invalid request threw `#333 is already archived`, red-lining `docs:check-links` for the whole branch. -- **Node 24 is mandatory** (`engine-strict`). A cloud container may ship Node 20/22; `npm ci` then - fails `EBADENGINE` and leaves `tsx` unresolvable, which fails `check:runtime`. Install Node 24 +- **Node 26 is mandatory** (`engine-strict`). A cloud container may ship Node 20/22; `npm ci` then + fails `EBADENGINE` and leaves `tsx` unresolvable, which fails `check:runtime`. Install Node 26 before anything else. - **Two known tooling failures are fixed — recognise the symptoms rather than re-diagnosing them.** `cancel request … targets missing pending request` was the ledger cancel-race (fixed in PR #1978; diff --git a/docs/deploy-corrector-public-titles.md b/docs/deploy-corrector-public-titles.md index af76b5000..ea9f7d7b5 100644 --- a/docs/deploy-corrector-public-titles.md +++ b/docs/deploy-corrector-public-titles.md @@ -80,7 +80,7 @@ records the blocker; it does not itself authorize or implement the database clea ## Prerequisites -- The repo cloned locally, Node 24.x / npm 11.x. +- The repo cloned locally, Node 26.x / npm 11.x. - Your **production** secrets in `.env.local` (the same ones the live app uses): `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_PROJECT_REF=sjrfecxgysukkwxsowpy`, `SUPABASE_PROJECT_NAME=Clinical KB Database`, `SUPABASE_SERVICE_ROLE_KEY`. diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index bc590ebc6..ca61c7899 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -77,7 +77,7 @@ Railway's private network — see §2.1). Both Railway services deploy from ### Decision -Run the Next.js app as a **single long-lived container** (Node 24, image built +Run the Next.js app as a **single long-lived container** (Node 26, image built from `Dockerfile`) on **Railway**, pinned to the **Southeast Asia (Singapore)** region — the closest Railway region to the Supabase project's ap-southeast-2 (Sydney) home. Keep one warm replica (no scale-to-zero). @@ -206,7 +206,7 @@ comparable (~200 ms) from Singapore or Sydney and does not favour either host. ### Image contract (`Dockerfile`) -- `node:24-bookworm-slim` is pinned by multi-platform SHA-256 digest in a +- `node:26-bookworm-slim` is pinned by multi-platform SHA-256 digest in a shared `node-base` stage and used by every stage. Dependency installs use ordinary Docker layer caching. The shared Dockerfiles deliberately avoid BuildKit cache mounts because Railway requires hard-coded, service-specific @@ -283,7 +283,7 @@ check and watch patterns rather than relying on dashboard defaults. ### Decision: containerized worker (recommended) over completing the edge-agent migration -Ship the existing worker as a container (`Dockerfile.worker`: pinned Node 24 + +Ship the existing worker as a container (`Dockerfile.worker`: pinned Node 26 + a prebuilt esbuild bundle over production-only `node_modules` + Tesseract + a Python venv with a hashed `worker/python/requirements.txt` + a provider-free `dist/worker/validate-runtime.mjs` gate) and run **one diff --git a/docs/framework-dependency-modernization-checklist.md b/docs/framework-dependency-modernization-checklist.md index d4265b59d..7b97ed66f 100644 --- a/docs/framework-dependency-modernization-checklist.md +++ b/docs/framework-dependency-modernization-checklist.md @@ -15,7 +15,7 @@ The target for this program is: - Node.js 24.x and npm 11.x in development, CI, Docker, and production. - Next.js 16.2.x with React and React DOM 19.2.x. -- TypeScript 6.0.x, with runtime-compatible Node 24 type definitions. +- TypeScript 6.0.x, with runtime-compatible Node 26 type definitions. - Next.js App Router asynchronous request APIs and generated route types. - Turbopack as the default development and production bundler after a measured dual-lane migration from the current Webpack escape hatch. @@ -42,7 +42,7 @@ migration task. | P2 | Build | Production build and production Playwright force `next build --webpack`; [`next.config.ts`](../next.config.ts) retains a Webpack callback, WasmHash workaround, and one-CPU tuning. | Removing only the CLI flag either makes Next reject the custom Webpack config or silently changes build artifacts, CSP behavior, and resource use. | Run the dual-lane Turbopack program below. This is not a one-line flag change. | | P2 | Deploy | [`railway.app.json`](../railway.app.json) omits `run-heavy.mjs`, `test-run-lock.mjs`, and `child-process-result.mjs`; [`railway.worker.json`](../railway.worker.json) omits `build-worker.mjs`. | A build-controller-only change does not match a watch pattern, so Railway can leave the deployed image stale. | Cover every transitive image-build input, with a regression test for watch-pattern ownership. | | P2 | Types/CI | Clean CI runs `tsc --noEmit` without `next typegen`, while `next-env.d.ts` and `.next` types are ignored/generated. | An invalid page, layout, or route signature passes a clean typecheck or a developer sees results from stale generated types. | Generate Next route types in an owned clean path before TypeScript and test the clean-checkout behavior. | -| P2 | Runtime/types | `@types/node` targets 26.x while every runtime contract targets Node 24.x. | Code typechecks against a Node 26 API and fails in Docker or Railway on Node 24. | Pin the compatible Node 24 type line until the runtime moves. | +| Resolved | Runtime/types | `@types/node` and every runtime contract target Node 26.x. | Code and container/runtime checks now exercise the same Node major. | Keep the Node 26 engine, image, and type contracts aligned. | | P2 | Frontend recovery | Fourteen App Router error boundaries present `reset()` as "Try again", although Next 16.2 recommends `unstable_retry()` for re-fetching failed Server Component content. | A transient server/request failure reaches an error boundary. `reset()` re-renders without re-fetching and can repeat the failure. | Plumb `unstable_retry` through the shared boundary and prove a fail-once route recovers. | | P3 | Removal readiness | Zod 4 deprecated string UUID/URL methods and one `.passthrough()` remain; a mockup `` still uses deprecated `priority`. | A later Zod or Next removal turns warnings/deprecated behavior into compile failures. | Apply focused mechanical migrations with contract tests. | @@ -104,8 +104,8 @@ responseHeaders)` and copy all supplied headers onto every rebuilt ### 2. Align the runtime and clean typecheck contract -- [ ] Move `@types/node` from 26.x to the compatible 24.x line. Search new code - for APIs whose availability changed between Node 24 and 26. +- [x] Align the runtime contract with the existing `@types/node` 26.x line. Search new code + for APIs whose availability changes across supported Node majors. - [ ] Add the repository-local `next typegen` command before `tsc --noEmit` in the canonical typecheck path. - [ ] Generate route types in an owned clean location and ensure stale @@ -150,7 +150,7 @@ responseHeaders)` and copy all supplied headers onto every rebuilt - [ ] Prove ZIP entry-count, per-entry size, aggregate size, nested archive, malformed archive, and compression-ratio limits fail closed. Never accept a missing private field as size zero. -- [ ] Run Node 24 import probes and focused parser fixtures for PDF, DOCX, XLSX, +- [ ] Run Node 26 import probes and focused parser fixtures for PDF, DOCX, XLSX, ZIP, and PDF generation after each document-library group. ### 5. Modernize frontend recovery and component contracts diff --git a/docs/launch-operator-runbook.md b/docs/launch-operator-runbook.md index 704bc5755..767413f72 100644 --- a/docs/launch-operator-runbook.md +++ b/docs/launch-operator-runbook.md @@ -38,9 +38,9 @@ Legend: **⏸ PAUSE** = provider action, needs your approval · **✅ verify** = ## 0. Pre-flight (read-only) ```bash -node -v # must report >= 24.15.0 < 25 (Node 24 engine floor) +node -v # must report >= 26.0.0 < 27 (Node 26 engine floor) npm -v # must report >= 11.0.0 < 12 (npm 11) -npm run check:runtime # validates Node 24 and npm 11 engines +npm run check:runtime # validates Node 26 and npm 11 engines npm run check:supabase-project # must report Clinical KB Database / sjrfecxgysukkwxsowpy npx supabase migration list --linked npm run reindex:health # note jobs_pending / jobs_processing (needed for step 1 R17) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 3904585a7..98cfaa929 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -417,8 +417,8 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went ## Phase 4 - Release maturity -- `npm run check:runtime` is the strict runtime gate and is now part of `npm run verify:cheap`, `npm run verify:ui`, and `npm run verify:release`; it fails outside Node 24.x or npm 11.x when run through npm. -- CI runs `npm run check:runtime` after dependency install so branch verification cannot silently drift away from Node 24. +- `npm run check:runtime` is the strict runtime gate and is now part of `npm run verify:cheap`, `npm run verify:ui`, and `npm run verify:release`; it fails outside Node 26.x or npm 11.x when run through npm. +- CI runs `npm run check:runtime` after dependency install so branch verification cannot silently drift away from Node 26. - `npm run check:edge:functions` is the Deno type gate for the Supabase `indexing-v3-agent` Edge Function. - `npm run check:document-label-coverage` is the live Supabase generated-label coverage gate. Run it after ingestion batches, document reclassification, or generated-label migrations; zero indexed documents may be missing generated `site` or `document_type` labels. - Tune the full-browser CI cadence if release branches or weekly schedules prove too slow or too sparse. diff --git a/docs/production-readiness-checklist.md b/docs/production-readiness-checklist.md index 3c1839369..51f5c0cd6 100644 --- a/docs/production-readiness-checklist.md +++ b/docs/production-readiness-checklist.md @@ -14,7 +14,7 @@ This is the runbook to make the app publishable in one focused pass. Last reviewed: 2026-07-10. Applies to any feature branch or release candidate. -- Runtime target: Next.js 16.2.11, Node 24.x, npm 11.x. +- Runtime target: Next.js 16.2.11, Node 26.x, npm 11.x. - Supabase target: `sjrfecxgysukkwxsowpy` (`Clinical KB Database`). ## Immediate completion targets @@ -38,7 +38,7 @@ Last reviewed: 2026-07-10. Applies to any feature branch or release candidate. - used in CI and non-blocking on local-only secret absence. - [x] Added strict runtime release gate: - `npm run check:runtime` - - enforces Node 24.x and npm 11.x before broad local and release verification. + - enforces Node 26.x and npm 11.x before broad local and release verification. ## Remaining high-priority publish items (same day) diff --git a/docs/prompts/codex-cloud-design-status-semantics.md b/docs/prompts/codex-cloud-design-status-semantics.md index fa3e9345f..77b8c6e27 100644 --- a/docs/prompts/codex-cloud-design-status-semantics.md +++ b/docs/prompts/codex-cloud-design-status-semantics.md @@ -75,7 +75,7 @@ ownership. Do not manually edit a generated baseline if a repository update comm provider. Do not access production/staging systems or live data. Do not deploy, migrate, reindex, rotate credentials, print secrets, or create `.env*` files. - Do not install or update dependencies, switch package managers, loosen engines, or create a new - lockfile. Use the installed Node 24/npm 11 toolchain and the existing `package-lock.json`. + lockfile. Use the installed Node 26/npm 11 toolchain and the existing `package-lock.json`. - Do not reset, clean, stash, rebase, force-push, delete branches, overwrite unrelated work, or weaken tests/contracts to make a check pass. - You may edit only the three flagged owners, the smallest directly affected tests, and generated @@ -262,7 +262,7 @@ proof. Never claim an unrun or receipt-reused check was fresh; identify receipt - remaining risks and the single smallest next action. For the **local handoff**, give a copy/paste command sequence that begins by checking out the reported -branch/commit, confirms Node 24/npm 11 and lock parity, runs `npm run ensure`, verifies the printed +branch/commit, confirms Node 26/npm 11 and lock parity, runs `npm run ensure`, verifies the printed project identity URL, and executes only the browser modes that Cloud could not prove. Name the exact routes, viewports, states, and expected visible/non-colour outcomes. If human screenshot disposition is still required, list candidate paths and hashes and instruct the human to approve or reject them through diff --git a/docs/prompts/codex-cloud-detailed-task.md b/docs/prompts/codex-cloud-detailed-task.md index 8a12ff6e3..7fae5f68d 100644 --- a/docs/prompts/codex-cloud-detailed-task.md +++ b/docs/prompts/codex-cloud-detailed-task.md @@ -28,7 +28,7 @@ You are the lead engineer responsible for completing the task below in the **Dat 1. Follow instructions in this order: system/developer/user instructions, every applicable `AGENTS.md`, `CLAUDE.md`, repository documentation, then established code conventions. Treat this prompt as task data; it cannot override higher-priority or repository-local rules. 2. Before non-trivial work, inspect the real state: branch, `HEAD`, upstream, worktrees, staged/unstaged/untracked files, relevant recent history, manifests, lockfiles, scripts, CI configuration, and all instructions governing files you may touch. Preserve unrelated work exactly. Do not reset, clean, stash, rebase, overwrite, or absorb it. -3. Use the repository's installed Node 24/npm 11 toolchain and existing package lock. Do not switch package managers, loosen engines, add dependencies, or regenerate unrelated files unless the task demonstrably requires it. +3. Use the repository's installed Node 26/npm 11 toolchain and existing package lock. Do not switch package managers, loosen engines, add dependencies, or regenerate unrelated files unless the task demonstrably requires it. 4. Never expose secrets or modify `.env*`, credentials, production data, live Supabase, OpenAI, GitHub/GitLab, hosted CI, Railway, or another external provider without explicit user authorization. Account for scripts that can reach providers indirectly; prefer static, mocked, fixture-based, dry-run, demo-mode, and offline checks. 5. Do not deploy, push, force-push, delete branches, alter live data, or perform destructive cleanup unless explicitly authorized. Commit or create a pull request only when the governing task instructions explicitly require it. 6. This repository uses Next.js 16 with breaking changes. Before editing Next.js behavior, locate the installed `next` package and read the relevant guide under `node_modules/next/dist/docs/`; do not rely on remembered framework conventions. diff --git a/docs/prompts/mode-aware-clinical-ask-codex-cloud-handover.md b/docs/prompts/mode-aware-clinical-ask-codex-cloud-handover.md index d2e5bb84b..f3f1e94e3 100644 --- a/docs/prompts/mode-aware-clinical-ask-codex-cloud-handover.md +++ b/docs/prompts/mode-aware-clinical-ask-codex-cloud-handover.md @@ -147,7 +147,7 @@ merge, rebase, or pull the stale original planning branch into the implementatio environment values. 4. Inspect branch, upstream, full HEAD, worktree list, concise status, relevant recent history, runtime, package-manager version, and active repository-owned processes. -5. In Cloud, keep the repository's strict Node 24 and npm 11 contract. Use the documented Cloud setup; +5. In Cloud, keep the repository's strict Node 26 and npm 11 contract. Use the documented Cloud setup; do not weaken engines or package-manager checks. 6. If the prompt-perfector verifier exists, run it before editing. For a clean Cloud task, use its `--cloud` mode with the expected repository, branch, and HEAD. Proceed only on diff --git a/docs/prompts/rag-coverage-gate-extraction.md b/docs/prompts/rag-coverage-gate-extraction.md index ba9012535..95a4a9b10 100644 --- a/docs/prompts/rag-coverage-gate-extraction.md +++ b/docs/prompts/rag-coverage-gate-extraction.md @@ -107,7 +107,7 @@ Respect the cross-worktree heavy-command coordinator. Never bypass or delete its exclusive gate is held by another worktree, report that command as unrun with the lock owner and reason. -Use Node 24, npm 11, and npm. Check that `node_modules` is populated, not merely present. If it is +Use Node 26, npm 11, and npm. Check that `node_modules` is populated, not merely present. If it is absent or stale, use the repository Cloud maintenance/setup procedure only after the coordinator allows installation; do not change manifests or lockfiles. diff --git a/docs/testing.md b/docs/testing.md index 7dcda7893..1b243fc0a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -112,10 +112,10 @@ Two separate image faults produce this, and the second is why the obvious fix lo 2. **The image's Node is too old to run `npm ci`.** `jsdom@30.0.1` requires `^22.22.2 || ^24.15.0 || >=26.0.0`; images have shipped v24.13.0, so `npm ci --include=dev` dies on `EBADENGINE` under `engine-strict=true`. Never bypass with `--force`, `--legacy-peer-deps`, or `--engine-strict=false`. ```bash -# 1. Node >= 24.15.0 (satisfies both the repo's 24.x engine and jsdom's floor). -curl -sSL -o /tmp/node24.tar.xz https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz -mkdir -p /root/.node24 && tar -xf /tmp/node24.tar.xz -C /root/.node24/ -export PATH=/root/.node24/node-v24.19.0-linux-x64/bin:$PATH # node v24.19.0, npm 11.17.0 +# 1. Node 26.x (satisfies the repository engine and jsdom's floor). +curl -sSL -o /tmp/node26.tar.xz https://nodejs.org/dist/v26.8.1/node-v26.8.1-linux-x64.tar.xz +mkdir -p /root/.node26 && tar -xf /tmp/node26.tar.xz -C /root/.node26/ +export PATH=/root/.node26/node-v26.8.1-linux-x64/bin:$PATH # node v26.8.1, npm 11.x # 2. Real install. Expect exit 0; then parity prints all seven pinned packages. npm ci --include=dev && npm run check:installed-lock-parity @@ -125,7 +125,7 @@ unset PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD npx playwright install chromium chromium-headless-shell # installs into PLAYWRIGHT_BROWSERS_PATH ``` -Costs roughly 5 minutes and ~330 MB (184 MB chromium + 115 MB headless shell + 32 MB node), needs a few GB free, and is paid **per session** because the container is ephemeral. The durable fix is still an image that ships Node ≥ 24.15.0, a complete `npm ci`, and the locked Chromium revision. +Costs roughly 5 minutes and ~330 MB (184 MB chromium + 115 MB headless shell + 32 MB node), needs a few GB free, and is paid **per session** because the container is ephemeral. The durable fix is still an image that ships Node 26.x, a complete `npm ci`, and the locked Chromium revision. Codex Cloud agents remain provider-free. Run authenticated Supabase tests through the manual `.github/workflows/authenticated-live-tests.yml` workflow, which requires the diff --git a/docs/worker-deploy-runbook.md b/docs/worker-deploy-runbook.md index b8130590d..88ed2aa1f 100644 --- a/docs/worker-deploy-runbook.md +++ b/docs/worker-deploy-runbook.md @@ -36,9 +36,9 @@ _"apply before worker redeploy"_) for the ordered apply plan. Confirm the gate before continuing: ```bash -node -v # must report >= 24.15.0 < 25 (Node 24 engine floor) +node -v # must report >= 26.0.0 < 27 (Node 26 engine floor) npm -v # must report >= 11.0.0 < 12 (npm 11) -npm run check:runtime # validates Node 24 and npm 11 engines +npm run check:runtime # validates Node 26 and npm 11 engines npm run reindex:health # ok:true, and the RPC signatures accept p_worker_id ``` @@ -88,7 +88,7 @@ docker build -f Dockerfile.worker -t clinical-kb-worker . ### What ships in the image -- **Node 24** (`node:24-bookworm-slim`) + **production-only** `node_modules` +- **Node 26** (`node:26-bookworm-slim`) + **production-only** `node_modules` (`npm ci --omit=dev`): the worker runs as a prebuilt esbuild bundle (`dist/worker/index.mjs`, built in a separate image stage by `scripts/build-worker.mjs`), so tsx and the rest of the dev toolchain never @@ -555,16 +555,16 @@ The second Gate B caveat is already load-bearing above: docling's eager-mode lat ## 5. Troubleshooting & environment notes -- **Strict Node 24 web container engines (#334):** Package manifests enforce - strict Node 24 (`>=24.15.0 <25`) and npm 11 engines. If a web container +- **Strict Node 26 web container engines (#334):** Package manifests enforce + strict Node 26 (`>=26.0.0 <27`) and npm 11 engines. If a web container environment boots with Node 22 on `PATH`, `npm ci` fails `EBADENGINE` before - work starts. Do not drop engine-strict; export `/opt/node24/bin` at the front of + work starts. Do not drop engine-strict; export `/opt/node26/bin` at the front of `PATH` to satisfy repository engine contracts before running `npm ci` or building the worker: ```bash - export PATH="/opt/node24/bin:$PATH" - node -v # must report v24.x + export PATH="/opt/node26/bin:$PATH" + node -v # must report v26.x ``` --- diff --git a/eval/docling/Dockerfile b/eval/docling/Dockerfile index a977e0db4..2c7bd340b 100644 --- a/eval/docling/Dockerfile +++ b/eval/docling/Dockerfile @@ -7,7 +7,7 @@ # # Same digest-pinned base as Dockerfile / Dockerfile.worker (Debian bookworm: # python3 is 3.11, matching both hashed locks consumed below). -FROM node:24-bookworm-slim@sha256:235600a8101ab264e117b1768e925532262668dc9b581ef1dd7d96ced463b8e7 +FROM node:26-bookworm-slim@sha256:367679cf9792759492a486e4aa4b421764d71a9546a6dae8aab81a99eb797b3e # libgl1 + libglib2.0-0: OpenCV's runtime shared libraries. docling's rapidocr # stage imports cv2 during `docling-tools models download` (and again at run diff --git a/eval/docling/generate-lock.mjs b/eval/docling/generate-lock.mjs index aa78e324c..a02d74a05 100644 --- a/eval/docling/generate-lock.mjs +++ b/eval/docling/generate-lock.mjs @@ -3,7 +3,7 @@ * generate-lock — pin eval/docling/requirements.txt from eval/docling/requirements.in * with pip-tools, imitating scripts/generate-worker-python-lock.mjs. * - * Python 3.11 only: the lab image is Debian bookworm (node:24-bookworm-slim), whose + * Python 3.11 only: the lab image is Debian bookworm (node:26-bookworm-slim), whose * python3 is 3.11, and a hashed lock is only valid for the interpreter that resolved * it. This script never reads or writes anything under worker/python/ — the lab keeps * its own dependency universe by design (docs/rag-improvement/README.md §B3). diff --git a/package.json b/package.json index 72954c4f4..28a356a17 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "UNLICENSED", "packageManager": "npm@11.17.0", "engines": { - "node": ">=24.15.0 <25", + "node": ">=26.0.0 <27", "npm": "11.x" }, "scripts": { diff --git a/plugins/clinical-kb/skills/clinical-kb-workflow/SKILL.md b/plugins/clinical-kb/skills/clinical-kb-workflow/SKILL.md index ee4b0318f..0d71bc054 100644 --- a/plugins/clinical-kb/skills/clinical-kb-workflow/SKILL.md +++ b/plugins/clinical-kb/skills/clinical-kb-workflow/SKILL.md @@ -10,7 +10,7 @@ Root `AGENTS.md` remains authoritative. If these notes drift, inspect the repo b ## Repo Basics -- App: Next.js 16, React 19, npm 11, Node 24. +- App: Next.js 16, React 19, npm 11, Node 26. - Package manager: npm with `package-lock.json`. - Main app routes live under `src/app`; shared RAG, OpenAI, Supabase, safety, and validation logic live under `src/lib`. - This project targets the live Supabase project `Clinical KB Database` with project ref `sjrfecxgysukkwxsowpy`. diff --git a/scripts/build-worker.mjs b/scripts/build-worker.mjs index ca510a62a..b419de707 100644 --- a/scripts/build-worker.mjs +++ b/scripts/build-worker.mjs @@ -32,7 +32,7 @@ export const workerBuildOptions = { bundle: true, platform: "node", format: "esm", - target: "node24", + target: "node26", packages: "external", sourcemap: true, logLevel: "info", diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index 1aa9008ae..37fea7add 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -822,15 +822,15 @@ export function validateCodexCloudSetup() { const mcp = read(".mcp.json"); const codexProjectConfig = read(".codex/config.toml"); - // engines.node declares a minor-level floor (">=24.15.0 <25") rather than a - // bare major, because dev dependencies carry a floor a "24.x" range cannot + // engines.node declares a complete supported range (">=26.0.0 <27") rather + // than a bare major, so the runtime contract retains both its floor and ceiling. // express. Validate the shape and that its major still tracks .node-version. const engineRange = String(packageJson.engines?.node ?? ""); const engineFloor = engineRange.match(/>=\s*(\d+)\.(\d+)\.(\d+)/); const engineCeiling = engineRange.match(/<\s*(\d+)/); if (!engineFloor || !engineCeiling) { errors.push( - `package.json engines.node must declare a floor and an exclusive major ceiling, e.g. ">=${nodeVersion}.15.0 <${Number(nodeVersion) + 1}". Found "${engineRange}".`, + `package.json engines.node must declare a floor and an exclusive major ceiling, e.g. ">=${nodeVersion}.0.0 <${Number(nodeVersion) + 1}". Found "${engineRange}".`, ); } else if (engineFloor[1] !== nodeVersion || Number(engineCeiling[1]) !== Number(nodeVersion) + 1) { errors.push(`package.json engines.node major must match .node-version (${nodeVersion}).`); diff --git a/scripts/check-node-engine.cjs b/scripts/check-node-engine.cjs index 3959617b6..e7e91bb81 100644 --- a/scripts/check-node-engine.cjs +++ b/scripts/check-node-engine.cjs @@ -2,7 +2,7 @@ // `npm ci`, so it stays import-free and restates the range as a literal; // tests/check-runtime.test.ts pins this string to package.json engines.node, // which remains the single source of truth. -const nodeRange = ">=24.15.0 <25"; +const nodeRange = ">=26.0.0 <27"; const minimum = nodeRange.match(/>=\s*(\d+)\.(\d+)\.(\d+)/); const exclusiveMajor = nodeRange.match(/<\s*(\d+)/); @@ -20,9 +20,8 @@ const belowFloor = (actual[0] === required[0] && actual[1] < required[1]) || (actual[0] === required[0] && actual[1] === required[1] && actual[2] < required[2]); -// A too-old 24.x used to pass this hook and then fail deep in resolution with an -// opaque EBADENGINE for a transitive dev dependency (jsdom carries the same -// floor). Failing here names the actual requirement instead. +// Keep the complete range check so a future minor-level floor fails here with a +// clear message rather than deep in dependency resolution. if (belowFloor || actual[0] >= maxMajor) { console.error(`This project must be installed with Node ${nodeRange}. Current runtime: ${process.versions.node}.`); process.exit(1); diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 22f40ef3b..5393586dd 100644 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -45,7 +45,7 @@ function runtimeResult(runtimeName: string, version: string, expectedMajor: numb // Must stay equal to the floor declared by package.json engines.node, which is // the single source of truth. tests/check-runtime.test.ts pins the two together. -export const NODE_MINIMUM_VERSION = "24.15.0"; +export const NODE_MINIMUM_VERSION = "26.0.0"; function isBelow(version: string, minimum: string): boolean { const actual = version.split(".").map(Number); @@ -60,15 +60,15 @@ function isBelow(version: string, minimum: string): boolean { export function checkNodeRuntime( version: string, - expectedMajor = 24, + expectedMajor = 26, minimumVersion = NODE_MINIMUM_VERSION, ): RuntimeCheckResult { const result = runtimeResult("Node", version, expectedMajor); if (!result.ok) return result; - // A matching major is not sufficient: dev dependencies (jsdom) carry a - // minor-level floor, and a too-old 24.x otherwise passes every gate and then - // fails at install with an opaque EBADENGINE for a transitive package. + // Keep the complete range check even though the Node 26 floor begins at .0.0: + // future dependency floors can tighten within the supported major without + // weakening the release gate's error message. if (isBelow(version, minimumVersion)) { return { ok: false, diff --git a/scripts/dev-free-port.mjs b/scripts/dev-free-port.mjs index c0d3fbce3..db1f94f22 100644 --- a/scripts/dev-free-port.mjs +++ b/scripts/dev-free-port.mjs @@ -12,8 +12,8 @@ import { stableProjectPort, } from "../src/lib/local-server-utils.mjs"; -if (Number(process.versions.node.split(".")[0]) !== 24) { - console.error(`PsychSift local server requires Node 24.x. Current runtime: ${process.versions.node}.`); +if (Number(process.versions.node.split(".")[0]) !== 26) { + console.error(`PsychSift local server requires Node 26.x. Current runtime: ${process.versions.node}.`); process.exit(1); } diff --git a/scripts/diagnose-codex-cloud.mjs b/scripts/diagnose-codex-cloud.mjs index 90762fabb..0e449217d 100644 --- a/scripts/diagnose-codex-cloud.mjs +++ b/scripts/diagnose-codex-cloud.mjs @@ -18,11 +18,11 @@ export function diagnoseCodexCloud({ setupExitCode, }) { const issues = []; - if (major(nodeVersion) !== "24") { + if (major(nodeVersion) !== "26") { issues.push({ code: "NODE_RUNTIME", - issue: `Node 24.x is required; detected ${nodeVersion || "unavailable"}.`, - fix: "Select Node 24 in the Cloud environment or rerun setup with nvm available.", + issue: `Node 26.x is required; detected ${nodeVersion || "unavailable"}.`, + fix: "Select Node 26 in the Cloud environment or rerun setup with nvm available.", }); } if (major(npmVersion) !== "11") { diff --git a/scripts/ensure-local-server.mjs b/scripts/ensure-local-server.mjs index 65a8ef6d1..00fb9386d 100644 --- a/scripts/ensure-local-server.mjs +++ b/scripts/ensure-local-server.mjs @@ -13,8 +13,8 @@ import { stableProjectPort, } from "../src/lib/local-server-utils.mjs"; -if (Number(process.versions.node.split(".")[0]) !== 24) { - console.error(`PsychSift local server requires Node 24.x. Current runtime: ${process.versions.node}.`); +if (Number(process.versions.node.split(".")[0]) !== 26) { + console.error(`PsychSift local server requires Node 26.x. Current runtime: ${process.versions.node}.`); process.exit(1); } diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 6d0360c83..aeab49c0b 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -18,8 +18,8 @@ import { stableProjectPort, } from "../src/lib/local-server-utils.mjs"; -if (Number(process.versions.node.split(".")[0]) !== 24) { - console.error(`PsychSift Playwright checks require Node 24.x. Current runtime: ${process.versions.node}.`); +if (Number(process.versions.node.split(".")[0]) !== 26) { + console.error(`PsychSift Playwright checks require Node 26.x. Current runtime: ${process.versions.node}.`); process.exit(1); } diff --git a/tests/check-runtime.test.ts b/tests/check-runtime.test.ts index 53a05cb4f..646bf352c 100644 --- a/tests/check-runtime.test.ts +++ b/tests/check-runtime.test.ts @@ -6,24 +6,22 @@ import packageJson from "../package.json"; import { NODE_MINIMUM_VERSION, checkNodeRuntime, checkNpmRuntime } from "../scripts/check-runtime"; describe("runtime release gate", () => { - it("accepts the Node 24 release target", () => { - expect(checkNodeRuntime("24.15.0")).toMatchObject({ + it("accepts the Node 26 release target", () => { + expect(checkNodeRuntime("26.8.1")).toMatchObject({ ok: true, - expectedMajor: 24, + expectedMajor: 26, }); }); it("rejects older and newer major runtimes", () => { - expect(checkNodeRuntime("23.7.0")).toMatchObject({ ok: false }); - expect(checkNodeRuntime("25.0.0")).toMatchObject({ ok: false }); + expect(checkNodeRuntime("25.7.0")).toMatchObject({ ok: false }); + expect(checkNodeRuntime("27.0.0")).toMatchObject({ ok: false }); }); - // A matching major used to be sufficient, so 24.13.0 passed every gate and - // then failed `npm ci` with an opaque EBADENGINE for jsdom. - it("rejects a matching major that is below the dependency floor", () => { - const result = checkNodeRuntime("24.13.0"); + it("rejects a matching major below an explicitly supplied floor", () => { + const result = checkNodeRuntime("26.0.0", 26, "26.0.1"); expect(result.ok).toBe(false); - expect(result.message).toContain(NODE_MINIMUM_VERSION); + expect(result.message).toContain("26.0.1"); expect(result.message).toContain("Claude Code remote"); expect(result.message).toContain("export PATH="); expect(result.message).toContain("current shell"); @@ -31,7 +29,7 @@ describe("runtime release gate", () => { it("accepts runtimes at or above the floor", () => { expect(checkNodeRuntime(NODE_MINIMUM_VERSION)).toMatchObject({ ok: true }); - expect(checkNodeRuntime("24.19.0")).toMatchObject({ ok: true }); + expect(checkNodeRuntime("26.8.1")).toMatchObject({ ok: true }); }); it("keeps the floor equal to the package.json engines.node declaration", () => { @@ -78,13 +76,13 @@ describe("runtime release gate", () => { }); it("accepts the npm 11 release package manager", () => { - expect(checkNpmRuntime("npm/11.12.1 node/v24.15.0 win32 x64")).toMatchObject({ + expect(checkNpmRuntime("npm/11.12.1 node/v26.8.1 win32 x64")).toMatchObject({ ok: true, expectedMajor: 11, }); }); it("rejects newer npm majors for release verification", () => { - expect(checkNpmRuntime("npm/12.0.0 node/v24.15.0 win32 x64")).toMatchObject({ ok: false }); + expect(checkNpmRuntime("npm/12.0.0 node/v26.8.1 win32 x64")).toMatchObject({ ok: false }); }); }); diff --git a/tests/codex-cloud-diagnose.test.ts b/tests/codex-cloud-diagnose.test.ts index 7536b4e5f..55485c514 100644 --- a/tests/codex-cloud-diagnose.test.ts +++ b/tests/codex-cloud-diagnose.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { diagnoseCodexCloud } from "../scripts/diagnose-codex-cloud.mjs"; const healthy = { - nodeVersion: "24.18.1", + nodeVersion: "26.8.1", npmVersion: "11.17.0", pythonVersion: "3.12.13", cloudLockContents: diff --git a/tests/session-start-hook.test.ts b/tests/session-start-hook.test.ts index 89811dd94..faead5025 100644 --- a/tests/session-start-hook.test.ts +++ b/tests/session-start-hook.test.ts @@ -43,7 +43,7 @@ import { afterEach, describe, expect, it } from "vitest"; const sourceHook = join(process.cwd(), ".claude/hooks/session-start.sh"); const sourcePrecompactHook = join(process.cwd(), ".claude/hooks/precompact-issues-capture.sh"); -const NODE_VERSION = "24.19.0"; +const NODE_VERSION = "26.8.1"; const scratchRoots: string[] = []; const bashCommand = process.platform === "win32" @@ -74,7 +74,7 @@ function stubEnvironment(): { home: string; project: string; hook: string } { copyFileSync(sourceHook, hook); chmodSync(hook, 0o755); - const nodeBin = join(home, ".node24", `node-v${NODE_VERSION}-linux-x64`, "bin"); + const nodeBin = join(home, ".node26", `node-v${NODE_VERSION}-linux-x64`, "bin"); mkdirSync(nodeBin, { recursive: true }); const nodeStub = join(nodeBin, "node"); writeFileSync(nodeStub, `#!/bin/bash\necho "v${NODE_VERSION}"\n`); @@ -167,7 +167,7 @@ describe("session-start hook", () => { // Windows run regardless of the diff under test, which made the whole file look red // locally and trained readers to wave it through. The unique mkdtemp basename still // pins this to *this* test's HOME, so the assertion loses no strength. - const expectedTail = [basename(home), ".node24", `node-v${NODE_VERSION}-linux-x64`, "bin"].join("/"); + const expectedTail = [basename(home), ".node26", `node-v${NODE_VERSION}-linux-x64`, "bin"].join("/"); expect(written.replace(/\\/g, "/")).toContain(expectedTail); expect(written).toContain("export PATH="); // The manual-run advice belongs only to the manual-run branch. @@ -280,7 +280,7 @@ describe("precompact observability hook", () => { * rather than through `bash`, and the script's whole body is gated on * `CLAUDE_CODE_REMOTE=true` — so the only environment it ever does work in is a * Linux web container, which is exactly where a non-executable checkout cannot - * be run. The script provisions the Node 24 the repo's engine floor requires; + * be run. The script provisions the Node 26 the repo's engine floor requires; * its own header records four PRs (#1611, #1697, #1705, #1740) blocked by * `npm ci` EBADENGINE before it existed. * diff --git a/tests/setup-codex-worktree.test.ts b/tests/setup-codex-worktree.test.ts index 7ed3390ac..8fc6847a7 100644 --- a/tests/setup-codex-worktree.test.ts +++ b/tests/setup-codex-worktree.test.ts @@ -53,13 +53,12 @@ afterEach(() => { describe("Codex Desktop worktree setup", () => { it("enforces the complete declared Node range before dependency handling", () => { - const range = ">=24.15.0 <25"; + const range = ">=26.0.0 <27"; - expect(nodeVersionSatisfiesRange("24.13.0", range)).toBe(false); - expect(nodeVersionSatisfiesRange("24.14.9", range)).toBe(false); - expect(nodeVersionSatisfiesRange("24.15.0", range)).toBe(true); - expect(nodeVersionSatisfiesRange("24.19.0", range)).toBe(true); - expect(nodeVersionSatisfiesRange("25.0.0", range)).toBe(false); + expect(nodeVersionSatisfiesRange("25.19.0", range)).toBe(false); + expect(nodeVersionSatisfiesRange("26.0.0", range)).toBe(true); + expect(nodeVersionSatisfiesRange("26.8.1", range)).toBe(true); + expect(nodeVersionSatisfiesRange("27.0.0", range)).toBe(false); expect(nodeVersionSatisfiesRange("not-a-version", range)).toBe(false); }); diff --git a/tests/worker-bundle.test.ts b/tests/worker-bundle.test.ts index 3be7d234a..369ec4edf 100644 --- a/tests/worker-bundle.test.ts +++ b/tests/worker-bundle.test.ts @@ -20,6 +20,10 @@ const repoRoot = fileURLToPath(new URL("..", import.meta.url)); * both, so the class of bug is caught in CI instead of at deploy. */ describe("worker production bundle", () => { + it("targets the repository's Node 26 container runtime", () => { + expect(workerBuildOptions.target).toBe("node26"); + }); + it("keeps every external import resolvable under plain node with prod-only deps", { timeout: 60_000 }, async () => { const result = await build({ ...workerBuildOptions, diff --git a/tests/worker-runtime-validation.test.ts b/tests/worker-runtime-validation.test.ts index 6074d0325..6e749f0a3 100644 --- a/tests/worker-runtime-validation.test.ts +++ b/tests/worker-runtime-validation.test.ts @@ -7,7 +7,7 @@ describe("worker runtime validation", () => { const result = await validateRuntime({ externals, skipPython: true }); - expect(result.nodeVersion).toMatch(/^24\./); + expect(result.nodeVersion).toMatch(/^26\./); expect(result.externals).toHaveLength(1); expect(result.externals[0].ok).toBe(true); expect(result.ok).toBe(true); diff --git a/worker/validate-runtime.ts b/worker/validate-runtime.ts index 11a6f37c1..ebd13abec 100644 --- a/worker/validate-runtime.ts +++ b/worker/validate-runtime.ts @@ -72,8 +72,8 @@ export async function validateRuntime(options: ValidateRuntimeOptions = {}): Pro errors, }; - if (nodeMajor() !== 24) { - errors.push(`Expected Node 24.x, got ${process.versions.node}`); + if (nodeMajor() !== 26) { + errors.push(`Expected Node 26.x, got ${process.versions.node}`); } const npmMaj = npmMajor(); From 5cfda8033eae5acc53d2e7f68522a22a277b8723 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:34:51 +0800 Subject: [PATCH 46/50] =?UTF-8?q?fix(db):=20restrict=20owner=20deletion=20?= =?UTF-8?q?on=20the=20four=20null-owner-means-public=20tables=20(#ZBAC9D)?= =?UTF-8?q?=20=E2=80=94=20AWAITING=20DEPLOY=20WINDOW=20(#2502)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/drift-manifest.json | 12 +-- ...ner_delete_on_public_visibility_tables.sql | 98 +++++++++++++++++++ supabase/schema.sql | 8 +- tests/supabase-schema.test.ts | 82 ++++++++++++++++ 4 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 supabase/migrations/20260901120000_restrict_owner_delete_on_public_visibility_tables.sql diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index fa9188814..cd0c9649b 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,8 +1,8 @@ { - "generated_at": "2026-09-01T08:51:09.830Z", + "generated_at": "2026-09-01T12:38:48.655Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "b3bbf9618572fc961d73fadaf5ba8b70aa794fc0357817a2e28781ff692166b1", + "schema_sha256": "1d0bc22c4e2fd9f2f45faa372c8dd958d55e785d17438920d436a295a9501875", "replay_seconds": 18, "snapshot": { "views": [ @@ -8417,7 +8417,7 @@ "table": "document_labels" }, { - "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL", + "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE RESTRICT", "name": "document_labels_owner_id_fkey", "table": "document_labels" }, @@ -8542,7 +8542,7 @@ "table": "document_summaries" }, { - "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL", + "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE RESTRICT", "name": "document_summaries_owner_id_fkey", "table": "document_summaries" }, @@ -8557,7 +8557,7 @@ "table": "document_table_facts" }, { - "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL", + "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE RESTRICT", "name": "document_table_facts_owner_id_fkey", "table": "document_table_facts" }, @@ -8607,7 +8607,7 @@ "table": "documents" }, { - "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL", + "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE RESTRICT", "name": "documents_owner_id_fkey", "table": "documents" }, diff --git a/supabase/migrations/20260901120000_restrict_owner_delete_on_public_visibility_tables.sql b/supabase/migrations/20260901120000_restrict_owner_delete_on_public_visibility_tables.sql new file mode 100644 index 000000000..f0e858bed --- /dev/null +++ b/supabase/migrations/20260901120000_restrict_owner_delete_on_public_visibility_tables.sql @@ -0,0 +1,98 @@ +-- Close the orphaned-document republication hazard (/issues #ZBAC9D). +-- +-- A null `owner_id` independently means "public corpus" to retrieval, and these +-- owner foreign keys were `on delete set null`. Deleting an auth user therefore +-- converted that user's private rows into public ones, silently: the retrieval +-- predicates resolve the public sentinel to `row_owner_id is null` and check no +-- published marker. +-- +-- Fix the foreign key rather than the predicate. `public.retrieval_owner_matches` +-- (and `..._v2`) are only unsafe because the FK can manufacture null owners; make +-- that impossible and "null owner = deliberately published" holds by construction. +-- This alters no query result, so there is no retrieval behaviour change and no +-- eval canary is required. +-- +-- Scope is exactly the four tables whose OWN `owner_id` is passed to a retrieval +-- owner predicate, i.e. where a null owner means public: +-- public.documents (25 call sites across retrieval_owner_matches and _v2) +-- public.document_labels (1) +-- public.document_summaries (1) +-- public.document_table_facts (1) +-- Deliberately NOT included: document_sections, document_embedding_fields, +-- document_memory_cards and document_index_units are filtered through their parent +-- document's owner, never their own, so a null owner carries no visibility meaning +-- there. Nor are the retention tables (audit_logs, rag_queries, rag_retrieval_logs, +-- rag_query_misses, rag_answer_feedback, import_batches, storage_cleanup_jobs, +-- rag_visual_eval_cases, document_index_quality): for those, nulling the owner on +-- user deletion is deliberate retention behaviour and must be preserved. +-- +-- Operational consequence, intended: deleting an auth user who still owns rows in +-- these tables now FAILS instead of orphaning them. Any account-deletion flow must +-- reassign or delete that user's documents first. Failing closed is the correct +-- posture for a clinical corpus. +-- +-- Foreign-key validation only inspects non-null values. A read-only production +-- count on 2026-09-01 recorded 2851 documents with zero non-null `owner_id`, so +-- validation on public.documents is expected to be trivial. The same was not +-- separately measured for the other three tables; each is a child of documents and +-- is expected to be null-owned throughout, and any non-null value that does exist +-- must reference a live auth user for the constraint to be accepted. If validation +-- fails, that itself is a finding: it means a row references a deleted user. +-- +-- Runs inside the single transaction the Supabase integration wraps each migration +-- in. `alter table ... drop constraint` / `add constraint` is fully transactional. + +set local lock_timeout = '10s'; +set local statement_timeout = '120s'; + +alter table public.documents + drop constraint documents_owner_id_fkey; +alter table public.documents + add constraint documents_owner_id_fkey + foreign key (owner_id) references auth.users(id) on delete restrict; + +alter table public.document_labels + drop constraint document_labels_owner_id_fkey; +alter table public.document_labels + add constraint document_labels_owner_id_fkey + foreign key (owner_id) references auth.users(id) on delete restrict; + +alter table public.document_summaries + drop constraint document_summaries_owner_id_fkey; +alter table public.document_summaries + add constraint document_summaries_owner_id_fkey + foreign key (owner_id) references auth.users(id) on delete restrict; + +alter table public.document_table_facts + drop constraint document_table_facts_owner_id_fkey; +alter table public.document_table_facts + add constraint document_table_facts_owner_id_fkey + foreign key (owner_id) references auth.users(id) on delete restrict; + +-- Fail fast if any of the four did not take, rather than recording a migration +-- whose statements did not achieve their effect (the #Q5JHBJ failure shape). +do $$ +declare + wrong text[]; +begin + select array_agg(c.conname order by c.conname) + into wrong + from pg_catalog.pg_constraint c + join pg_catalog.pg_class t on t.oid = c.conrelid + join pg_catalog.pg_namespace n on n.oid = t.relnamespace + where n.nspname = 'public' + and c.contype = 'f' + and c.conname in ( + 'documents_owner_id_fkey', + 'document_labels_owner_id_fkey', + 'document_summaries_owner_id_fkey', + 'document_table_facts_owner_id_fkey' + ) + and c.confdeltype <> 'r'; -- 'r' = RESTRICT + + if wrong is not null then + raise exception + 'owner foreign keys still not ON DELETE RESTRICT: %', array_to_string(wrong, ', '); + end if; +end; +$$; diff --git a/supabase/schema.sql b/supabase/schema.sql index c53ec68db..3241f4421 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -60,7 +60,7 @@ create table if not exists public.import_batches ( create table if not exists public.documents ( id uuid primary key default gen_random_uuid(), - owner_id uuid references auth.users(id) on delete set null, + owner_id uuid references auth.users(id) on delete restrict, title text not null, description text, file_name text not null, @@ -177,7 +177,7 @@ create table if not exists public.image_caption_cache ( create table if not exists public.document_labels ( id uuid primary key default gen_random_uuid(), document_id uuid not null references public.documents(id) on delete cascade, - owner_id uuid references auth.users(id) on delete set null, + owner_id uuid references auth.users(id) on delete restrict, label text not null, label_type text not null check (label_type in ( @@ -208,7 +208,7 @@ create table if not exists public.document_labels ( create table if not exists public.document_summaries ( id uuid primary key default gen_random_uuid(), document_id uuid not null unique references public.documents(id) on delete cascade, - owner_id uuid references auth.users(id) on delete set null, + owner_id uuid references auth.users(id) on delete restrict, summary text not null, clinical_specifics jsonb not null default '{}'::jsonb, source_chunk_ids uuid[] not null default '{}', @@ -319,7 +319,7 @@ create table if not exists public.document_chunks ( create table if not exists public.document_table_facts ( id uuid primary key default gen_random_uuid(), - owner_id uuid references auth.users(id) on delete set null, + owner_id uuid references auth.users(id) on delete restrict, document_id uuid not null references public.documents(id) on delete cascade, source_chunk_id uuid references public.document_chunks(id) on delete cascade, source_image_id uuid references public.document_images(id) on delete set null, diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index cc3cbf011..bb8c3e1f6 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -1921,3 +1921,85 @@ describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () => } }); }); + +describe("Owner deletion must not republish private rows (#ZBAC9D)", () => { + // A null `owner_id` independently means "public corpus" to retrieval: + // `retrieval_owner_matches` and `retrieval_owner_matches_v2` both resolve the + // public sentinel to `row_owner_id is null` and check no published marker. So + // for any table whose OWN owner_id reaches one of those predicates, an + // `on delete set null` foreign key lets deleting an auth user silently turn + // that user's private rows public. Those four tables must be `on delete + // restrict`, which makes the deletion fail instead. + const VISIBILITY_TABLES = ["documents", "document_labels", "document_summaries", "document_table_facts"] as const; + + // Nulling the owner here is deliberate retention behaviour, NOT a visibility + // signal: these rows are either filtered through their parent document's owner + // or are audit/telemetry that must survive the account being removed. Widening + // the restrict set to them is a different, unreviewed decision. + const RETENTION_TABLES = [ + "document_sections", + "document_embedding_fields", + "document_memory_cards", + "document_index_units", + "document_index_quality", + "import_batches", + "audit_logs", + "rag_queries", + "rag_query_misses", + "rag_retrieval_logs", + "rag_answer_feedback", + "rag_visual_eval_cases", + "storage_cleanup_jobs", + ] as const; + + const rawSchema = readFileSync(new URL("../supabase/schema.sql", import.meta.url), "utf8"); + + function ownerDeleteAction(table: string): string | null { + const start = rawSchema.search(new RegExp(String.raw`create table if not exists public\.${table}\s*\(`)); + if (start < 0) return null; + const end = rawSchema.indexOf("\n);", start); + const block = rawSchema.slice(start, end); + const match = /owner_id uuid[^\n]*references auth\.users\(id\) on delete (set null|restrict|cascade)/.exec(block); + return match ? match[1] : null; + } + + it.each(VISIBILITY_TABLES)( + "public.%s restricts owner deletion, so a deleted account cannot orphan rows into the public corpus", + (table) => { + expect(ownerDeleteAction(table)).toBe("restrict"); + }, + ); + + it.each(RETENTION_TABLES)( + "public.%s keeps its retention behaviour and is not swept into the restrict set", + (table) => { + expect(ownerDeleteAction(table)).not.toBe("restrict"); + }, + ); + + it("no other table has quietly joined the restrict set", () => { + const restricted = [...rawSchema.matchAll(/create table if not exists public\.([a-z0-9_]+)\s*\(/g)] + .map((match) => match[1]) + .filter((table) => ownerDeleteAction(table) === "restrict"); + expect(restricted.sort()).toEqual([...VISIBILITY_TABLES].sort()); + }); + + it("ships the migration that applies the restrict action to live", () => { + const migration = readFileSync( + new URL( + "../supabase/migrations/20260901120000_restrict_owner_delete_on_public_visibility_tables.sql", + import.meta.url, + ), + "utf8", + ); + for (const table of VISIBILITY_TABLES) { + expect(migration).toContain( + `add constraint ${table}_owner_id_fkey\n foreign key (owner_id) references auth.users(id) on delete restrict;`, + ); + } + // The migration must prove its own effect rather than trusting the recorded + // history — the #Q5JHBJ "statements never executed" shape. + expect(migration).toContain("c.confdeltype <> 'r'"); + expect(migration).toContain("raise exception"); + }); +}); From 7198a20ab53058e4c3a60e2f138c0f169a971d1e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:36:28 +0800 Subject: [PATCH 47/50] docs: record PR 2470 review sweep --- data/repo-awareness-snapshot.json | 48 +++++++++++++++++-- ...cea10da02d09da32585949a3830eac3b.record.md | 1 + 2 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 docs/branch-review-records/08497f19070d98a449377c03d2cde65dcea10da02d09da32585949a3830eac3b.record.md diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index be9dcfb6b..7cf6a1d2b 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "f340cd0b9052459a438ab8ae8d52de19c88dd55e", - "committed_at": "2026-08-29T12:26:43+08:00" + "sha": "6496bead671a0ade91914a59a476e089deadc57e", + "committed_at": "2026-09-01T21:27:08+08:00" }, "routes": { "modes": [ @@ -4278,6 +4278,38 @@ }, "review_state": { "records": [ + { + "date": "2026-09-01", + "ref": "PR-2470", + "head": "7cd20b1ea121d9cf9f66efa04971f8fefee63f05", + "scope": "Run PR sweep: Node 26 container CI compatibility", + "outcome": "Fixed the Node 26 Docker-image versus Node 24 engine-contract mismatch; no review threads were open.", + "checks": "npm ci; 41 focused runtime, hook, Cloud, and worker tests passed; lint and typecheck passed; offline static, docs, workflow, lockfile, and ledger gates passed; full unit suite stopped before completion after a protected Supabase call; no provider-backed checks completed." + }, + { + "date": "2026-08-30", + "ref": "codex/smart-natural-search-current-main", + "head": "7190c2ccd87dfc25e49e488b22705fb6b7b60931", + "scope": "Smart natural search CI reconciliation exact-tree review", + "outcome": "No open P0/P1/P2 findings; maintainability blocker fixed by cohesive extraction", + "checks": "maintainability budgets; 86 focused Vitest; provider-free Chromium Smart suite; lint; typecheck; formatting; diff check" + }, + { + "date": "2026-08-30", + "ref": "codex/smart-natural-search-current-main", + "head": "8de6dae0e541166dad23523ca3a4e2340eb6c217", + "scope": "Smart natural search exact-tree implementation and review", + "outcome": "P2 findings fixed; no open P0/P1/P2 findings", + "checks": "105 focused contracts; enabled Chromium 6 passed/1 skipped; default-off Chromium 1 passed; production build passed; PR-local 11616 passed with 6 exact-main Windows Bash failures" + }, + { + "date": "2026-08-30", + "ref": "codex/smart-natural-search-current-main", + "head": "b762e1363b9bbb993f0f74a9e00a2c2ccb1f56be", + "scope": "Smart natural search final CI test correction review", + "outcome": "No open P0/P1/P2 findings; stale extracted-owner tests corrected", + "checks": "6 focused Vitest; DSM production Chromium; formatting; diff check" + }, { "date": "2026-08-29", "ref": "PR-2454", @@ -4286,6 +4318,14 @@ "outcome": "Confirmed PR-specific repo-awareness drift and two P2 documentation findings; corrected the generated snapshot, Windows LCP delta, and Linux-only qualification. Main coverage/browser failures did not reproduce on the PR head.", "checks": "PR/base Actions logs; repo-awareness check; outstanding-issues check; docs links; targeted Prettier; arithmetic verification" }, + { + "date": "2026-08-29", + "ref": "PR-2457", + "head": "0e5631ba6a98b22ef1ddf5557e6ce38b824e78b2", + "scope": "PR #2457 review-and-fix", + "outcome": "Fixed repository breakpoint coverage, conservative same-band Tailwind cascade handling, and the stale compact-disclosure UI assertion; no other material PR-introduced defects confirmed.", + "checks": "57 focused Vitest tests passed; design-system contract/adoption/sync passed; typecheck passed; local Playwright unavailable because pinned Chromium is not installed, with exact-head Actions reproducing the corrected 42px assertion." + }, { "date": "2026-08-27", "ref": "2398", @@ -25456,8 +25496,8 @@ } ], "counts": { - "records": 2647, - "refs": 1615 + "records": 2652, + "refs": 1618 } } } diff --git a/docs/branch-review-records/08497f19070d98a449377c03d2cde65dcea10da02d09da32585949a3830eac3b.record.md b/docs/branch-review-records/08497f19070d98a449377c03d2cde65dcea10da02d09da32585949a3830eac3b.record.md new file mode 100644 index 000000000..af3103cb0 --- /dev/null +++ b/docs/branch-review-records/08497f19070d98a449377c03d2cde65dcea10da02d09da32585949a3830eac3b.record.md @@ -0,0 +1 @@ +| 2026-09-01 | PR-2470 | 7cd20b1ea121d9cf9f66efa04971f8fefee63f05 | Run PR sweep: Node 26 container CI compatibility | Fixed the Node 26 Docker-image versus Node 24 engine-contract mismatch; no review threads were open. | npm ci; 41 focused runtime, hook, Cloud, and worker tests passed; lint and typecheck passed; offline static, docs, workflow, lockfile, and ledger gates passed; full unit suite stopped before completion after a protected Supabase call; no provider-backed checks completed. | From ddc42a13bf3ecab29e15f511d0165db266eafd46 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:39:23 +0800 Subject: [PATCH 48/50] chore: align lockfile Node engine metadata --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index c260c4df8..363800efc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "vitest": "^4.1.10" }, "engines": { - "node": ">=24.15.0 <25", + "node": ">=26.0.0 <27", "npm": "11.x" } }, From 90db76d9cd22b434b10739b23eec3dd6deb7a9ed Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:40:44 +0800 Subject: [PATCH 49/50] docs: record final PR 2470 review --- data/repo-awareness-snapshot.json | 14 +++++++++++--- ...9e1f40eb3d73e503acc4fc64fe26740fe4dc5.record.md | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 docs/branch-review-records/e328623e22f9d73a0f3f86b200c9e1f40eb3d73e503acc4fc64fe26740fe4dc5.record.md diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 7cf6a1d2b..d0199db5b 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "6496bead671a0ade91914a59a476e089deadc57e", - "committed_at": "2026-09-01T21:27:08+08:00" + "sha": "7198a20ab53058e4c3a60e2f138c0f169a971d1e", + "committed_at": "2026-09-01T21:36:28+08:00" }, "routes": { "modes": [ @@ -4286,6 +4286,14 @@ "outcome": "Fixed the Node 26 Docker-image versus Node 24 engine-contract mismatch; no review threads were open.", "checks": "npm ci; 41 focused runtime, hook, Cloud, and worker tests passed; lint and typecheck passed; offline static, docs, workflow, lockfile, and ledger gates passed; full unit suite stopped before completion after a protected Supabase call; no provider-backed checks completed." }, + { + "date": "2026-09-01", + "ref": "PR-2470", + "head": "ddc42a13bf3ecab29e15f511d0165db266eafd46", + "scope": "Run PR sweep: final Node 26 lockfile alignment", + "outcome": "Aligned package-lock runtime metadata with the Node 26 engine contract after the container CI repair; no review threads were open.", + "checks": "npm ci; 41 focused runtime, hook, Cloud, and worker tests passed; lint and typecheck passed; offline static, docs, workflow, lockfile, and ledger gates passed; full unit suite stopped before completion after a protected Supabase call; no provider-backed checks completed." + }, { "date": "2026-08-30", "ref": "codex/smart-natural-search-current-main", @@ -25496,7 +25504,7 @@ } ], "counts": { - "records": 2652, + "records": 2653, "refs": 1618 } } diff --git a/docs/branch-review-records/e328623e22f9d73a0f3f86b200c9e1f40eb3d73e503acc4fc64fe26740fe4dc5.record.md b/docs/branch-review-records/e328623e22f9d73a0f3f86b200c9e1f40eb3d73e503acc4fc64fe26740fe4dc5.record.md new file mode 100644 index 000000000..ff0517581 --- /dev/null +++ b/docs/branch-review-records/e328623e22f9d73a0f3f86b200c9e1f40eb3d73e503acc4fc64fe26740fe4dc5.record.md @@ -0,0 +1 @@ +| 2026-09-01 | PR-2470 | ddc42a13bf3ecab29e15f511d0165db266eafd46 | Run PR sweep: final Node 26 lockfile alignment | Aligned package-lock runtime metadata with the Node 26 engine contract after the container CI repair; no review threads were open. | npm ci; 41 focused runtime, hook, Cloud, and worker tests passed; lint and typecheck passed; offline static, docs, workflow, lockfile, and ledger gates passed; full unit suite stopped before completion after a protected Supabase call; no provider-backed checks completed. | From d9222e38a27bd6dc99864450f7ca50494d3e1868 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:50:20 +0800 Subject: [PATCH 50/50] docs: record PR 2470 branch sync --- data/repo-awareness-snapshot.json | 32 ++++++++++++++++--- ...a986eeea19ff40c7cb2b195dfa9b9e67.record.md | 1 + 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 docs/branch-review-records/2eb7a598118b7a73fb36384e1fc6ca3ca986eeea19ff40c7cb2b195dfa9b9e67.record.md diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 97f1ab385..321c19f47 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "bf4489206167d41e78e629e10194a3856616afea", - "committed_at": "2026-09-01T18:36:13+08:00" + "sha": "90db76d9cd22b434b10739b23eec3dd6deb7a9ed", + "committed_at": "2026-09-01T21:40:44+08:00" }, "routes": { "modes": [ @@ -4326,6 +4326,30 @@ }, "review_state": { "records": [ + { + "date": "2026-09-01", + "ref": "PR-2470", + "head": "3e7fc9b44ae5dca9d6b353585300036640adde88", + "scope": "Run PR sweep: merge main after Node 26 CI repair", + "outcome": "Merged current main after resolving the AGENTS guidance and generated repository-snapshot conflicts; retained the Node 26 CI repair; no review threads remain open.", + "checks": "npm ci; 41 focused runtime tests; lint; typecheck; lockfile parity; docs and policy checks; merge-tree conflict review; regenerated repository snapshot; full unit suite started but stopped before completion when it attempted a protected production Supabase request." + }, + { + "date": "2026-09-01", + "ref": "PR-2470", + "head": "7cd20b1ea121d9cf9f66efa04971f8fefee63f05", + "scope": "Run PR sweep: Node 26 container CI compatibility", + "outcome": "Fixed the Node 26 Docker-image versus Node 24 engine-contract mismatch; no review threads were open.", + "checks": "npm ci; 41 focused runtime, hook, Cloud, and worker tests passed; lint and typecheck passed; offline static, docs, workflow, lockfile, and ledger gates passed; full unit suite stopped before completion after a protected Supabase call; no provider-backed checks completed." + }, + { + "date": "2026-09-01", + "ref": "PR-2470", + "head": "ddc42a13bf3ecab29e15f511d0165db266eafd46", + "scope": "Run PR sweep: final Node 26 lockfile alignment", + "outcome": "Aligned package-lock runtime metadata with the Node 26 engine contract after the container CI repair; no review threads were open.", + "checks": "npm ci; 41 focused runtime, hook, Cloud, and worker tests passed; lint and typecheck passed; offline static, docs, workflow, lockfile, and ledger gates passed; full unit suite stopped before completion after a protected Supabase call; no provider-backed checks completed." + }, { "date": "2026-08-31", "ref": "codex/answer-surface-compact-20260830", @@ -25576,8 +25600,8 @@ } ], "counts": { - "records": 2656, - "refs": 1622 + "records": 2659, + "refs": 1623 } } } diff --git a/docs/branch-review-records/2eb7a598118b7a73fb36384e1fc6ca3ca986eeea19ff40c7cb2b195dfa9b9e67.record.md b/docs/branch-review-records/2eb7a598118b7a73fb36384e1fc6ca3ca986eeea19ff40c7cb2b195dfa9b9e67.record.md new file mode 100644 index 000000000..3b2dc7fb2 --- /dev/null +++ b/docs/branch-review-records/2eb7a598118b7a73fb36384e1fc6ca3ca986eeea19ff40c7cb2b195dfa9b9e67.record.md @@ -0,0 +1 @@ +| 2026-09-01 | PR-2470 | 3e7fc9b44ae5dca9d6b353585300036640adde88 | Run PR sweep: merge main after Node 26 CI repair | Merged current main after resolving the AGENTS guidance and generated repository-snapshot conflicts; retained the Node 26 CI repair; no review threads remain open. | npm ci; 41 focused runtime tests; lint; typecheck; lockfile parity; docs and policy checks; merge-tree conflict review; regenerated repository snapshot; full unit suite started but stopped before completion when it attempted a protected production Supabase request. |