diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh
index eb01bb594f..53abbcb719 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/issues/SKILL.md b/.claude/skills/issues/SKILL.md
index f088ee03bc..53cb6548a0 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/.claude/skills/run-pr/SKILL.md b/.claude/skills/run-pr/SKILL.md
index f2cd72a13d..bef7ab4e8c 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/.design-sync/config.json b/.design-sync/config.json
index 790764d543..6a905c997a 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\"; onOpenSource?: ((sourceId: string, locator?: string) => void) | ((sourceId: string, locator?: string) => void); provenance?: AnswerFooterProps; query?: string; state: { kind: \"ready\"; sourceCount: number; } | DegradedAnswerState; support: AnswerSupportStrength; verification: VerificationNoticeProps;",
+ "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/.github/workflows/claude.yml b/.github/workflows/claude.yml
index 79e32aa769..cc85e8426d 100644
--- a/.github/workflows/claude.yml
+++ b/.github/workflows/claude.yml
@@ -64,7 +64,7 @@ jobs:
fetch-depth: 1
- name: Run Claude Code
if: ${{ env.CLAUDE_CODE_OAUTH_TOKEN != '' }}
- uses: anthropics/claude-code-action@dcb57747bfceeaa1fa72638cae52295d1d853d4a # v1.0.199
+ uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1.0.210
with:
# The action's default trigger phrase is "@claude"; it reads the
# triggering comment/review and acts on the current PR head.
diff --git a/.github/workflows/codex-run-pr-operator.yml b/.github/workflows/codex-run-pr-operator.yml
index 7e01787e5b..17d874e324 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 a1a07924e7..81386855be 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 a45fd52cc5..6f4247a625 100644
--- a/.node-version
+++ b/.node-version
@@ -1 +1 @@
-24
+26
diff --git a/.nvmrc b/.nvmrc
index a45fd52cc5..6f4247a625 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-24
+26
diff --git a/AGENTS.md b/AGENTS.md
index 03b80a3029..b46982f787 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
@@ -100,66 +100,11 @@ For Windows Codex Desktop worktree bootstrap and dry-run instructions, see [`doc
-
-
# Reasoning effort calibration
-**Repository baseline.** Use `gpt-5.6-sol` with `high` reasoning effort unless the user explicitly
-chooses another supported model or effort. `.codex/config.toml` records this default for trusted
-Codex clients that honor repository configuration; a task-level selection can override it.
-
-**Cloud xhigh gate.** A running Cloud task cannot raise its own reasoning effort. Before substantive
-inspection, planning, tool use, or edits, classify the request against the table and the risk rules
-below. If `xhigh` is required and the prompt does not contain the exact marker `[xhigh-confirmed]`,
-stop and ask the user to select `xhigh` in the Cloud reasoning control, then resubmit the same request
-with `[xhigh-confirmed]`. Do not begin the work at `high`, and do not claim the runtime changed. When
-the marker is present, treat it as the user's confirmation that `xhigh` was selected and proceed.
-Requests classified as `high` or lower proceed without this gate.
-
-Reasoning effort is a budget in the same way verification is a budget, and it is misspent the same
-way — by defaulting to the maximum instead of matching the spend to the risk. **Scale effort to how
-expensive the mistake is to undo (irreversibility × branching factor), never to the phase label.**
-"Plan high, build lower" is a good default, not a rule; it is wrong often enough that it must be
-chosen deliberately rather than assumed.
-
-**Why it is a good default.** Planning errors compound and implementation errors stay local: a wrong
-approach throws away the build, a wrong identifier is one edit. A plan is also a few thousand output
-tokens against a build's many long turns, so effort is cheapest exactly where it has the most leverage.
-
-**The mechanism that makes it work — do not skip this part.** A lower-effort build only succeeds
-against a plan concrete enough to execute: named files, named symbols, ordered steps, and the gate
-that will prove it. Downgrading the build against a vague plan does not save effort, it relocates the
-thinking into the expensive phase. If the plan cannot name those things, the build is not eligible for
-the downgrade.
-
-| Situation | Plan | Build |
-| ---------------------------------------------------------------------------------------------- | ----------- | ----------- |
-| Architecture, Supabase migrations/RLS, RAG ranking surfaces, auth/privacy, ingestion contracts | xhigh | high |
-| Ordinary feature or UI work with a clear shape | high | medium–high |
-| Mechanical and fully specified — ledger append, docs edit, rename (not dependency maintenance) | low or skip | medium |
-| Debugging an unknown failure | low | high |
-
-**Where the default inverts and the build needs more than the plan.** These are plan-light and
-execution-heavy; treating them as plan-heavy spends the budget in the wrong place:
-
-- **Debugging.** The plan is "find why X fails." The real reasoning is hypothesis-forming over local
- runtime, logs, and repro state during the build — hosted providers still need explicit confirmation.
-- **Constraint-dense implementation.** A one-sentence plan whose edit must simultaneously satisfy
- button wiring, design tokens, the one-composer rule, unlayered CSS, and the tap-target and
- phone-chrome contracts. Holding all of it at once is the hard part, not deciding what to do.
-- **Areas where training data is stale.** Next 16 is the standing case. Effort does not repair a wrong
- prior — reading `node_modules/next/dist/docs/` does. Raising effort instead of reading is itself the
- failure mode.
-
-**Constrain xhigh planning output, not just its effort.** Extra-high planning over-produces:
-alternatives, contingency branches, and surveys that are never used, paid for twice — once generating
-and once reading. Ask for the chosen approach, the files, and the gate, not a survey.
-
-**State the split before non-trivial planning work.** One line before starting: plan effort, build
-effort, and the risk that justifies them. It is cheap, it makes a wrong allocation visible while it is
-still free to change, and it stops the blanket default from being applied silently.
-
-
+For the Codex reasoning-effort baseline, the Cloud `xhigh` confirmation gate, and the
+plan-effort/build-effort table, see
+[`docs/agents/codex-reasoning-effort.md`](docs/agents/codex-reasoning-effort.md).
@@ -350,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%.
@@ -886,105 +836,12 @@ Run the matching planner command in `docs/productivity-workflows.md` without sid
## Codex GitHub review behavior
-These instructions apply to Codex GitHub pull request reviews and Codex tasks started from PR comments.
-
-- Keep automatic reviews focused and cost-conscious.
-- Prioritize high-confidence findings that affect correctness, security, privacy, data loss, auth/permissions, migrations, API contracts, production reliability, clinical behavior, source governance, or user-facing behavior.
-- Do not comment on formatting, naming, style, minor cleanup, or speculative refactors unless they create a real bug or maintainability risk.
-- Prefer fewer, stronger findings over exhaustive low-value review comments.
-- An automatic review may emit at most three inline findings total. Use inline comments only for P0/P1 issues; put non-blocking P2 context in one summary and omit P3 feedback.
-- A finding must cite concrete changed code and explain the failure mode.
-- Do not suggest broad rewrites during review. Recommend the smallest change that resolves the issue.
-- Do not propose or start fixes unless explicitly asked with an `@codex fix...` or `@codex resolve...` comment, or when the repository's Codex auto-resolve workflow posts that command.
-- Treat automatic review as single-pass per pull request. Do not re-review a later head, repeat a prior finding, or create another review during an auto-resolve task unless a human explicitly requests a fresh review.
-
-### Severity calibration
-
-- P0: active security exposure, data loss/corruption already possible, severe production outage risk, credential leakage, or a critical issue that must be fixed immediately.
-- P1: security vulnerability, auth bypass, data exposure/loss, destructive migration risk, production-breaking regression, public API contract break, severe clinical/user-facing bug, or missing validation with realistic exploit/failure impact.
-- P2: important correctness bug, missing behavior test for meaningful changed behavior, edge case likely to affect users, reliability issue, unsafe assumption, or maintainability issue that will likely cause defects.
-- P3: style, naming, formatting, small cleanup, speculative improvements, or optional refactors. Avoid raising these in automatic reviews unless explicitly requested.
-
-For GitHub automatic reviews, focus mainly on P1-level findings. If a P2 issue is important enough to block the PR, explain why it should be treated as P1.
-
-### PR risk detection
-
-When reviewing, identify whether the PR touches any high-risk area:
-
-- authentication or authorization
-- user data, privacy, or private document access
-- database schema, migrations, RLS, SECURITY DEFINER functions, or Supabase privileges
-- clinical answer generation, source governance, retrieval/ranking, ingestion, or document access
-- payment, billing, subscriptions, or quotas
-- public API contracts
-- production configuration or deployment behavior
-- background jobs, scheduled tasks, workers, or queue processing
-- file upload/download or generated document access
-- AI/API provider calls, paid external services, or credential-dependent workflows
-
-If a high-risk area is touched, review more carefully for regressions, missing tests, rollback/safety notes, and conservative failure behavior.
-
-### Cost and usage control
-
-Avoid broad repeated review passes. Do not request exhaustive review behavior unless the PR touches security, auth, data loss, migrations, billing, production reliability, clinical output, source governance, or private document access. Prefer targeted validation and targeted review comments. A new commit from the automatic repair task is not permission for another automatic review.
-
-### Fix behavior
-
-When explicitly asked to fix or resolve review findings:
-
-- Always fix P0 and P1 findings using the best minimal fix.
-- For P2 and lower-severity findings, decide whether the issue is worth fixing automatically.
-- Fix a P2 or lower finding only when the fix is clear, scoped, low-risk, and testable.
-- Do not automatically fix a P2 or lower finding when it requires broad refactoring, product judgment, dependency changes, credentials, paid/external APIs, large design decisions, or risky behavior changes.
-- If a P2 or lower finding is not worth fixing automatically, comment with the reason and the recommended human decision, then resolve the review conversation when supported.
-- Preserve unrelated work and avoid opportunistic refactors.
-- Do not add dependencies unless the issue cannot reasonably be fixed without one.
-- Do not change secrets, credentials, environment configuration, billing settings, deployment settings, or external service setup unless explicitly requested.
-- Do not use external APIs, paid services, credentials, secrets, live Supabase projects, or OpenAI provider calls unless explicitly authorized.
-- If a finding is ambiguous, unsafe to fix automatically, or requires a large rewrite, stop and explain the decision instead of guessing.
-- Add or update the smallest relevant test when the issue affects behavior.
-- Run the narrowest relevant validation for the touched surface before broader suites.
-- Summarize fixed issues, changed files, validation run, and any remaining human decisions.
-
-### Review comment lifecycle
-
-- Treat closing review conversations as part of the task when asked to fix or resolve comments.
-- After fixing a P0 or P1 finding, reply with the fix summary and resolve the review conversation when supported by GitHub permissions/tooling.
-- After fixing an approved P2 or lower finding, reply with the fix summary and resolve the review conversation when supported.
-- After deciding not to fix a P2 or lower finding, reply with the reason, note whether it is deferred or not actionable, and resolve the review conversation when supported.
-- For every fixed or fully dispositioned thread, start the thread reply with ``. On the next line, use `` for a code fix or `` for a no-code disposition. The workflow closes the thread only when exactly one result is declared and a reported fixed commit is the pull request head.
-- Do not use the marker when human input or new authorization is required; explain the blocker and leave that thread open.
-- Do not leave a review conversation open after it has been fixed or fully dispositioned. If direct resolution is unavailable, the marker reply is the required fallback and the workflow performs the closure.
-
-### Automatic resolve trigger
-
-Automatic Codex review is review-only by default. This repository includes `.github/workflows/codex-autofix-review-comments.yml`, which requests the resolve task automatically after Codex submits a completed PR review that raised findings and the pull request passes the repository's risk/complexity router.
-
-- The auto-resolve request must fire only from a Codex-authored `pull_request_review` **submitted** event on an open pull request — never from the first inline comment mid-review. This guarantees the request is posted only after a code review completes; without a review there are no findings and the request is pointless.
-- The request job must skip reviews with no actionable findings: skip `approved`/`dismissed` reviews, and skip when the submitted review carries zero inline comments.
-- Route automatic repair only when at least one changed path is high-risk, when the pull request changes at least 10 non-test source files or 300 non-test source lines, or when the `codex-review` label explicitly opts in. Treat `skip-codex-review` as an unconditional opt-out that wins if both labels are present.
-- **Clinical-decision surfaces are never routed to automatic repair**, whatever the finding's severity and whatever the routing rule above would otherwise say. The held paths are `data/**` (except `data/outstanding-issues-snapshot.json`), `src/data/**`, `src/lib/mha-act-sections.ts`, `src/lib/form-catalog.ts`, `src/lib/form-ranker.ts`, `src/components/forms/**`, `src/lib/rag/**`, and the named ranking/answer surfaces `clinical-search`, `retrieval-selection`, `released-search-order`, `ranking-config`, `answer-ranking`, `answer-verification`. The hold is evaluated before routing and has **no override**: the `codex-review` opt-in label does not release it, and a diff that also touches tests or generated files is still held. Codex still reviews these pull requests and its findings still post as inline comments — only the unattended write is withheld, so a human decides.
-
- Why this exists: a review finding can be sound as a code observation and wrong as an action. On PR #2314 a P1 finding contradicted the owner's explicit decision to display drafted Mental Health Act summaries behind an awaiting-review label; the automatic pass applied it, inverting the render gate and collapsing the Act-sections card from 54 forms to 1, and additionally hardcoding every summary as reviewed. The rationale for the owner's decision was written down in `docs/wiring-conventions.md` and nothing consulted it. "Always fix P0 and P1 findings" therefore stops at these paths: on a clinical surface a bot's severity label is not authority to overwrite a human decision. Enforced by `scripts/check-codex-autofix-workflow.mjs` and `tests/codex-autofix-workflow.test.ts`; do not weaken either to let a specific pull request through.
-
-- High-risk paths include migrations/RLS, application API routes, auth/permissions/privacy/security, clinical/RAG/retrieval/search/source/document behavior, provider or production configuration, dependencies, and CI/release workflows. Do not route docs-only, test-only, generated-only, or small low-risk UI/copy changes unless explicitly opted in.
-- Read changed-file metadata through the GitHub API only; never check out or execute pull-request code in the routing job. Record the selected route in a hidden `codex-autoresolve-route` marker for auditability.
-- Match the trusted Codex connector bot by exact login and bot type; do not use substring login checks.
-- Keep per-pull-request concurrency on the authorized job, not the whole workflow, so unrelated events cannot displace a pending Codex request.
-- Pin the supported Node 24-based `actions/github-script` release to its reviewed immutable commit SHA.
-- Post the `@codex` resolve request with a real (non-bot) user identity — a fine-grained PAT held in the `CODEX_TRIGGER_TOKEN` secret. The Codex connector ignores commands authored by `github-actions[bot]`, so a bot-authored request is silently dropped. The token needs `pull-requests: write` (issue-comment) access and no more.
-- The workflow must treat unmarked review-thread replies as inert. A trusted Codex reply beginning with `` may only resolve the exact containing thread, and a non-reply Codex review comment must never be turned into a new repair request.
-- The workflow must ask Codex to resolve only existing actionable Codex review findings for the triggering pull request and current head using these repository instructions; the resolve task must not perform a new review or create new findings. It must name the exact repository and PR head branch, require fixes to be published there through the authenticated GitHub connector, forbid detached `work` branches and stacked pull requests, and treat a local-only commit as a visible failure.
-- The workflow may request one automatic repair pass per pull request lifetime. Later heads require an explicit human request.
-- Only trust a pull-request deduplication marker when it was posted by the trigger-token account (the same identity that posts the request), resolved at runtime rather than hard-coded.
-- Permission failures while reading or creating pull-request comments must fail the workflow visibly, not return a successful soft-skip.
-- The workflow must not run Codex directly with API credentials.
-- P0 and P1 findings should always be fixed.
-- P2 and lower findings should be fixed only when clear, scoped, low-risk, and testable; otherwise explain the decision and resolve or mark ready for human resolution.
-
-### Primary PR command
-
-`@codex resolve actionable Codex review findings for this pull request and current head using the repository instructions. This is the pull request's single automatic repair pass: do not perform a fresh review, create new standalone findings, or request another review. Work only the existing unresolved Codex threads on the current head. The workflow will provide the only allowed repository, pull-request head branch, and starting commit. Publish every approved fix to that exact head branch through the authenticated GitHub connector; never use a detached or synthetic work branch and never create a stacked pull request. Verify the pull-request head contains the pushed commit before reporting success. Always fix P0 and P1 findings. For P2 and lower findings, fix only clear, scoped, low-risk issues; otherwise disposition them with a concise reason. For a fixed thread, reply with followed by . For a no-code disposition, use followed by . A local-only commit is not a fix. If publication or verification fails, use neither result marker, do not claim success, and leave the thread open with the blocker. Finish only after every actionable thread is fixed or dispositioned and closed, or explicitly left open for a human decision. Do not update the branch from main, address unrelated reviews, broaden scope, or create more than one scoped fix commit. Do not use external APIs, paid services, credentials, dependency changes, or broad refactors unless explicitly authorized. Add targeted tests where behavior changes and run the narrowest relevant validation.`
+For Codex's automated GitHub pull request review and auto-resolve behavior — severity
+calibration, PR risk detection, cost controls, the review comment lifecycle, the automatic
+resolve trigger, and the primary PR command — see
+[`docs/agents/codex-github-review.md`](docs/agents/codex-github-review.md). That file is the
+exact text `scripts/check-codex-autofix-workflow.mjs` enforces against the live workflow; do not
+let a copy in this file drift from it.
## Codex Cloud environment
@@ -1001,9 +858,5 @@ Codex Cloud uses an isolated Linux container and does not inherit desktop creden
## Cursor Cloud specific instructions (not Codex Cloud)
-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`).
-- 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).
+For Cursor Cloud agent setup, live-vs-demo mode detection, verification commands, and GitHub
+connector guidance, see [`docs/agents/cursor-cloud.md`](docs/agents/cursor-cloud.md).
diff --git a/Dockerfile b/Dockerfile
index a78519ab98..cd7b5551a0 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.
#
@@ -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 52f0644a60..bd6d8f1ed2 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).
@@ -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
diff --git a/README.md b/README.md
index 94a82998c7..9b9f6abb1b 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/bundle-budget.json b/bundle-budget.json
index a377012dbe..3cc5044d36 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/calculators/evidence.json b/data/calculators/evidence.json
new file mode 100644
index 0000000000..fdf9881bb2
--- /dev/null
+++ b/data/calculators/evidence.json
@@ -0,0 +1,135 @@
+{
+ "sources": [
+ {
+ "id": "source:governance",
+ "issuer": "PsychSift",
+ "title": "Calculator clinical-safety governance record",
+ "type": "internal_governance_record",
+ "version": "2026-09-01",
+ "url": "https://github.com/BigSimmo/Database/blob/main/docs/superpowers/specs/2026-09-01-calculators-clinical-safety.md",
+ "jurisdiction": "Australia",
+ "status": "reviewed",
+ "claimsSupported": [
+ "claim:phq9:interpretation",
+ "claim:phq9:safety-flag",
+ "claim:gad7:interpretation",
+ "claim:k10:interpretation",
+ "claim:cage:interpretation",
+ "claim:auditc:interpretation",
+ "claim:mdq:interpretation",
+ "claim:sadpersons:interpretation",
+ "claim:ybocs:interpretation"
+ ],
+ "limitations": ["Records the release decision and does not replace an instrument source."]
+ },
+ {
+ "id": "source:phq9",
+ "issuer": "Kroenke, Spitzer & Williams",
+ "title": "The PHQ-9: Validity of a Brief Depression Severity Measure",
+ "type": "journal_article",
+ "version": "2001 validation study",
+ "url": "https://pmc.ncbi.nlm.nih.gov/articles/PMC1495268/",
+ "jurisdiction": "International",
+ "status": "reviewed",
+ "claimsSupported": ["claim:phq9:interpretation"],
+ "limitations": [
+ "A completed score supports assessment and monitoring; it is not a diagnosis or management decision."
+ ]
+ },
+ {
+ "id": "source:gad7",
+ "issuer": "Spitzer et al.",
+ "title": "A Brief Measure for Assessing Generalized Anxiety Disorder",
+ "type": "journal_article",
+ "version": "2006 validation study",
+ "url": "https://pubmed.ncbi.nlm.nih.gov/16717171/",
+ "jurisdiction": "International",
+ "status": "reviewed",
+ "claimsSupported": ["claim:gad7:interpretation"],
+ "limitations": ["A completed score supports anxiety assessment and does not establish a diagnosis."]
+ },
+ {
+ "id": "source:k10",
+ "issuer": "Australian Bureau of Statistics",
+ "title": "Kessler Psychological Distress Scale (K10)",
+ "type": "government_information_paper",
+ "version": "2003 information paper",
+ "url": "https://www.abs.gov.au/ausstats/abs%40.nsf/papersbyReleaseDate/4D5BD324FE8B415FCA2579D500161D57",
+ "jurisdiction": "Australia",
+ "status": "reviewed",
+ "claimsSupported": ["claim:k10:interpretation"],
+ "limitations": ["The K10 measures psychological distress and is not a diagnostic category."]
+ },
+ {
+ "id": "source:cage",
+ "issuer": "Ewing",
+ "title": "Detecting alcoholism: The CAGE questionnaire",
+ "type": "journal_article",
+ "version": "1984 original article",
+ "url": "https://pubmed.ncbi.nlm.nih.gov/6471323/",
+ "jurisdiction": "International",
+ "status": "reviewed",
+ "claimsSupported": ["claim:cage:interpretation"],
+ "limitations": ["CAGE is a lifetime problem-drinking screen and does not assess current use or withdrawal."]
+ },
+ {
+ "id": "source:auditc",
+ "issuer": "Australian Government Department of Health",
+ "title": "Australian standard drinks guide",
+ "type": "government_web_guidance",
+ "version": "Current web guidance",
+ "url": "https://www.health.gov.au/topics/alcohol/about-alcohol/standard-drinks-guide",
+ "jurisdiction": "Australia",
+ "status": "reviewed",
+ "claimsSupported": ["claim:auditc:interpretation"],
+ "limitations": ["The standard-drink reference does not establish a diagnosis or management decision."]
+ },
+ {
+ "id": "source:mdq",
+ "issuer": "Hirschfeld et al.",
+ "title": "Development and validation of a screening instrument for bipolar spectrum disorder",
+ "type": "journal_article",
+ "version": "2000 validation study",
+ "url": "https://pubmed.ncbi.nlm.nih.gov/11058490/",
+ "jurisdiction": "International",
+ "status": "permission_review_required",
+ "claimsSupported": ["claim:mdq:interpretation"],
+ "limitations": ["Quarantined pending approval of digital-use rights and completion semantics."]
+ },
+ {
+ "id": "source:sadpersons",
+ "issuer": "Patterson et al.",
+ "title": "Evaluation of suicidal patients: the SAD PERSONS scale",
+ "type": "journal_article",
+ "version": "1983 original article",
+ "url": "https://pubmed.ncbi.nlm.nih.gov/6867245/",
+ "jurisdiction": "International",
+ "status": "not_for_active_use",
+ "claimsSupported": ["claim:sadpersons:interpretation"],
+ "limitations": ["Not released for active score-based clinical decision support."]
+ },
+ {
+ "id": "source:ybocs",
+ "issuer": "Goodman et al.",
+ "title": "The Yale-Brown Obsessive Compulsive Scale. I. Development, Use, and Reliability",
+ "type": "journal_article",
+ "version": "1989 original article",
+ "url": "https://pubmed.ncbi.nlm.nih.gov/2684084/",
+ "jurisdiction": "International",
+ "status": "permission_review_required",
+ "claimsSupported": ["claim:ybocs:interpretation"],
+ "limitations": ["Quarantined because the current generic anchors are not a verified Y-BOCS implementation."]
+ }
+ ],
+ "claims": [
+ { "id": "claim:phq9:interpretation", "sourceIds": ["source:phq9", "source:governance"] },
+ { "id": "claim:phq9:safety-flag", "sourceIds": ["source:phq9", "source:governance"] },
+ { "id": "claim:gad7:interpretation", "sourceIds": ["source:gad7", "source:governance"] },
+ { "id": "claim:k10:interpretation", "sourceIds": ["source:k10", "source:governance"] },
+ { "id": "claim:cage:interpretation", "sourceIds": ["source:cage", "source:governance"] },
+ { "id": "claim:auditc:interpretation", "sourceIds": ["source:auditc", "source:governance"] },
+ { "id": "claim:mdq:interpretation", "sourceIds": ["source:mdq", "source:governance"] },
+ { "id": "claim:sadpersons:interpretation", "sourceIds": ["source:sadpersons", "source:governance"] },
+ { "id": "claim:ybocs:interpretation", "sourceIds": ["source:ybocs", "source:governance"] }
+ ]
+}
diff --git a/data/outstanding-issues-snapshot.json b/data/outstanding-issues-snapshot.json
index f2c29a9844..ff5cd51854 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": 85,
"resolved": 431
},
"queue": [
@@ -830,6 +830,24 @@
"summary": "Caring Contacts: the vocabulary scan rewards leaving developer identifiers on screen, and the sealed domain it pushes copy into is watched by no vocabulary scan at all",
"created_at": "2026-08-25"
},
+ {
+ "request_id": "084e06ec-e097-4b27-933d-2c3138ffbb01",
+ "action": "done",
+ "summary": "#XHNTHD: 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.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "0e0c8272-b098-4cce-9a19-04450b931432",
+ "action": "cancel",
+ "summary": "Cancel request 9e88c6de-2225-4134-888c-aec117a116e7: Target issue is no longer in Open items on current main; cancel stale PR-bundled done request so check:docs-links can dry-apply the pending inbox.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "10743a6e-53e4-45e9-b71a-468bf54682f2",
+ "action": "done",
+ "summary": "#S4R2W3: Added isBareDocumentSupportListAnswer and guarded documentSupportListIntent so clinical questions do not return bare title lists without substantive prose.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "11e9136a-6db8-4d3f-84c1-4d39cf18774a",
"action": "cancel",
@@ -842,18 +860,48 @@
"summary": "Caring Contacts: the interface-vocabulary scan cannot express a job title, so correct plain-words role labels fail while raw identifiers pass",
"created_at": "2026-08-25"
},
+ {
+ "request_id": "1742520a-b0d1-4d65-98bf-719f030e98d2",
+ "action": "cancel",
+ "summary": "Cancel request 45fe36c1-2d0b-47d2-afc4-f449b7dc3c84: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "17e26e25-5180-4ed4-9391-834ec456d605",
"action": "done",
"summary": "#G693BZ: Verified PanelSection extracted in panel-primitives.tsx and adopted across routes, documentation, test-health, review-state, and ledger pages.",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "184404de-c45e-43e3-8048-2825799b349e",
+ "action": "add",
+ "summary": "Nothing compares the replayed migration chain against supabase/schema.sql, so a migration that changes a function body without a matching schema.sql edit passes every pre-merge gate and only surfaces post-merge in live-drift",
+ "created_at": "2026-09-01"
+ },
{
"request_id": "2166a980-bac5-4565-b009-f7f4ea530e5c",
"action": "done",
"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",
+ "summary": "Cancel request 97bbfd51-435b-4e31-b956-7138753eb0a8: Target issue is no longer in Open items on current main; cancel stale PR-bundled done request so check:docs-links can dry-apply the pending inbox.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "272880e4-0cc3-4522-9422-1c152a6d838d",
+ "action": "cancel",
+ "summary": "Cancel request 932b96f8-6af8-48a2-aeac-d78d5bb506a9: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "2f077536-00d5-46da-a7c2-63ad9d0221e5",
"action": "done",
@@ -866,12 +914,24 @@
"summary": "Caring Contacts: a draft plan's contacts are already in state scheduled and listSendableContacts has no plan-state gate",
"created_at": "2026-08-25"
},
+ {
+ "request_id": "356a1eb2-70c3-4303-bf98-4af30d9502ce",
+ "action": "cancel",
+ "summary": "Cancel request b2d0158c-09c0-441e-97af-fac42cbe5f12: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "35b8730d-b1f3-41bd-9bc2-7e485446391a",
"action": "done",
"summary": "#V27DZ1: Resolved Playwright strict-mode locator uniqueness on Ward Flow role screens by unifying root containers and verified with tests/ward-management-role.test.ts",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "368839d5-3ef0-46d4-94bc-b9d93842490c",
+ "action": "done",
+ "summary": "#WP76E6: Added Dismissal model section to docs/caring-contacts/interaction-matrix.md.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "36b7dc6b-733e-4116-8a47-4ef35e86039f",
"action": "done",
@@ -902,12 +962,36 @@
"summary": "Caring Contacts: no browser evidence exists for any activation-wizard stage, including a two-write middle state where a clinician presses a writing control a second time",
"created_at": "2026-08-25"
},
+ {
+ "request_id": "428fedef-b62c-461a-8211-ce645549e2d2",
+ "action": "add",
+ "summary": "The document cover-thumbnail hook and its /api/documents/[id]/cover route have no product consumer since the source drawer stopped rendering a front-page thumbnail",
+ "created_at": "2026-09-01"
+ },
+ {
+ "request_id": "44b322b0-0a87-4564-878a-9d5ac97bcdc3",
+ "action": "update",
+ "summary": "#WG24JB: summary → Ward Flow: spec D9: the discharge board is reachable only from the sidebar, not from the capacity board's own figures",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "44e5f87c-d30f-4334-9184-75c1544773ef",
"action": "done",
"summary": "#PM9SP1: Relabelled Therapy Compass copy from 'Decision support' to 'Source-grounded therapy reference' across workspace, screens, and app-modes",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "45fe36c1-2d0b-47d2-afc4-f449b7dc3c84",
+ "action": "done",
+ "summary": "#V27DZ1: Disambiguated ward-diagram-unplaced-unit-${unit.id} and ward-shortlist-unresolved-referred-badge in flow-diagram.tsx and shortlist-panel.tsx.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "461a406d-4937-41c2-afbb-64d1b75f4bcf",
+ "action": "done",
+ "summary": "#AGRAKQ: Fixed regex in tests/helpers/caring-contacts-prohibited-language.ts to exclude plural job titles like clinical leads.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "4e58bc6b-982a-4aca-b89f-d976ab47b61d",
"action": "done",
@@ -920,6 +1004,12 @@
"summary": "Cancel request 373b1aa3-596e-4d5f-bc4f-b05ad44a76f5: Scoped to hydration when filed after Task 7; Tasks 8 and 9 widened the consequence far beyond that, and the row as written understates what is unproven. Same root cause and same trigger to close, so superseded by one replacement stating the full cost rather than adding a second row.",
"created_at": "2026-08-25"
},
+ {
+ "request_id": "6a1eb6b9-8a29-4f7c-89e3-6e5b9b498b3b",
+ "action": "cancel",
+ "summary": "Cancel request 461a406d-4937-41c2-afbb-64d1b75f4bcf: Target issue is no longer in Open items on current main; cancel stale PR-bundled done request so check:docs-links can dry-apply the pending inbox.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "6b49d272-6636-4004-b13d-3073b75b7339",
"action": "add",
@@ -938,6 +1028,12 @@
"summary": "#Y0BDBJ: Implemented ReviewStateTable with 50-item responsive pagination reducing HTML payload from 1.73 MB to <100 KB.",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "7492964b-2a87-4116-a492-8212f5270a99",
+ "action": "cancel",
+ "summary": "Cancel request eaa89aeb-c7a4-47d8-b81f-7ef8a6689c4e: Owner approvals closed the HMAC and retention items; replace with a six-blocker request.",
+ "created_at": "2026-09-01"
+ },
{
"request_id": "7ab6b272-d913-43f5-bf08-a34dcb362f9a",
"action": "add",
@@ -950,18 +1046,48 @@
"summary": "#EFETZT: detail → UPDATE 2026-08-27 (PR #2413, second and third occurrences): this is not only a staleness problem, it also produces hard git merge conflicts that block auto-merge. data/repo-awareness-snapshot.json conflicted twice in fifteen minutes on one PR head — first when main advanced to be65b8a, again when main advanced to fb88b47 — each time setting mergeable_state=dirty, which suppresses refs/pull//merge so pull_request CI (CI, Gitleaks, Semgrep) does not run at all and the check list reads empty rather than red. Both were resolved identically: git merge origin/main, git checkout --theirs the snapshot, npm run snapshot:repo-awareness, commit, push. The staleness half also reproduced: check:repo-awareness-snapshot failed Static PR checks with 'review_state differs from the repository'. Cost per occurrence is one full CI round trip plus a push, and it scales with how many PRs are open, since every merge to main regenerates the file for all of them. Reinforces the fix direction already proposed on this row: exclude docs/branch-review-records/ from the compared content the way captured_revision already is, or compare a narrower key set. Adding .gitattributes merge guidance for this path would blunt the conflict half but not the CI-staleness half.",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "86ba753c-f2fd-4be7-92f4-df3d54cf4a04",
+ "action": "update",
+ "summary": "#QCNE6N: detail → generate-drift-manifest.ts replays supabase/schema.sql and embeds ITS sha256; CI's db-reset-verify runs supabase migration up --local, proving the chain APPLIES but never diffing the result against schema.sql. A migration whose function or policy body diverges from the mirror passes every gate. Object-NAME parity does hold today (all create targets across 211 migrations resolve into schema.sql) but it is enforced by hand-written per-object tests, not systematically. FIX: after migration up --local, replay schema.sql into a second scratch database and diff schema_drift_snapshot() between the two. Fully offline and containerised, no provider access. DEMONSTRATED 2026-09-01, no longer theoretical: live-drift run 33484535655 (red on main at d3074946a) reported its sole unexpected finding as public.correct_clinical_query_terms(text,real) def_hash manifest e2356565 vs live 2ebaf978. Cause was exactly this gap - migration 20260831100000 (PR #2477) redefined that function with a duplicated 'and length(canonical) between 4 and 40' predicate and supabase/schema.sql was never updated to match, so the manifest disagreed with live while every pre-merge gate stayed green. The db-reset-verify assertion that did run is committed.schema_sha256 === generated.schema_sha256 (.github/workflows/ci.yml ~L1082-1095), which only catches an unrefreshed manifest, not a chain/mirror divergence. Behaviour impact of that instance was nil (the duplicate predicate is a boolean no-op) but it cost a red daily alarm and a remediation PR, and it is the second occurrence of this failure class after the #316 root cause (SET work_mem absent from schema.sql). Strengthens the case for scheduling the containerised two-database diff above.; source → repo-wide audit at 3ed1932 (six domain reviewers), re-verified against main 1bb362f; demonstrated by live-drift run 33484535655, classified by the database coordination chat 2026-09-01",
+ "created_at": "2026-09-01"
+ },
+ {
+ "request_id": "870d4673-65da-40b8-94ba-15ba3c6ec6db",
+ "action": "cancel",
+ "summary": "Cancel request b6d16549-d2cb-4815-9386-9c38854e7c0b: Target issue is no longer in Open items on current main; cancel stale PR-bundled done request so check:docs-links can dry-apply the pending inbox.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "8cf62eef-79a8-4b23-83cc-9290eb71dff3",
"action": "add",
"summary": "mode-home-page-skeleton still subtracts a chrome estimate from 100dvh, the pattern invariant 24 retired everywhere else",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "8d78d861-64cc-4e96-ad68-8dd7560cafad",
+ "action": "done",
+ "summary": "#2X46NY: Added comprehensive JSDoc docstrings across exported and internal functions in src/lib/rag/ to raise answer-source coverage above 80%.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "932b96f8-6af8-48a2-aeac-d78d5bb506a9",
+ "action": "done",
+ "summary": "#HX1KSZ: Added component metric boundary prohibition table row and §3c rule to docs/design-system/GATES.md.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "964e1147-56b2-4750-8794-4b8b1d8e4e04",
"action": "add",
"summary": "Ward Flow pinned-clock fix is committed only to an unpushed local branch, and the Phase 6 morning page still carries the defect, its workaround, and an untested D5 branch",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "97bbfd51-435b-4e31-b956-7138753eb0a8",
+ "action": "done",
+ "summary": "#EG4Q7W: Modularized postgres-repository.ts into 5 cohesive domain modules (core, plans, contacts, referrals-pathways, assignments-services) with full test suite passing.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "998e657c-4852-4572-a4f8-5cf919ea2fea",
"action": "done",
@@ -974,6 +1100,18 @@
"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": "9e88c6de-2225-4134-888c-aec117a116e7",
+ "action": "done",
+ "summary": "#S4K1GA: Documented Reduce Motion iPhone acceptance criteria in docs/search-chrome-behaviour.md.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "a2cff633-7bfe-480e-91bc-8d97f68c66c7",
"action": "done",
@@ -998,6 +1136,18 @@
"summary": "Lighthouse desktop-root LCP reads 100-175ms above main on a feature branch, decays run over run, and reddened PR #2422 once",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "b1630592-46f5-4e1a-aa03-97aa30e516af",
+ "action": "done",
+ "summary": "#0HYHTH: Verified static prohibited vocabulary checks in tests/caring-contacts-interface-vocabulary.test.ts.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "b2d0158c-09c0-441e-97af-fac42cbe5f12",
+ "action": "done",
+ "summary": "#PDM6NG: Added screen registration parity test in tests/caring-contact-route-files.test.ts.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "b2e5f3fc-ab2d-4a14-a055-6742d6446784",
"action": "add",
@@ -1010,24 +1160,108 @@
"summary": "CI never runs the caring-contacts database suite, so its 193 tests and the PLAN_COLUMNS narrowing guard only fire on a developer's machine",
"created_at": "2026-08-25"
},
+ {
+ "request_id": "b6d16549-d2cb-4815-9386-9c38854e7c0b",
+ "action": "done",
+ "summary": "#61TZJA: Verified PR #2199 merged and AWAITING_BASELINE in tests/ui-visual-baseline.spec.ts is empty.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "bb121cd3-235b-45f1-817e-a82d7c6c5a31",
+ "action": "add",
+ "summary": "Close remaining privacy provider, legal, and clinical approvals",
+ "created_at": "2026-09-01"
+ },
{
"request_id": "bda0d7e0-7d0a-4946-9f22-8062959a6908",
"action": "add",
"summary": "Caring Contacts: most wizard test cases are proved alive rather than proved complete, and the denominator is now counted",
"created_at": "2026-08-25"
},
+ {
+ "request_id": "c3cf6c59-298b-424e-90bf-9e2cdd9c559e",
+ "action": "done",
+ "summary": "#F5EYAJ: Verified BLOCK_REASON_WORDING in overlay-host.tsx and DOM tests in tests/caring-contacts-overlay-host.dom.test.tsx.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "c4bc5cb3-442e-42ed-9af4-29ae72f02fb6",
"action": "add",
"summary": "Caring Contacts: the plans list query fetches every patient's mobile number and identifiers on every caseload render",
"created_at": "2026-08-24"
},
+ {
+ "request_id": "c5f2cb44-cd51-4acc-a9d1-40a5fc1c56a0",
+ "action": "cancel",
+ "summary": "Cancel request dc26c333-1935-47e8-ab71-b5206c84e19b: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "c66f01fb-e52a-40fa-a4b2-f4d1449ddb11",
"action": "done",
"summary": "#DTSABC: Authored Form 12A statutory Authority and Criteria prose from committed PDF and asserted in tests/forms.test.ts",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "c733065e-9076-4f43-a7b2-9e0b5d5dcedd",
+ "action": "cancel",
+ "summary": "Cancel request 44b322b0-0a87-4564-878a-9d5ac97bcdc3: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "c862828e-276c-4d90-829b-2c2b35e04274",
+ "action": "done",
+ "summary": "#59JT7W: Verified resolveClosingContactMessageBody and tests/caring-contacts-message-policy.test.ts.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "c995abce-f7d4-4be2-b057-80b173a5246f",
+ "action": "add",
+ "summary": "An agent push rewrote tests/ui-smoke.spec.ts from 6,019 lines to 1,081, deleting 74 of 82 UI journey tests, and only a merge conflict stopped it auto-merging",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "cbe7c359-b8e1-4218-a4df-59e869133e0a",
+ "action": "cancel",
+ "summary": "Cancel request 184404de-c45e-43e3-8048-2825799b349e: Duplicate of the existing open row #QCNE6N, which already records that CI never diffs the replayed migration chain against supabase/schema.sql and already proposes the same containerised two-database fix. Retracted the same session it was queued; the new 2026-09-01 evidence was reissued as an update to #QCNE6N instead (request 86ba753c-f2fd-4be7-92f4-df3d54cf4a04).",
+ "created_at": "2026-09-01"
+ },
+ {
+ "request_id": "cd767916-0034-4e5a-b2db-276609d0deec",
+ "action": "cancel",
+ "summary": "Cancel request c862828e-276c-4d90-829b-2c2b35e04274: Target issue is no longer in Open items on current main; cancel stale PR-bundled done request so check:docs-links can dry-apply the pending inbox.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "d5ea0d4f-652f-480c-9a87-3be0ff7ef4b6",
+ "action": "cancel",
+ "summary": "Cancel request dba6b1fc-cb98-49e6-a035-8ca83946232a: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "d6e41550-1a6a-4f4f-aff5-d95004011942",
+ "action": "done",
+ "summary": "#PWZYY8: Added aria-hidden=\"true\" to icon call-sites and updated eslint-rules/require-lucide-icon-aria.mjs to cover Icon/ActiveIcon.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "da5b168d-a9fa-475f-a456-867edfe3694c",
+ "action": "cancel",
+ "summary": "Cancel request 368839d5-3ef0-46d4-94bc-b9d93842490c: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "dba6b1fc-cb98-49e6-a035-8ca83946232a",
+ "action": "done",
+ "summary": "#76GGRG: Implemented full WAI-ARIA Menu keyboard navigation with Arrow Up/Down/Left/Right/Home/End/Escape in search-pins-menu.tsx.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "dc26c333-1935-47e8-ab71-b5206c84e19b",
+ "action": "done",
+ "summary": "#1CYR4V: Verified 640-767px overlay modal responsive boundary contract in tests/caring-contacts-overlay-host.dom.test.tsx.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "dc7ad7ef-1a27-4305-b1f5-326ee08985eb",
"action": "add",
@@ -1040,17 +1274,65 @@
"summary": "#HX1KSZ: Codified 3-way component-metric boundary rule in docs/design-system/GATES.md section 3 prohibition table without value conversions.",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "df2e763d-c1ad-4495-9b62-046f01ab377c",
+ "action": "done",
+ "summary": "#DHAR98: Harden all SECURITY DEFINER functions with explicit pg_temp search_path.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "e2c82b28-4e33-46cf-839e-d00ad26d35d7",
+ "action": "cancel",
+ "summary": "Cancel request f3288efd-b1b6-492f-9a4a-8b316e35fe71: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "e3892275-41ea-475c-bade-e57d2d348fc4",
+ "action": "done",
+ "summary": "#0JJ4RD: 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.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "eaa89aeb-c7a4-47d8-b81f-7ef8a6689c4e",
+ "action": "add",
+ "summary": "Close current privacy provider, legal, and clinical approvals",
+ "created_at": "2026-09-01"
+ },
+ {
+ "request_id": "f3288efd-b1b6-492f-9a4a-8b316e35fe71",
+ "action": "update",
+ "summary": "#V27DZ1: summary → Ward Flow: role screens intermittent Playwright strict-mode violation, a role screen's own test id resolves to 2 elements",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "f6cffbd6-98e9-4233-8c2f-315e5fcc722a",
"action": "done",
"summary": "#F5EYAJ: Created connection and permission unavailable fallback components, wired routing in app/caring-contacts/error.tsx, and dynamically supplied blockReason in workspace-overlays.tsx",
"created_at": "2026-08-27"
},
+ {
+ "request_id": "fa3a637d-a2b9-44b9-bdb9-eb23c912505a",
+ "action": "cancel",
+ "summary": "Cancel request c3cf6c59-298b-424e-90bf-9e2cdd9c559e: Superseded by an earlier pending done request already on main for the same issue; keep main claim and drop this PR duplicate to unblock check:docs-links mutation conflicts.",
+ "created_at": "2026-08-31"
+ },
+ {
+ "request_id": "fbfb32c2-6ca8-4504-adb5-b7647d1d9174",
+ "action": "cancel",
+ "summary": "Cancel request b1630592-46f5-4e1a-aa03-97aa30e516af: Target issue is no longer in Open items on current main; cancel stale PR-bundled done request so check:docs-links can dry-apply the pending inbox.",
+ "created_at": "2026-08-31"
+ },
{
"request_id": "fed7ba66-652e-4081-8845-3c3b70496507",
"action": "done",
"summary": "#ZW43ZT: Bypassed GlobalMockupSearchShell for /mockups/development in MockupsLayoutClient to eliminate duplicate dev main landmark collisions.",
"created_at": "2026-08-27"
+ },
+ {
+ "request_id": "ff7bcba4-5dee-44b7-9b4f-a1aefdd9ab85",
+ "action": "cancel",
+ "summary": "Cancel request d6e41550-1a6a-4f4f-aff5-d95004011942: Target issue is no longer in Open items on current main; cancel stale PR-bundled done request so check:docs-links can dry-apply the pending inbox.",
+ "created_at": "2026-08-31"
}
]
}
diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json
index be9dcfb6b9..321c19f478 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": "90db76d9cd22b434b10739b23eec3dd6deb7a9ed",
+ "committed_at": "2026-09-01T21:40:44+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
}
@@ -1464,6 +1469,11 @@
"section": "agents",
"catalogued": false
},
+ {
+ "path": "docs/agents/codex-reasoning-effort.md",
+ "section": "agents",
+ "catalogued": false
+ },
{
"path": "docs/agents/codex-review-throttling.md",
"section": "agents",
@@ -2869,6 +2879,16 @@
"section": "root",
"catalogued": true
},
+ {
+ "path": "docs/governance/privacy-closeout-2026-09-01.md",
+ "section": "governance",
+ "catalogued": true
+ },
+ {
+ "path": "docs/governance/privacy-role-attestation-pack-2026-09-01.md",
+ "section": "governance",
+ "catalogued": true
+ },
{
"path": "docs/ingestion-concurrency-fix-workorder.md",
"section": "root",
@@ -3314,6 +3334,11 @@
"section": "root",
"catalogued": true
},
+ {
+ "path": "docs/services-mode-governance.md",
+ "section": "root",
+ "catalogued": false
+ },
{
"path": "docs/site-map.md",
"section": "root",
@@ -3549,6 +3574,16 @@
"section": "superpowers",
"catalogued": false
},
+ {
+ "path": "docs/superpowers/plans/2026-09-01-calculators-clinical-safety.md",
+ "section": "superpowers",
+ "catalogued": false
+ },
+ {
+ "path": "docs/superpowers/plans/2026-09-01-services-safety-provenance.md",
+ "section": "superpowers",
+ "catalogued": false
+ },
{
"path": "docs/superpowers/rag-upgrade/canonical/approval-matrix.md",
"section": "superpowers",
@@ -3834,6 +3869,16 @@
"section": "superpowers",
"catalogued": false
},
+ {
+ "path": "docs/superpowers/specs/2026-09-01-calculators-clinical-safety.md",
+ "section": "superpowers",
+ "catalogued": false
+ },
+ {
+ "path": "docs/superpowers/specs/2026-09-01-services-safety-provenance-design.md",
+ "section": "superpowers",
+ "catalogued": false
+ },
{
"path": "docs/testing.md",
"section": "root",
@@ -4234,6 +4279,9 @@
{
"name": "evidence"
},
+ {
+ "name": "governance"
+ },
{
"name": "plans"
},
@@ -4263,10 +4311,10 @@
}
],
"counts": {
- "documents": 554,
- "catalogued": 109,
- "uncatalogued": 445,
- "sections": 20
+ "documents": 562,
+ "catalogued": 111,
+ "uncatalogued": 451,
+ "sections": 21
}
},
"test_health": {
@@ -4278,6 +4326,86 @@
},
"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",
+ "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-31",
+ "ref": "codex/chat-smart-natural-mode-search-pr-2480-landed-verify",
+ "head": "f64762dd6b16bd90e94773b1dc892760180747e1",
+ "scope": "Smart natural-language catalogue search separation",
+ "outcome": "no P0/P1 findings; ready for PR",
+ "checks": "208 focused tests; 53 post-merge tests; production Chromium 4/4; typecheck; lint; docs links; diff check"
+ },
+ {
+ "date": "2026-08-31",
+ "ref": "core_rag_security_hub",
+ "head": "d39db0ec72583680f666078a4a366083bf37fcf9",
+ "scope": "PR Group 2: Core RAG, Security, DB Functions & Developer Hub (#DHAR98, #0JJ4RD, #2X46NY, #S4R2W3, #N8B176, #VKH7N1, #CJCH2E, #FDST2Q, #XKS6FD, #ZW43ZT, #G693BZ)",
+ "outcome": "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.",
+ "checks": "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"
+ },
+ {
+ "date": "2026-08-31",
+ "ref": "gemini/pr-group-3-ui-a11y-caring-contacts-ward-flow (PR #2479)",
+ "head": "8ff3f26c6e4f5302c1883f940f4cc1b22fbeec5c",
+ "scope": "Run PR sweep: existing unresolved review threads",
+ "outcome": "0 → 2 threads resolved: useful-actions disclosure semantics, workspace route registration assertion",
+ "checks": "Focused Vitest 18/18; typecheck passed; 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 +4414,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",
@@ -4406,6 +4542,14 @@
"outcome": "mergeable; snapshot regenerated; 5 premature inbox done requests removed; dev-drive-cache fails closed; Medication pin /medications",
"checks": "vitest session-start-hook+sidebar-production 18/18; check:dev-drive-cache; check:outstanding-issues-snapshot"
},
+ {
+ "date": "2026-08-27",
+ "ref": "ds-hazard-1-2-sweep",
+ "head": "19ee085b1cc952e8c40e893fb2fce99538415259",
+ "scope": "design-system",
+ "outcome": "Findings — 0 P0/P1; 4 structural P2 (disabled-send border jump, hero leading on composer, --leading-prose on chrome, stale conventions.md per-step type scale); residuals on specifier focus ring and leftover Lucide sizes",
+ "checks": "read-only origin/main...HEAD token/UI/docs inspection; ledger lookup NOT REVIEWED; no contract gate, browser, or providers"
+ },
{
"date": "2026-08-27",
"ref": "ds-hazard-1-2-sweep (PR #2416)",
@@ -25456,8 +25600,8 @@
}
],
"counts": {
- "records": 2647,
- "refs": 1615
+ "records": 2659,
+ "refs": 1623
}
}
}
diff --git a/docs/README.md b/docs/README.md
index 1de9fdfd0e..0acba0ec21 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -80,6 +80,9 @@ npm run docs:check-links
- [rag-injection-threat-model.md](rag-injection-threat-model.md) — prompt-injection threat model
- [privacy-impact-assessment.md](privacy-impact-assessment.md) — PIA findings and launch blockers
- [openai-cross-border-basis.md](openai-cross-border-basis.md) — cross-border data-processing basis
+- [governance/privacy-readiness.v1.json](governance/privacy-readiness.v1.json) — authoritative machine-checkable privacy readiness status
+- [governance/privacy-closeout-2026-09-01.md](governance/privacy-closeout-2026-09-01.md) — current provider, retention, legal, notice, and clinical closeout evidence
+- [governance/privacy-role-attestation-pack-2026-09-01.md](governance/privacy-role-attestation-pack-2026-09-01.md) — evidence and role decisions for eight requirements; two owner approvals are complete and six remain
- [production-readiness-checklist.md](production-readiness-checklist.md) — release readiness criteria
- [samd-classification-medication-considerations.md](samd-classification-medication-considerations.md) — SaMD classification and medication considerations
diff --git a/docs/agents-guide.md b/docs/agents-guide.md
index 0f9bbdb616..072ef441a1 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 d89256441c..c3b90fd4ce 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/codex-reasoning-effort.md b/docs/agents/codex-reasoning-effort.md
new file mode 100644
index 0000000000..21c8fa2438
--- /dev/null
+++ b/docs/agents/codex-reasoning-effort.md
@@ -0,0 +1,62 @@
+# Codex Reasoning Effort Calibration
+
+
+
+# Reasoning effort calibration
+
+**Repository baseline.** Use `gpt-5.6-sol` with `high` reasoning effort unless the user explicitly
+chooses another supported model or effort. `.codex/config.toml` records this default for trusted
+Codex clients that honor repository configuration; a task-level selection can override it.
+
+**Cloud xhigh gate.** A running Cloud task cannot raise its own reasoning effort. Before substantive
+inspection, planning, tool use, or edits, classify the request against the table and the risk rules
+below. If `xhigh` is required and the prompt does not contain the exact marker `[xhigh-confirmed]`,
+stop and ask the user to select `xhigh` in the Cloud reasoning control, then resubmit the same request
+with `[xhigh-confirmed]`. Do not begin the work at `high`, and do not claim the runtime changed. When
+the marker is present, treat it as the user's confirmation that `xhigh` was selected and proceed.
+Requests classified as `high` or lower proceed without this gate.
+
+Reasoning effort is a budget in the same way verification is a budget, and it is misspent the same
+way — by defaulting to the maximum instead of matching the spend to the risk. **Scale effort to how
+expensive the mistake is to undo (irreversibility × branching factor), never to the phase label.**
+"Plan high, build lower" is a good default, not a rule; it is wrong often enough that it must be
+chosen deliberately rather than assumed.
+
+**Why it is a good default.** Planning errors compound and implementation errors stay local: a wrong
+approach throws away the build, a wrong identifier is one edit. A plan is also a few thousand output
+tokens against a build's many long turns, so effort is cheapest exactly where it has the most leverage.
+
+**The mechanism that makes it work — do not skip this part.** A lower-effort build only succeeds
+against a plan concrete enough to execute: named files, named symbols, ordered steps, and the gate
+that will prove it. Downgrading the build against a vague plan does not save effort, it relocates the
+thinking into the expensive phase. If the plan cannot name those things, the build is not eligible for
+the downgrade.
+
+| Situation | Plan | Build |
+| ---------------------------------------------------------------------------------------------- | ----------- | ----------- |
+| Architecture, Supabase migrations/RLS, RAG ranking surfaces, auth/privacy, ingestion contracts | xhigh | high |
+| Ordinary feature or UI work with a clear shape | high | medium–high |
+| Mechanical and fully specified — ledger append, docs edit, rename (not dependency maintenance) | low or skip | medium |
+| Debugging an unknown failure | low | high |
+
+**Where the default inverts and the build needs more than the plan.** These are plan-light and
+execution-heavy; treating them as plan-heavy spends the budget in the wrong place:
+
+- **Debugging.** The plan is "find why X fails." The real reasoning is hypothesis-forming over local
+ runtime, logs, and repro state during the build — hosted providers still need explicit confirmation.
+- **Constraint-dense implementation.** A one-sentence plan whose edit must simultaneously satisfy
+ button wiring, design tokens, the one-composer rule, unlayered CSS, and the tap-target and
+ phone-chrome contracts. Holding all of it at once is the hard part, not deciding what to do.
+- **Areas where training data is stale.** Next 16 is the standing case. Effort does not repair a wrong
+ prior — reading `node_modules/next/dist/docs/` does. Raising effort instead of reading is itself the
+ failure mode.
+
+**Constrain xhigh planning output, not just its effort.** Extra-high planning over-produces:
+alternatives, contingency branches, and surveys that are never used, paid for twice — once generating
+and once reading. Ask for the chosen approach, the files, and the gate, not a survey.
+
+**State the split before non-trivial planning work.** One line before starting: plan effort, build
+effort, and the risk that justifies them. It is cheap, it makes a wrong allocation visible while it is
+still free to change, and it stops the blanket default from being applied silently.
+
+
diff --git a/docs/agents/cursor-cloud.md b/docs/agents/cursor-cloud.md
index f0e48fe4f4..683ef91660 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/audit/live-drift-forensics-2026-08.md b/docs/audit/live-drift-forensics-2026-08.md
index 4426d98449..a4b533f518 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/branch-review-records/08497f19070d98a449377c03d2cde65dcea10da02d09da32585949a3830eac3b.record.md b/docs/branch-review-records/08497f19070d98a449377c03d2cde65dcea10da02d09da32585949a3830eac3b.record.md
new file mode 100644
index 0000000000..af3103cb0e
--- /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. |
diff --git a/docs/branch-review-records/2eb7a598118b7a73fb36384e1fc6ca3ca986eeea19ff40c7cb2b195dfa9b9e67.record.md b/docs/branch-review-records/2eb7a598118b7a73fb36384e1fc6ca3ca986eeea19ff40c7cb2b195dfa9b9e67.record.md
new file mode 100644
index 0000000000..3b2dc7fb2d
--- /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. |
diff --git a/docs/branch-review-records/38c77f1167740922262bdf237802d21945497a9d612a51685a62002989e57c7d.record.md b/docs/branch-review-records/38c77f1167740922262bdf237802d21945497a9d612a51685a62002989e57c7d.record.md
new file mode 100644
index 0000000000..1a8575d984
--- /dev/null
+++ b/docs/branch-review-records/38c77f1167740922262bdf237802d21945497a9d612a51685a62002989e57c7d.record.md
@@ -0,0 +1 @@
+| 2026-08-31 | gemini/pr-group-3-ui-a11y-caring-contacts-ward-flow (PR #2479) | 8ff3f26c6e4f5302c1883f940f4cc1b22fbeec5c | Run PR sweep: existing unresolved review threads | 0 → 2 threads resolved: useful-actions disclosure semantics, workspace route registration assertion | Focused Vitest 18/18; typecheck passed; no provider-backed checks run |
diff --git a/docs/branch-review-records/46928e14c627bd4e8be016b6d181c434e996a4a21e5d788b2862b12296656a21.record.md b/docs/branch-review-records/46928e14c627bd4e8be016b6d181c434e996a4a21e5d788b2862b12296656a21.record.md
new file mode 100644
index 0000000000..27a7bb7ab7
--- /dev/null
+++ b/docs/branch-review-records/46928e14c627bd4e8be016b6d181c434e996a4a21e5d788b2862b12296656a21.record.md
@@ -0,0 +1 @@
+| 2026-08-31 | codex/answer-surface-compact-20260830 | 705561dd1f9b1ac8f72c7a4858e3819b9ee5a40e | compact answer source safety and library UI | No P0-P2 findings; compact source status, answer utilities, safety row, and library placement ready for PR | 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 |
diff --git a/docs/branch-review-records/59910cd5286d4386f2d1b58327c7fd4deec2fff472836a8e5e7a8d91cee14d82.record.md b/docs/branch-review-records/59910cd5286d4386f2d1b58327c7fd4deec2fff472836a8e5e7a8d91cee14d82.record.md
new file mode 100644
index 0000000000..74fa5d6cb1
--- /dev/null
+++ b/docs/branch-review-records/59910cd5286d4386f2d1b58327c7fd4deec2fff472836a8e5e7a8d91cee14d82.record.md
@@ -0,0 +1 @@
+| 2026-08-31 | codex/chat-smart-natural-mode-search-pr-2480-landed-verify | f64762dd6b16bd90e94773b1dc892760180747e1 | Smart natural-language catalogue search separation | no P0/P1 findings; ready for PR | 208 focused tests; 53 post-merge tests; production Chromium 4/4; typecheck; lint; docs links; diff check |
diff --git a/docs/branch-review-records/9252ad9dfa57000de74b55496e6c60bbdde1ca84db9e2bfa7faf784cdcf7d98d.record.md b/docs/branch-review-records/9252ad9dfa57000de74b55496e6c60bbdde1ca84db9e2bfa7faf784cdcf7d98d.record.md
new file mode 100644
index 0000000000..220038ea82
--- /dev/null
+++ b/docs/branch-review-records/9252ad9dfa57000de74b55496e6c60bbdde1ca84db9e2bfa7faf784cdcf7d98d.record.md
@@ -0,0 +1 @@
+| 2026-08-27 | ds-hazard-1-2-sweep | 19ee085b1cc952e8c40e893fb2fce99538415259 | design-system | Findings — 0 P0/P1; 4 structural P2 (disabled-send border jump, hero leading on composer, --leading-prose on chrome, stale conventions.md per-step type scale); residuals on specifier focus ring and leftover Lucide sizes | read-only origin/main...HEAD token/UI/docs inspection; ledger lookup NOT REVIEWED; no contract gate, browser, or providers |
diff --git a/docs/branch-review-records/e328623e22f9d73a0f3f86b200c9e1f40eb3d73e503acc4fc64fe26740fe4dc5.record.md b/docs/branch-review-records/e328623e22f9d73a0f3f86b200c9e1f40eb3d73e503acc4fc64fe26740fe4dc5.record.md
new file mode 100644
index 0000000000..ff05175815
--- /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. |
diff --git a/docs/branch-review-records/eb3b45f1c916b2533496cd8afa3563a413625a0b67ddd31360d242f48cb9a2b3.record.md b/docs/branch-review-records/eb3b45f1c916b2533496cd8afa3563a413625a0b67ddd31360d242f48cb9a2b3.record.md
new file mode 100644
index 0000000000..5107066480
--- /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/caring-contacts/interaction-matrix.md b/docs/caring-contacts/interaction-matrix.md
index 0da6a54f35..fd7a7edc39 100644
--- a/docs/caring-contacts/interaction-matrix.md
+++ b/docs/caring-contacts/interaction-matrix.md
@@ -29,6 +29,11 @@ The source of truth is `completionOverlayDefinitions` in `src/components/caring-
| `team-switcher` | Header active-team context | Bottom sheet | Dialog | Yes | Escape, backdrop, close |
| `draft-version-conflict` | Draft/version guard | Full-screen stage | Dialog | No | Escape, backdrop, close |
+## Dismissal model
+
+- **Standard dismissals (`Escape, backdrop, close`):** Standard modal overlays and inspection drawers allow clinician dismissal via pressing the `Escape` key, clicking the backdrop, or activating the close/cancel button. Closing restores focus to the triggering element.
+- **System gate dismissals (`Recovery action only`):** Non-dismissible blocking gates (such as `session-expiry` and `offline-banner`) deliberately disallow passive dismissal (`Escape`, backdrop tap, or close icon). They persist until the explicit recovery action is completed (e.g. re-authenticating at the session gate or regaining network connectivity).
+
## Feedback contract
- Success: announce the synthetic in-memory outcome and update the visible plan/audit summary.
diff --git a/docs/claude-cloud.md b/docs/claude-cloud.md
index 2be0a1313d..7d91b436e6 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/clinical-governance.md b/docs/clinical-governance.md
index 044c7509b4..12582a694b 100644
--- a/docs/clinical-governance.md
+++ b/docs/clinical-governance.md
@@ -21,6 +21,14 @@ PsychSift is currently a source-backed clinical reference prototype. Before prod
- Confirm OpenAI and Supabase data-processing arrangements are acceptable for the intended clinical setting.
- Define audit requirements for uploads, document access, user queries, generated answers, copied drafts, and source opening.
+The status authority is [`governance/privacy-readiness.v1.json`](governance/privacy-readiness.v1.json),
+with the current evidence summary in
+[`governance/privacy-closeout-2026-09-01.md`](governance/privacy-closeout-2026-09-01.md). As of
+2026-09-01, OpenAI API sharing and call logging are disabled and optional hosted tools are disabled;
+OpenAI has acknowledged the ZDR request, but ZDR, DPA, APP 8, APP 1/5 notice, and clinical
+PHI-minimisation approvals remain release blockers. Do not infer approval from request submission or
+from the public `/privacy` transparency page.
+
## Clinical Use Rules
- Generated answers and copied drafts must be verified against linked source text, local policy, and patient context before use.
@@ -60,11 +68,12 @@ Authority must come from registered publisher codes or compatible canonical publ
## Mode-aware Clinical Ask governance
-Clinical Ask remains dormant by default. When `CLINICAL_ASK_ENABLED=true`, the existing shared composer exposes a
-server-gated Smart entry point for seven exhaustive clinician-reference modes: Services, Forms, Differentials,
-Formulation, DSM-5 Diagnosis, Specifiers, and Therapy. Unsupported or denylisted modes and all deployments with the
-flag off retain deterministic search/filter behaviour and show no Smart promise. There is no microphone control or
-separate Ask rail. Every Smart request uses the same deterministic Evidence Ladder: local
+Clinical Ask remains dormant by default and is separate from Smart mode search. The shared mode composer never
+routes a natural-language query to Clinical Ask: Services, Forms, Differentials, Formulation, DSM-5 Diagnosis,
+Specifiers, and Therapy interpret natural language locally and show their ordinary deterministic catalogue results.
+`CLINICAL_ASK_ENABLED` governs only the dormant answer workflow; it does not enable, disable, or alter Smart search.
+There is no microphone control or separate Ask rail. If Clinical Ask is exposed through a dedicated governed-answer
+surface in future, every request must use the same deterministic Evidence Ladder: local
Catalogue first, authorised owner-scoped Indexed evidence second, and an allowlisted External Authority only when
there is a deterministic evidence gap, unresolved conflict, stale material, or a `needs_review` source. An unsupported
conclusion is rendered as an Evidence Gap; source conflict and review state remain visible, and clinically material
@@ -88,5 +97,6 @@ protected-staging canary, contractual, or physical-device evidence.
An `answered` stream payload fails closed unless it contains governed evidence and every visible lead, section, and
conflict claim references evidence present in that payload. An Evidence Gap may still carry zero or partial evidence.
-Production activation remains separately gated by named human clinical and contractual/privacy approval plus physical
-iPhone Safari and installed-PWA acceptance of the enabled Smart path.
+Clinical Ask production activation remains separately gated by named human clinical and contractual/privacy approval
+plus physical iPhone Safari and installed-PWA acceptance. These answer-workflow gates do not block provider-free Smart
+catalogue search.
diff --git a/docs/codebase-index.md b/docs/codebase-index.md
index a9829195ec..dd5258dd1e 100644
--- a/docs/codebase-index.md
+++ b/docs/codebase-index.md
@@ -188,6 +188,7 @@ domain-extracted directory; imported as `@/lib/rag/rag*`). Other modules below r
| Module | Role |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `differentials.ts`, `forms.ts`, `services.ts`, `registry-records.ts` | Shared catalogue content with optional owner overrides |
+| `services-canonical-data/` | Generated, partitioned canonical WA Services source records, carrying safety, provenance, availability, and review metadata for the governed overlay |
| `mha-act-sections.ts` | Mental Health Act 2014 (WA) section summaries shared across forms; `actSectionsForCue` resolves a form's `sourceFacts.sectionCue` and withholds the whole list until every cited section has a summary; `drafted` entries render with an awaiting-clinical-review note, `reviewed` ones name their reviewer (`docs/wiring-conventions.md`) |
| `dictionary-data.ts`, `dictionary.ts` | Governed terminology, sources, topics, aliases, filters; `dictionaryCatalogue` is the one selector behind the merged Terms surface |
| `dsm.ts` | Local DSM diagnosis catalogue and comparison helpers |
@@ -704,7 +705,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 d8f4cee772..71ef422f09 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 d5972a75d7..be2838b1fb 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 3fec4c9381..5288e09b4a 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 af76b5000d..ea9f7d7b57 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 bc590ebc67..ca61c7899a 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/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md
index 1ad3cae5c5..b50e0360cb 100644
--- a/docs/design-system/COMPONENTS.md
+++ b/docs/design-system/COMPONENTS.md
@@ -1052,7 +1052,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `Progress` | feedback | yes | yes | no | yes | no | 0 |
| `Quantity` | answer | yes | yes | inherited-global-root | yes | no | 1 |
| `RadioGroup` | controls | yes | yes | no | yes | no | 0 |
-| `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 |
+| `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 2 |
| `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 |
| `SearchField` | controls | yes | yes | no | yes | no | 0 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 9 |
@@ -1070,6 +1070,6 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `ToastRegion` | feedback | yes | yes | no | yes | no | 0 |
| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `Tooltip` | feedback | yes | yes | no | yes | no | 0 |
-| `VerificationNotice` | answer | yes | yes | inherited-global-root | yes | no | 1 |
+| `VerificationNotice` | answer | yes | yes | inherited-global-root | yes | no | 2 |
diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md
index f341d83dff..5aee1547e3 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` (6) + `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 34 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 34 are the other six aliases after the e2 (`--shadow-card`/`--shadow-soft`) family pay-down, still `#262` |
-| Raw pixel size, padding, radius, gap or line-height in markup | `check:design-system-contract` — `rawPaddingLiterals` (52), `rawRadiusLiterals` (20), `rawGapLiterals` (25), `rawMarginLiterals` (74), `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` (41) | **implemented-blocking** for the Tailwind `duration-*`/`delay-*`/`transition-all` form; the CSS form is a ratchet, so its 41 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` (9) + 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 9 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 `
) : 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/app/mockups/mode-dropdown/page.tsx b/src/app/mockups/mode-dropdown/page.tsx
index 7189feacd0..467adf2eef 100644
--- a/src/app/mockups/mode-dropdown/page.tsx
+++ b/src/app/mockups/mode-dropdown/page.tsx
@@ -74,7 +74,7 @@ function HeaderMockup({ expanded = false, compact = false }: { expanded?: boolea
aria-expanded={expanded}
>
-
+
@@ -84,7 +84,7 @@ function HeaderMockup({ expanded = false, compact = false }: { expanded?: boolea
{activeMode.label}
-
+
{expanded ? (
@@ -114,7 +114,7 @@ function HeaderMockup({ expanded = false, compact = false }: { expanded?: boolea
: "border-[color:var(--border)] bg-[color:var(--surface-raised)]",
].join(" ")}
>
-
+ {mode.label}
@@ -122,7 +122,7 @@ function HeaderMockup({ expanded = false, compact = false }: { expanded?: boolea
{mode.description}
- {mode.active ? : null}
+ {mode.active ? : null}
);
})}
diff --git a/src/app/mockups/phone-inpage-navigation/page.tsx b/src/app/mockups/phone-inpage-navigation/page.tsx
index 955ed1018e..96682f59b4 100644
--- a/src/app/mockups/phone-inpage-navigation/page.tsx
+++ b/src/app/mockups/phone-inpage-navigation/page.tsx
@@ -218,7 +218,7 @@ function ClinicalContent({ active }: { active: SectionLabel }) {
@@ -370,7 +370,7 @@ function PriorityDock() {
aria-current={active === label ? "page" : undefined}
className={`flex min-h-[52px] flex-col items-center justify-center gap-1 rounded-xl text-3xs font-semibold ${active === label ? "bg-[#173b3e] text-[#6de1e4]" : "text-[#8f9997]"}`}
>
-
+
{label === "Why matched" ? "Matched" : label}
))}
@@ -381,7 +381,7 @@ function PriorityDock() {
aria-current={overflowActive ? "page" : undefined}
className={`flex min-h-[52px] flex-col items-center justify-center gap-1 rounded-xl text-3xs font-semibold ${more || overflowActive ? "bg-[#173b3e] text-[#6de1e4]" : "text-[#8f9997]"}`}
>
-
+
More
{more ? (
@@ -397,7 +397,7 @@ function PriorityDock() {
aria-current={active === label ? "page" : undefined}
className={`flex min-h-12 w-full items-center gap-3 rounded-xl px-3 text-xs font-semibold ${active === label ? "bg-[#173b3e] text-[#6de1e4]" : "text-[#c0c8c6] hover:bg-white/5"}`}
>
-
+
{label}
))}
@@ -424,7 +424,7 @@ function ProgressNavigator() {
aria-label="Previous section"
className="grid size-11 shrink-0 place-items-center rounded-full border border-[#30393b] text-[#9aa5a3] disabled:opacity-30"
>
-
+ {Math.round(((index + 1) / sections.length) * 100)}%
-
+
{active.label}
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index 675fc08646..83bdb4f81e 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -4,7 +4,8 @@ import { PrivacyQuietSignalPage } from "@/components/privacy-quiet-signal-page";
export const metadata: Metadata = {
title: "How PsychSift handles your data",
- description: "Learn what PsychSift processes, where data is handled, and how long information is retained.",
+ description:
+ "Learn what PsychSift processes, where data is handled, current provider controls, retention periods, and pending privacy approvals.",
};
/**
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index 4e536f862d..e032231957 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -80,7 +80,7 @@ import { GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/das
import { LazyGuideDialog, loadGuideDialog } from "@/components/clinical-dashboard/lazy-guide-dialog";
import { SystemNotice, DegradedNoticeFrame } from "@/components/clinical-dashboard/dashboard-notices";
import { resolveModeHomeCanvasClass } from "@/components/clinical-dashboard/mode-home-canvas";
-import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text";
+import { sanitizeAnswerDisplayText } from "@/components/clinical-dashboard/display-text";
import { AnswerCancelledNotice } from "@/components/clinical-dashboard/answer-cancelled-notice";
import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content";
import {
@@ -238,7 +238,7 @@ import {
type AnswerThreadSnapshotMetadata,
} from "@/components/clinical-dashboard/use-persisted-answer-thread";
import { buildAnswerClipboardText } from "@/components/clinical-dashboard/answer-copy-payload";
-import { buildAnswerRenderModel, isAnswerSourceBacked } from "@/lib/answer-render-policy";
+import { buildAnswerRenderModel } from "@/lib/answer-render-policy";
import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
import {
frontendSourceGovernanceWarnings,
@@ -255,7 +255,6 @@ import type {
IngestionJob,
QuoteCard,
RagAnswer,
- AnswerSection,
SearchResult,
SearchScopeSummary,
ClinicalQueryMode,
@@ -566,7 +565,7 @@ function ClinicalDashboardContent({
const [userStartedIngestion, setUserStartedIngestion] = useState(false);
const [nextRefreshDelayMs, setNextRefreshDelayMs] = useState(null);
const auth = useAuthSession();
- const { clinicalAskSession, clinicalAskMode, runModeClinicalAsk, submitSmartSearch } = useClinicalAskDashboardChrome({
+ const { clinicalAskSession, clinicalAskMode, runModeClinicalAsk } = useClinicalAskDashboardChrome({
accountId: auth.session?.user.id,
searchMode,
query,
@@ -2191,7 +2190,6 @@ function ClinicalDashboardContent({
run: true,
...navigationContext,
});
- if (submitSmartSearch(trimmedQuery, () => setModeSearchSubmitted(true))) return;
if (trimmedQuery && !isDashboardModeHref(modeDestination)) {
rememberRecentQuery(trimmedQuery);
router.push(modeDestination);
@@ -2840,12 +2838,6 @@ function ClinicalDashboardContent({
const safetyFindings = useMemo(() => extractSafetyFindings(answer), [answer]);
const bestSource = answerRenderModel?.bestSource ?? null;
const sourceSummary = answer?.evidenceSummary ?? answer?.smartPanel?.evidenceSummary;
- const answerGrounded =
- answer?.grounded === true &&
- answer.confidence !== "unsupported" &&
- isAnswerSourceBacked(answer) &&
- answerRenderModel?.trust !== "unsupported";
- const sourceLookup = useMemo(() => new Map(sources.map((source) => [source.id, source])), [sources]);
const answerPreformatted = isPreformattedGroundedAnswer(answer);
const safeAnswerText = useMemo(
() => sanitizeAnswerDisplayText(answer?.answer ?? "", { preformatted: answerPreformatted }),
@@ -2861,36 +2853,6 @@ function ClinicalDashboardContent({
if (showEarlierTurns || hiddenPriorTurnCount === 0) return priorAnswerTurns;
return priorAnswerTurns.slice(-maxVisiblePriorTurns);
}, [hiddenPriorTurnCount, priorAnswerTurns, showEarlierTurns]);
- const safeAnswerSections = useMemo(() => {
- return (answer?.answerSections ?? [])
- .map((section) => {
- const heading = sanitizeDisplayText(section.heading, { minLength: 1, minTokens: 1 });
- const body = sanitizeAnswerDisplayText(section.body, {
- minLength: 8,
- minTokens: 2,
- preformatted: answerPreformatted,
- });
- if (!heading || !body) return null;
-
- const citationSources: SearchResult[] = [];
- const seenCitationIds = new Set();
- for (const id of section.citation_chunk_ids) {
- if (seenCitationIds.has(id)) continue;
- const source = sourceLookup.get(id);
- if (!source) continue;
- seenCitationIds.add(id);
- citationSources.push(source);
- }
-
- return {
- ...section,
- heading,
- body,
- citationSources,
- };
- })
- .filter((section): section is AnswerSection & { citationSources: SearchResult[] } => section !== null);
- }, [answer?.answerSections, answerPreformatted, sourceLookup]);
const showSystemNotice = Boolean(setupWarning && !demoMode);
const groupedGovernanceWarningCount = useMemo(
() =>
@@ -3338,7 +3300,6 @@ function ClinicalDashboardContent({
canAccessFavourites={favouritesAccessible}
onRequestAccountSetup={() => openAccountSetup("favourites")}
onAsk={ask}
- clinicalAskAvailable={Boolean(clinicalAskMode)}
onClearQuery={() => {
setQuery("");
if (!answer) setModeSearchSubmitted(false);
@@ -3826,20 +3787,18 @@ function ClinicalDashboardContent({
sourceSummary={sourceSummary}
renderModel={answerRenderModel}
weakEvidence={weakEvidence}
- answerGrounded={answerGrounded}
sources={answerRenderModel.reviewSources}
- safeAnswerSections={safeAnswerSections}
safetyFindings={safetyFindings}
copiedAnswer={copiedAction === "answer"}
pendingFeedback={pendingFeedback}
onCopyAnswer={handleCopyAnswer}
onSubmitFeedback={handleSubmitAnswerFeedback}
onFollowUpQuote={handleAnswerFollowUpQuote}
+ crossModeQueries={crossModeQueries}
+ onCrossModeSearch={handleCrossModeSearch}
followUpSuggestions={answerFollowUpSuggestions}
onPickFollowUpSuggestion={handleFollowUpSuggestionPick}
followUpSuggestionsDisabled={loading}
- crossModeQueries={crossModeQueries}
- onCrossModeSearch={handleCrossModeSearch}
onScopeDocument={handleScopeDocument}
/>
>
@@ -3988,7 +3947,7 @@ function ClinicalDashboardContent({
)}
>
-
+
{tab.label}
[source.id, source]));
+ return sourceIds.flatMap((sourceId) => {
+ const source = sourcesById.get(sourceId);
+ return source ? [source] : [];
+ });
+}
diff --git a/src/components/calculators/calculator-fixtures.ts b/src/components/calculators/calculator-fixtures.ts
index c1eb34f7f0..5aeaa85cf4 100644
--- a/src/components/calculators/calculator-fixtures.ts
+++ b/src/components/calculators/calculator-fixtures.ts
@@ -13,6 +13,10 @@ import {
type LucideIcon,
} from "lucide-react";
+import { calculatorEvidence } from "./calculator-evidence";
+
+export { calculatorEvidence };
+
export type CalculatorTone = "success" | "info" | "warning" | "danger";
type CalculatorOption = {
@@ -36,6 +40,8 @@ export type CalculatorItem = {
options?: CalculatorOption[];
/** Safety flag surfaced whenever this item scores above zero. */
flag?: string;
+ /** Evidence claim for the safety wording. */
+ flagClaimId?: string;
};
export type ScoreBand = {
@@ -43,7 +49,8 @@ export type ScoreBand = {
max: number;
label: string;
tone: CalculatorTone;
- guidance: string;
+ /** Source-linked psychometric interpretation, never management instruction. */
+ interpretation: string;
};
export type CalculatorDomain = "mood" | "anxiety" | "substance" | "risk" | "distress";
@@ -71,8 +78,54 @@ export type CalculatorFixture = {
scoringNote: string;
source: string;
caution?: string;
+ instrumentVersion: string;
+ administrationMethod: string;
+ intendedPopulation: string;
+ timeframe: string;
+ completionPolicy: string;
+ interpretationPolicy: string;
+ sourceIds: string[];
+ claimIds: string[];
+ rights: { status: "available" | "permission_review_required" | "unknown" };
+ jurisdiction: string;
+ lastReviewed: string;
+ nextReview: string;
+ reviewer: string;
+ confidence: "high" | "moderate" | "low";
+ riskLevel: "low" | "moderate" | "high";
+ instrumentStatus: "active" | "quarantined";
+ evidenceStatus: "verified" | "review_required" | "blocked";
+ releaseStatus: "available" | "quarantined";
+ limitations: string[];
+ unresolvedIssues: string[];
};
+type RawScoreBand = Omit & { guidance: string };
+type RawCalculatorFixture = Omit<
+ CalculatorFixture,
+ | "bands"
+ | "instrumentVersion"
+ | "administrationMethod"
+ | "intendedPopulation"
+ | "timeframe"
+ | "completionPolicy"
+ | "interpretationPolicy"
+ | "sourceIds"
+ | "claimIds"
+ | "rights"
+ | "jurisdiction"
+ | "lastReviewed"
+ | "nextReview"
+ | "reviewer"
+ | "confidence"
+ | "riskLevel"
+ | "instrumentStatus"
+ | "evidenceStatus"
+ | "releaseStatus"
+ | "limitations"
+ | "unresolvedIssues"
+> & { bands: RawScoreBand[] };
+
export const domainLabels: Record = {
mood: "Mood",
anxiety: "Anxiety & OCD",
@@ -112,7 +165,7 @@ const ybocsSeverity: CalculatorOption[] = [
{ label: "Extreme", short: "4", points: 4 },
];
-export const calculators: CalculatorFixture[] = [
+const calculatorFixtures: RawCalculatorFixture[] = [
{
id: "phq9",
abbrev: "PHQ-9",
@@ -120,13 +173,14 @@ export const calculators: CalculatorFixture[] = [
domain: "mood",
icon: CloudRain,
indication: "Screen for depression, grade severity, and track response to treatment over time.",
- summary: "9-item depression severity score with treatment-action bands.",
+ summary: "9-item depression assessment and monitoring measure.",
stem: "Over the last 2 weeks, how often have you been bothered by:",
timeEstimate: "2–3 min",
timeEstimateMinutes: { min: 2, max: 3 },
minScore: 0,
maxScore: 27,
- scoringNote: "Sum of 9 items (0–3 each). Severity bands map to stepped treatment actions.",
+ scoringNote:
+ "Sum of 9 items (0–3 each); interpret a completed result with the instrument source and clinical context.",
source: "Kroenke, Spitzer & Williams 2001",
caution: "Any endorsement of item 9 requires direct suicide-risk assessment regardless of total score.",
items: [
@@ -163,32 +217,45 @@ export const calculators: CalculatorFixture[] = [
kind: "options",
options: frequency0to3,
text: "Thoughts that you would be better off dead, or of hurting yourself in some way",
- flag: "Item 9 endorsed — complete a structured suicide-risk assessment now.",
+ flag: "Item 9 endorsed — directly assess suicidal thoughts, self-harm thoughts and immediate safety now.",
+ flagClaimId: "claim:phq9:safety-flag",
},
],
bands: [
- { min: 0, max: 4, label: "Minimal", tone: "success", guidance: "Monitor; treatment may not be required." },
- { min: 5, max: 9, label: "Mild", tone: "info", guidance: "Watchful waiting; repeat PHQ-9 at follow-up." },
+ {
+ min: 0,
+ max: 4,
+ label: "Minimal",
+ tone: "success",
+ guidance: "Lower symptom score band; interpret in clinical context.",
+ },
+ {
+ min: 5,
+ max: 9,
+ label: "Mild",
+ tone: "info",
+ guidance: "Mild symptom score band; interpret in clinical context.",
+ },
{
min: 10,
max: 14,
label: "Moderate",
tone: "warning",
- guidance: "Treatment plan: psychotherapy, follow-up and/or pharmacotherapy.",
+ guidance: "Moderate symptom score band; interpret in clinical context.",
},
{
min: 15,
max: 19,
label: "Moderately severe",
tone: "warning",
- guidance: "Active treatment with pharmacotherapy and/or psychotherapy.",
+ guidance: "Moderately severe symptom score band; interpret in clinical context.",
},
{
min: 20,
max: 27,
label: "Severe",
tone: "danger",
- guidance: "Initiate pharmacotherapy; expedite specialist referral if impairment is severe.",
+ guidance: "Severe symptom score band; interpret in clinical context.",
},
],
},
@@ -217,21 +284,33 @@ export const calculators: CalculatorFixture[] = [
{ id: "g7", kind: "options", options: frequency0to3, text: "Feeling afraid, as if something awful might happen" },
],
bands: [
- { min: 0, max: 4, label: "Minimal", tone: "success", guidance: "No action beyond routine care." },
- { min: 5, max: 9, label: "Mild", tone: "info", guidance: "Monitor; repeat GAD-7 at review." },
+ {
+ min: 0,
+ max: 4,
+ label: "Minimal",
+ tone: "success",
+ guidance: "Lower anxiety symptom score band; interpret in clinical context.",
+ },
+ {
+ min: 5,
+ max: 9,
+ label: "Mild",
+ tone: "info",
+ guidance: "Mild anxiety symptom score band; interpret in clinical context.",
+ },
{
min: 10,
max: 14,
label: "Moderate",
tone: "warning",
- guidance: "Probable anxiety disorder — confirm diagnosis and agree a treatment plan.",
+ guidance: "Moderate anxiety symptom score band; interpret in clinical context.",
},
{
min: 15,
max: 21,
label: "Severe",
tone: "danger",
- guidance: "Active treatment warranted; assess functional impact and comorbid depression.",
+ guidance: "Severe anxiety symptom score band; interpret in clinical context.",
},
],
},
@@ -264,27 +343,33 @@ export const calculators: CalculatorFixture[] = [
{ id: "k10", kind: "options", options: kessler1to5, text: "Worthless" },
],
bands: [
- { min: 10, max: 15, label: "Low", tone: "success", guidance: "Likely well; no specific action." },
+ {
+ min: 10,
+ max: 15,
+ label: "Low",
+ tone: "success",
+ guidance: "Lower psychological distress score band; interpret in clinical context.",
+ },
{
min: 16,
max: 21,
label: "Moderate",
tone: "info",
- guidance: "Consistent with mild distress — brief intervention and review.",
+ guidance: "Moderate psychological distress score band; interpret in clinical context.",
},
{
min: 22,
max: 29,
label: "High",
tone: "warning",
- guidance: "Likely mild-to-moderate mental disorder — structured assessment indicated.",
+ guidance: "High psychological distress score band; interpret in clinical context.",
},
{
min: 30,
max: 50,
label: "Very high",
tone: "danger",
- guidance: "Likely severe disorder — comprehensive assessment and active treatment.",
+ guidance: "Very high psychological distress score band; interpret in clinical context.",
},
],
},
@@ -385,14 +470,14 @@ export const calculators: CalculatorFixture[] = [
max: 6,
label: "Below symptom threshold",
tone: "success",
- guidance: "Screen negative on symptom count alone.",
+ guidance: "Symptom-count screen result; interpret with all required criteria and clinical context.",
},
{
min: 7,
max: 13,
label: "Symptom threshold met",
tone: "warning",
- guidance: "Check co-occurrence and impairment criteria to complete the screen.",
+ guidance: "Symptom-count screen result; interpret with all required criteria and clinical context.",
},
],
},
@@ -427,14 +512,14 @@ export const calculators: CalculatorFixture[] = [
max: 1,
label: "Screen negative",
tone: "success",
- guidance: "Reinforce low-risk drinking guidance; rescreen opportunistically.",
+ guidance: "Lower-risk screening score band; interpret in clinical context.",
},
{
min: 2,
max: 4,
label: "Clinically significant",
tone: "danger",
- guidance: "Positive screen — take a full drinking history and complete the AUDIT.",
+ guidance: "Positive screening score band; interpret in clinical context.",
},
],
},
@@ -491,20 +576,26 @@ export const calculators: CalculatorFixture[] = [
},
],
bands: [
- { min: 0, max: 2, label: "Lower risk", tone: "success", guidance: "Below screening threshold for both sexes." },
+ {
+ min: 0,
+ max: 2,
+ label: "Lower risk",
+ tone: "success",
+ guidance: "Lower-risk screening score band; interpret in clinical context.",
+ },
{
min: 3,
max: 4,
label: "At threshold",
tone: "warning",
- guidance: "Positive for women at ≥3 and men at ≥4 — brief intervention and full AUDIT.",
+ guidance: "Positive screening score band; interpret in clinical context.",
},
{
min: 5,
max: 12,
label: "Higher risk",
tone: "danger",
- guidance: "Likely hazardous or harmful drinking — full AUDIT, brief intervention, consider referral.",
+ guidance: "Higher screening score band; interpret in clinical context.",
},
],
},
@@ -548,21 +639,21 @@ export const calculators: CalculatorFixture[] = [
max: 4,
label: "Lower indicative risk",
tone: "info",
- guidance: "Consider discharge with follow-up if the clinical assessment agrees.",
+ guidance: "Lower score band; this instrument is not available for score-based decision support.",
},
{
min: 5,
max: 6,
label: "Intermediate",
tone: "warning",
- guidance: "Consider close follow-up or admission; complete a structured risk assessment.",
+ guidance: "Intermediate score band; this instrument is not available for score-based decision support.",
},
{
min: 7,
max: 10,
label: "Higher indicative risk",
tone: "danger",
- guidance: "Admission usually indicated; ensure immediate safety planning.",
+ guidance: "Higher score band; this instrument is not available for score-based decision support.",
},
],
},
@@ -605,33 +696,119 @@ export const calculators: CalculatorFixture[] = [
{ id: "y10", kind: "options", options: ybocsSeverity, text: "Degree of control over compulsive behaviour" },
],
bands: [
- { min: 0, max: 7, label: "Subclinical", tone: "success", guidance: "Symptoms below the clinical range." },
- { min: 8, max: 15, label: "Mild", tone: "info", guidance: "Consider CBT with exposure and response prevention." },
+ {
+ min: 0,
+ max: 7,
+ label: "Subclinical",
+ tone: "success",
+ guidance: "Subclinical score band; interpret in clinical context.",
+ },
+ { min: 8, max: 15, label: "Mild", tone: "info", guidance: "Mild score band; interpret in clinical context." },
{
min: 16,
max: 23,
label: "Moderate",
tone: "warning",
- guidance: "Active treatment: ERP and/or SSRI at adequate dose.",
+ guidance: "Moderate score band; interpret in clinical context.",
},
{
min: 24,
max: 31,
label: "Severe",
tone: "danger",
- guidance: "Intensive treatment; review augmentation options.",
+ guidance: "Severe score band; interpret in clinical context.",
},
{
min: 32,
max: 40,
label: "Extreme",
tone: "danger",
- guidance: "Specialist OCD service involvement recommended.",
+ guidance: "Extreme score band; interpret in clinical context.",
},
],
},
];
+const activeCalculatorIds = new Set(["phq9", "gad7", "k10", "cage", "auditc"]);
+
+function sourceIdFor(id: string) {
+ return `source:${id}`;
+}
+
+function claimIdFor(id: string) {
+ return `claim:${id}:interpretation`;
+}
+
+function metadataFor(id: string): Omit {
+ const active = activeCalculatorIds.has(id);
+ const rightsStatus = id === "mdq" ? "permission_review_required" : active ? "available" : "unknown";
+
+ return {
+ instrumentVersion: `${id.toUpperCase()} catalogue fixture v1`,
+ administrationMethod: "Clinician-entered responses in a browser-session calculator.",
+ intendedPopulation: "Adults unless the linked instrument source specifies otherwise.",
+ timeframe:
+ id === "k10" ? "Past 4 weeks" : id === "cage" || id === "mdq" ? "Lifetime" : "As stated by the instrument.",
+ completionPolicy: "Every item requires an explicit response before a final result is shown.",
+ interpretationPolicy:
+ "Interpretation describes the completed instrument result only and does not determine management.",
+ sourceIds: [sourceIdFor(id), "source:governance"],
+ claimIds: [claimIdFor(id), ...(id === "phq9" ? ["claim:phq9:safety-flag"] : [])],
+ rights: { status: rightsStatus },
+ jurisdiction:
+ id === "k10" || id === "auditc" ? "Australia" : "International instrument with Australian use context",
+ lastReviewed: "2026-09-01",
+ nextReview: "2027-09-01",
+ reviewer: "Clinical safety governance",
+ confidence: active ? "moderate" : "low",
+ riskLevel: active ? "moderate" : "high",
+ instrumentStatus: active ? "active" : "quarantined",
+ evidenceStatus: active ? "verified" : id === "mdq" ? "review_required" : "blocked",
+ releaseStatus: active ? "available" : "quarantined",
+ limitations: [
+ "A score supports assessment and monitoring but does not establish a diagnosis or management decision.",
+ ],
+ unresolvedIssues: active ? [] : ["Not released for active clinical decision support pending governance review."],
+ };
+}
+
+function interpretationFor(id: string, label: string) {
+ if (id === "k10") return `${label} psychological-distress range; it is not a diagnostic category.`;
+ if (id === "auditc")
+ return `${label} completed AUDIT-C range; interpret with Australian alcohol context and history.`;
+ return `${label} completed-score range; interpret with the instrument source, clinical context and limitations.`;
+}
+
+export const allCalculatorFixtures: CalculatorFixture[] = calculatorFixtures.map((fixture) => {
+ const { bands, ...base } = fixture;
+
+ return {
+ ...base,
+ ...metadataFor(fixture.id),
+ summary: `${fixture.abbrev} assessment and monitoring measure with interpretation after complete responses.`,
+ scoringNote: "Final interpretation is available only after every item is explicitly answered.",
+ bands: bands.map((band) => ({
+ min: band.min,
+ max: band.max,
+ label: band.label,
+ tone: band.tone,
+ interpretation: interpretationFor(fixture.id, band.label),
+ })),
+ };
+});
+
+export const calculators = allCalculatorFixtures.filter(
+ (fixture) =>
+ fixture.instrumentStatus === "active" &&
+ fixture.rights.status === "available" &&
+ fixture.evidenceStatus === "verified" &&
+ fixture.releaseStatus === "available",
+);
+
+export const quarantinedCalculators = allCalculatorFixtures.filter(
+ (fixture) => fixture.instrumentStatus === "quarantined",
+);
+
export const domainOrder: CalculatorDomain[] = ["mood", "anxiety", "substance", "risk", "distress"];
/** Extra directory entries shown as "coming soon" in directory-style mockups. */
diff --git a/src/components/calculators/calculator-pathways.ts b/src/components/calculators/calculator-pathways.ts
index 70f77cee4b..496e226164 100644
--- a/src/components/calculators/calculator-pathways.ts
+++ b/src/components/calculators/calculator-pathways.ts
@@ -1,296 +1,65 @@
import type { CalculatorFixture } from "./calculator-fixtures";
import type { DerivedCalculator } from "./calculator-ui";
-/**
- * Score-driven pathway content for the search/detail mockup: what to do next
- * at the current severity band, and which knowledge-base content to surface.
- * Local pathway fixtures — production can later resolve `related` through the live
- * retrieval index instead of hand-authored hrefs.
- */
-
export type PathwayAction = {
label: string;
- detail?: string;
-};
-
-export type RelatedKind = "guideline" | "medication" | "differential" | "service" | "form" | "answer" | "calculator";
-
-export type RelatedItem = {
- title: string;
- kind: RelatedKind;
- /** Route within the app; ignored for kind "calculator". */
- href?: string;
- /** For kind "calculator": switch to this scale in place. */
- calcId?: string;
- note?: string;
- /** Only show once the current band index reaches this value (0-based). */
- minBandIndex?: number;
+ sourceIds: string[];
+ claimIds: string[];
};
-export const relatedKindLabels: Record = {
- guideline: "Guideline",
- medication: "Medication",
- differential: "Differential",
- service: "Service",
- form: "Form",
- answer: "Ask",
- calculator: "Calculator",
+type ClinicalConsideration = PathwayAction & {
+ sourceIds: string[];
+ claimIds: string[];
};
-type CalculatorPathway = {
- /** Parallel to calc.bands; falls back to the band guidance sentence. */
- bandActions?: PathwayAction[][];
- related: RelatedItem[];
+const considerationsByCalculator: Record = {
+ phq9: [
+ {
+ label:
+ "Interpret the completed score alongside diagnostic assessment, impairment, history and current safety assessment.",
+ sourceIds: ["source:phq9"],
+ claimIds: ["claim:phq9:interpretation"],
+ },
+ ],
+ gad7: [
+ {
+ label:
+ "Use the completed score as one part of anxiety assessment, including differential diagnosis and functional impact.",
+ sourceIds: ["source:gad7"],
+ claimIds: ["claim:gad7:interpretation"],
+ },
+ ],
+ k10: [
+ {
+ label: "K10 describes psychological distress and is not a diagnostic or disposition category.",
+ sourceIds: ["source:k10"],
+ claimIds: ["claim:k10:interpretation"],
+ },
+ ],
+ cage: [
+ {
+ label:
+ "CAGE is a lifetime problem-drinking screen. Interpret it with alcohol history, current use and withdrawal assessment.",
+ sourceIds: ["source:cage"],
+ claimIds: ["claim:cage:interpretation"],
+ },
+ ],
+ auditc: [
+ {
+ label:
+ "Interpret AUDIT-C with Australian standard-drink context, alcohol history and assessment of dependence or withdrawal where relevant.",
+ sourceIds: ["source:auditc"],
+ claimIds: ["claim:auditc:interpretation"],
+ },
+ ],
};
-const pathways: Record = {
- phq9: {
- bandActions: [
- [
- { label: "Reassure and reinforce sleep, activity, and alcohol basics" },
- { label: "Rescreen if the clinical picture changes" },
- ],
- [
- { label: "Watchful waiting with psychoeducation" },
- { label: "Repeat PHQ-9 in 2–4 weeks", detail: "Track the trend, not the single score" },
- { label: "Consider low-intensity psychological therapy" },
- ],
- [
- { label: "Confirm DSM-5 criteria for a major depressive episode" },
- { label: "Start psychological therapy; consider an SSRI" },
- { label: "Screen for bipolarity before any antidepressant", detail: "Run the MDQ below" },
- ],
- [
- { label: "Active treatment: pharmacotherapy and/or psychotherapy" },
- { label: "Screen for bipolarity before prescribing", detail: "Run the MDQ below" },
- { label: "Safety-net and book review within 1–2 weeks" },
- ],
- [
- { label: "Initiate pharmacotherapy; consider psychiatry referral" },
- { label: "Assess psychotic features and ECT indications" },
- { label: "Complete a structured suicide-risk assessment now" },
- ],
- ],
- related: [
- {
- title: "Major depression — stepped treatment pathway",
- kind: "guideline",
- href: "/documents/search?q=major+depression+treatment+pathway",
- },
- {
- title: "Sertraline — initiation and monitoring",
- kind: "medication",
- href: "/medications/sertraline",
- minBandIndex: 2,
- },
- { title: "Low mood — differential diagnoses", kind: "differential", href: "/differentials" },
- { title: "Mental health treatment plan", kind: "form", href: "/forms", minBandIndex: 1 },
- {
- title: "Acute mental health team referral",
- kind: "service",
- href: "/services",
- minBandIndex: 3,
- },
- {
- title: "ECT consent requirements",
- kind: "answer",
- href: "/?mode=answer&q=ECT+consent+requirements",
- minBandIndex: 4,
- },
- { title: "MDQ — bipolar screen before antidepressants", kind: "calculator", calcId: "mdq", minBandIndex: 2 },
- ],
- },
- gad7: {
- bandActions: [
- [{ label: "No action beyond routine care" }],
- [{ label: "Psychoeducation and active monitoring" }, { label: "Repeat GAD-7 at next review" }],
- [
- { label: "Confirm the anxiety diagnosis and rule out mimics", detail: "Thyroid, stimulants, withdrawal" },
- { label: "Refer for CBT; consider an SSRI" },
- { label: "Co-screen for depression", detail: "Run the PHQ-9 below" },
- ],
- [
- { label: "Active treatment: CBT and/or SSRI at adequate dose" },
- { label: "Assess functional impact and comorbid depression" },
- { label: "Avoid initiating benzodiazepines for chronic anxiety" },
- ],
- ],
- related: [
- {
- title: "Generalised anxiety — management pathway",
- kind: "guideline",
- href: "/documents/search?q=generalised+anxiety+management",
- },
- {
- title: "Escitalopram — dosing and cautions",
- kind: "medication",
- href: "/medications/escitalopram",
- minBandIndex: 2,
- },
- { title: "Anxiety — differential diagnoses", kind: "differential", href: "/differentials" },
- {
- title: "Benzodiazepine deprescribing",
- kind: "answer",
- href: "/?mode=answer&q=benzodiazepine+deprescribing",
- minBandIndex: 3,
- },
- { title: "PHQ-9 — depression co-screen", kind: "calculator", calcId: "phq9", minBandIndex: 2 },
- ],
- },
- k10: {
- bandActions: [
- [{ label: "Likely well — no specific action" }],
- [{ label: "Brief intervention and lifestyle advice" }, { label: "Repeat K10 at follow-up" }],
- [
- { label: "Structured assessment for anxiety and depression", detail: "PHQ-9 and GAD-7 below" },
- { label: "Consider a mental health treatment plan" },
- ],
- [
- { label: "Comprehensive assessment and active treatment" },
- { label: "Prepare a mental health treatment plan and referral" },
- { label: "Assess suicide risk directly" },
- ],
- ],
- related: [
- { title: "Mental health treatment plan", kind: "form", href: "/forms", minBandIndex: 2 },
- {
- title: "Psychological distress — stepped care",
- kind: "guideline",
- href: "/documents/search?q=stepped+care+psychological+distress",
- },
- { title: "PHQ-9 — depression severity", kind: "calculator", calcId: "phq9", minBandIndex: 2 },
- { title: "GAD-7 — anxiety severity", kind: "calculator", calcId: "gad7", minBandIndex: 2 },
- ],
- },
- mdq: {
- related: [
- {
- title: "Bipolar disorder — assessment and referral",
- kind: "guideline",
- href: "/documents/search?q=bipolar+disorder+assessment",
- },
- {
- title: "Lithium — initiation and monitoring",
- kind: "medication",
- href: "/medications/lithium-carbonate-ir-sr",
- minBandIndex: 1,
- },
- { title: "Elevated mood — differential diagnoses", kind: "differential", href: "/differentials" },
- { title: "PHQ-9 — current depressive severity", kind: "calculator", calcId: "phq9" },
- ],
- },
- cage: {
- bandActions: [
- [{ label: "Reinforce low-risk drinking guidance" }, { label: "Rescreen opportunistically" }],
- [
- { label: "Take a full drinking history" },
- { label: "Complete the full AUDIT", detail: "AUDIT-C below covers consumption only" },
- { label: "Brief intervention; assess dependence and withdrawal risk" },
- { label: "Consider thiamine if dependence is likely" },
- ],
- ],
- related: [
- { title: "AUDIT-C — consumption screen", kind: "calculator", calcId: "auditc" },
- {
- title: "Alcohol withdrawal management",
- kind: "guideline",
- href: "/documents/search?q=alcohol+withdrawal+management",
- minBandIndex: 1,
- },
- { title: "Thiamine — Wernicke prophylaxis", kind: "medication", href: "/medications/thiamine", minBandIndex: 1 },
- { title: "Drug and alcohol service referral", kind: "service", href: "/services", minBandIndex: 1 },
- ],
- },
- auditc: {
- bandActions: [
- [{ label: "Below screening threshold — reinforce low-risk limits" }],
- [
- { label: "Positive for women at ≥3, men at ≥4 — brief intervention" },
- { label: "Complete the full 10-item AUDIT" },
- ],
- [
- { label: "Likely hazardous or harmful drinking — full AUDIT" },
- { label: "Brief intervention; assess dependence and withdrawal risk" },
- { label: "Consider drug and alcohol service referral" },
- ],
- ],
- related: [
- {
- title: "Alcohol — brief intervention guide",
- kind: "guideline",
- href: "/documents/search?q=alcohol+brief+intervention",
- },
- { title: "CAGE — dependence signal check", kind: "calculator", calcId: "cage" },
- { title: "Drug and alcohol service referral", kind: "service", href: "/services", minBandIndex: 2 },
- {
- title: "Safe drinking limits",
- kind: "answer",
- href: "/?mode=answer&q=safe+drinking+limits+australia",
- },
- ],
- },
- sadpersons: {
- bandActions: [
- [
- { label: "Complete a structured risk assessment regardless of score" },
- { label: "Safety plan and follow-up if discharging" },
- ],
- [
- { label: "Structured risk assessment now" },
- { label: "Consider admission or intensive community follow-up" },
- { label: "Involve family or carers where safe to do so" },
- ],
- [
- { label: "Admission usually indicated — ensure immediate safety" },
- { label: "Continuous observation while in the department" },
- { label: "Structured risk assessment and psychiatry review" },
- ],
- ],
- related: [
- {
- title: "Suicide risk assessment framework",
- kind: "guideline",
- href: "/documents/search?q=suicide+risk+assessment+framework",
- },
- { title: "Safety planning template", kind: "form", href: "/forms" },
- { title: "13YARN — crisis support referral", kind: "service", href: "/services" },
- { title: "Acute mental health team", kind: "service", href: "/services", minBandIndex: 1 },
- ],
- },
- ybocs: {
- related: [
- {
- title: "OCD — exposure and response prevention",
- kind: "guideline",
- href: "/documents/search?q=OCD+exposure+response+prevention",
- },
- {
- title: "Sertraline — higher-dose OCD treatment",
- kind: "medication",
- href: "/medications/sertraline",
- minBandIndex: 2,
- },
- {
- title: "SSRI augmentation in OCD",
- kind: "answer",
- href: "/?mode=answer&q=SSRI+augmentation+OCD",
- minBandIndex: 3,
- },
- ],
- },
-};
-
-/** Actions for the current band — falls back to the band's guidance sentence. */
+/** Clinical considerations are source-linked and unavailable until completion. */
export function actionsForBand(calc: CalculatorFixture, derived: DerivedCalculator): PathwayAction[] {
- const bandIndex = derived.band ? calc.bands.indexOf(derived.band) : -1;
- if (bandIndex < 0) return [];
- const custom = pathways[calc.id]?.bandActions?.[bandIndex];
- if (custom?.length) return custom;
- return derived.result.guidance ? [{ label: derived.result.guidance }] : [];
-}
-
-/** Related knowledge-base content visible at the current band. */
-export function relatedForBand(calc: CalculatorFixture, derived: DerivedCalculator): RelatedItem[] {
- const bandIndex = derived.band ? calc.bands.indexOf(derived.band) : 0;
- return (pathways[calc.id]?.related ?? []).filter((item) => (item.minBandIndex ?? 0) <= bandIndex);
+ if (!derived.complete) return [];
+ return (considerationsByCalculator[calc.id] ?? []).map(({ label, sourceIds, claimIds }) => ({
+ label,
+ sourceIds,
+ claimIds,
+ }));
}
diff --git a/src/components/calculators/calculator-sheet.tsx b/src/components/calculators/calculator-sheet.tsx
index 2e592dabb5..8328d28c25 100644
--- a/src/components/calculators/calculator-sheet.tsx
+++ b/src/components/calculators/calculator-sheet.tsx
@@ -16,13 +16,7 @@ import {
progressLabel,
type AnswerMap,
} from "./calculator-ui";
-import {
- CalculatorSearchHome,
- NextActionsPanel,
- RelatedContentPanel,
- ScorePanel,
- type SessionAnswers,
-} from "./search-detail";
+import { CalculatorSearchHome, NextActionsPanel, ScorePanel, type SessionAnswers } from "./search-detail";
/**
* Popup variant of the search flow: the individual calculator opens as a
@@ -35,13 +29,11 @@ export function CalculatorSheet({
answers,
onAnswersChange,
onClose,
- onOpenCalculator,
}: {
calc: CalculatorFixture;
answers: AnswerMap;
onAnswersChange: (next: AnswerMap) => void;
onClose: () => void;
- onOpenCalculator: (calcId: string) => void;
}) {
const derived = deriveCalculator(calc, answers);
const closeRef = useRef(null);
@@ -180,10 +172,7 @@ export function CalculatorSheet({
-
-
-
-
+ onAnswersChange({})} />
@@ -220,7 +209,6 @@ export function CalculatorsPopupSheetMockup() {
answers={session[activeCalc.id] ?? {}}
onAnswersChange={(next) => setSession((prev) => ({ ...prev, [activeCalc.id]: next }))}
onClose={() => setOpenId(null)}
- onOpenCalculator={setOpenId}
/>
) : null}
diff --git a/src/components/calculators/calculator-ui.tsx b/src/components/calculators/calculator-ui.tsx
index eafed9f3c8..39190b8084 100644
--- a/src/components/calculators/calculator-ui.tsx
+++ b/src/components/calculators/calculator-ui.tsx
@@ -4,7 +4,6 @@ import { AlertTriangle, Check, CheckCheck, ClipboardCopy, RotateCcw, type Lucide
import { useState } from "react";
import { cn } from "@/components/ui-primitives";
-import { missingValuePhrase } from "@/components/ui/missing-value";
import type { CalculatorFixture, CalculatorItem, CalculatorTone, ScoreBand } from "./calculator-fixtures";
@@ -62,6 +61,7 @@ type CalculatorState = {
/** Checkbox-style items currently ticked. */
checkedCount: number;
checkboxItemCount: number;
+ checkboxAnsweredCount: number;
complete: boolean;
started: boolean;
band: ScoreBand | undefined;
@@ -86,8 +86,7 @@ function mdqResult(answers: AnswerMap, symptomScore: number): CalculatorResult {
return {
label: "Positive screen",
tone: "danger",
- guidance:
- "All three criteria met — proceed to a structured bipolar-disorder assessment before treatment changes.",
+ guidance: "All three screening criteria are met; interpret this completed screen in clinical context.",
};
}
if (symptomsMet) {
@@ -103,7 +102,7 @@ function mdqResult(answers: AnswerMap, symptomScore: number): CalculatorResult {
return {
label: "Negative screen",
tone: "success",
- guidance: "Below the 7-symptom threshold. Rescreen if the history changes.",
+ guidance: "Below the symptom-count threshold; interpret this completed screen in clinical context.",
};
}
@@ -117,43 +116,28 @@ export function deriveCalculator(calc: CalculatorFixture, answers: AnswerMap): D
const answeredCount = optionItems.filter((item) => answers[item.id] !== undefined).length;
const checkedCount = checkboxItems.filter((item) => answers[item.id] === 1).length;
const checkboxAnsweredCount = checkboxItems.filter((item) => answers[item.id] !== undefined).length;
- // Checkbox-only scales complete once every yes/no item has an explicit value
- // (seeded to 0 on open). Mixed scales (MDQ) complete on answered options;
- // an unticked symptom checkbox is a valid "not endorsed", not a gap.
- const complete =
- answeredCount === optionItems.length && (optionItems.length > 0 || checkboxAnsweredCount === checkboxItems.length);
+ // A missing response is never an implicit negative. This applies equally to
+ // checkbox and options items, including the 13 MDQ symptoms, co-occurrence
+ // and impairment criteria.
+ const complete = calc.items.every((item) => answers[item.id] !== undefined);
const started = Object.values(answers).some((value) => value !== undefined);
- // Only publish a severity band when the reading is trustworthy. Options scales
- // with a zero floor (PHQ-9/GAD-7) may show a provisional band as they fill in,
- // but checkbox-only screens (CAGE/SAD PERSONS) must wait for completion — a
- // half-ticked screen still has undefined items and must never read "negative" —
- // and non-zero-minimum scales (K10: 10–50) must not publish below their floor
- // (nine "None of the time" answers sum to 9).
- const showBand = isCheckboxOnly(calc) ? complete : calc.minScore === 0 || complete;
- const band = showBand ? bandForScore(calc, score) : undefined;
- // Two different absences, and only one of them is a withholding. `showBand`
- // false means a band COULD be read off the answers so far and we are choosing
- // not to publish it. A band absent while `showBand` is true would instead mean
- // the fixture's band table has a gap at this score — nothing is being held
- // back, we simply cannot name the band. (`tests/calculator-scoring.test.ts`
- // pins every fixture's bands as contiguous, so that branch is unreachable
- // today; it exists so a future fixture edit cannot turn a table gap into a
- // false claim that a result is being withheld from the clinician.)
- const missingBandReason = showBand ? "unknown" : "withheld_until_complete";
+ const band = complete ? bandForScore(calc, score) : undefined;
const flags = calc.items
.filter((item) => item.flag && itemScore(item, answers[item.id]) > 0)
.map((item) => item.flag as string);
- const result: CalculatorResult =
- calc.id === "mdq"
+ const result: CalculatorResult = !complete
+ ? {
+ label: "Incomplete",
+ tone: "info",
+ guidance: "Answer every item before interpreting this result.",
+ }
+ : calc.id === "mdq"
? mdqResult(answers, score)
: {
- // `label` is typed `string` and flows into `formatResultSummary`'s
- // clipboard line, so this is the plain-string form of the primitive —
- // same six phrases, never a second vocabulary.
- label: band?.label ?? missingValuePhrase(missingBandReason),
+ label: band?.label ?? "Unavailable",
tone: band?.tone ?? "info",
- guidance: band?.guidance ?? "",
+ guidance: band?.interpretation ?? "",
};
return {
@@ -162,6 +146,7 @@ export function deriveCalculator(calc: CalculatorFixture, answers: AnswerMap): D
optionItemCount: optionItems.length,
checkedCount,
checkboxItemCount: checkboxItems.length,
+ checkboxAnsweredCount,
complete,
started,
band,
@@ -480,8 +465,9 @@ export function ResetButton({ onReset, disabled }: { onReset: () => void; disabl
}
export function progressLabel(state: DerivedCalculator): string {
- if (state.optionItemCount === 0) return `${state.checkedCount} of ${state.checkboxItemCount} endorsed`;
- const answered = `${state.answeredCount} of ${state.optionItemCount} answered`;
+ const answeredCount = state.answeredCount + state.checkboxAnsweredCount;
+ const itemCount = state.optionItemCount + state.checkboxItemCount;
+ const answered = `${answeredCount} of ${itemCount} answered`;
return state.checkboxItemCount > 0 ? `${answered} · ${state.checkedCount} endorsed` : answered;
}
@@ -523,7 +509,7 @@ export function CopyResultButton({
;
-const relatedKindChip: Record = {
- guideline:
- "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]",
- medication:
- "border-[color:var(--type-table-border)] bg-[color:var(--type-table-soft)] text-[color:var(--type-table)]",
- differential:
- "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]",
- service:
- "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]",
- form: "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]",
- answer: "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]",
- calculator:
- "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
-};
-
/** First item whose text matches the query — shown as match context in results. */
function matchContext(calc: CalculatorFixture, query: string): string | null {
const matched = calc.items.find((item) => item.text.toLowerCase().includes(query));
@@ -375,7 +353,8 @@ export function CalculatorSearchHome({
)}
- Scores support clinical judgement — they never replace a full assessment. Nothing entered here is stored.
+ Calculator answers remain in this browser session and are not intentionally submitted by this calculator
+ interface. Application telemetry and clinical-record documentation are governed separately.
);
@@ -388,33 +367,30 @@ export function NextActionsPanel({ calc, derived }: { calc: CalculatorFixture; d
return (
- Answer the items and recommendations for the scored severity band appear here.
+ Answer every item before source-linked clinical considerations are shown.
- Scores support clinical judgement — they never replace a full assessment. Every calculator cites its source and
- maps its result to next clinical actions. Nothing you enter is stored.
+ Scores support clinical judgement — they never replace a full assessment. Calculator answers remain in this
+ browser session and are not intentionally submitted by this calculator interface. Application telemetry and
+ clinical-record documentation are governed separately.
- {plannedCalculators.length} more calculators (CIWA-Ar, EPDS, COWS) are coming next.
+ {plannedCalculators.length} candidate calculators remain governance-gated pending version, rights and workflow
+ review.
);
@@ -509,7 +511,6 @@ export function CalculatorsSearchPage({ initialQuery = "" }: { initialQuery?: st
answers={session[activeCalc.id] ?? {}}
onAnswersChange={(next) => setSession((current) => ({ ...current, [activeCalc.id]: next }))}
onClose={() => setOpenId(null)}
- onOpenCalculator={setOpenId}
/>
) : null}
>
diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx
index 73c2c298aa..0728051bc3 100644
--- a/src/components/clinical-dashboard/ClinicalSidebar.tsx
+++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx
@@ -133,6 +133,10 @@ const collapsedSidebarControl =
const collapsedSidebarButton = `grid ${collapsedSidebarControl}`;
const collapsedSidebarActiveButton =
"border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]";
+/* One divider for the whole rail. Every group separator is the same 32px rule
+ with the same 12px of air on both sides, so the rail reads as one column of
+ 48px controls broken into groups rather than four rules with three spacings. */
+const collapsedSidebarDivider = "my-1.5 h-px w-8 shrink-0 bg-[color:var(--border)]";
/* Phone drawer header (ClinicalMobileSidebar).
*
@@ -758,6 +762,7 @@ export function ClinicalSidebarContent({
)}
>
-
+
{collapseLocked ? (
-
+
) : (
<>
{/* Tablet: the expanded panel does not exist below lg, so show a
static brand mark instead of a dead expand control. */}
-
+ onCollapsedChange(false)}
>
-
+
>
)}
-
+
+ {/* Mirrors the rule under the brand mark, so the rail is bracketed the
+ same way at both ends and the two footer controls read as one group. */}
+
span]:hidden", collapsedSidebarButton)}
+ className={cn("gap-0 px-0 [&>span]:hidden", collapsedSidebarButton)}
aria-label="Settings"
title="Settings"
onClick={onOpenSettings}
@@ -1048,7 +1072,7 @@ function ClinicalCollapsedRail({
onPointerEnter={onPrefetchAccount}
onFocus={onPrefetchAccount}
data-testid="collapsed-account-settings"
- className="mt-3 grid h-tap w-tap shrink-0 place-items-center rounded-full border border-[color:var(--clinical-accent-border)]/60 bg-[color:var(--clinical-accent-soft)] text-xs font-bold text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
+ className="grid h-tap w-tap shrink-0 place-items-center rounded-full border border-[color:var(--clinical-accent-border)]/60 bg-[color:var(--clinical-accent-soft)] text-xs font-bold text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
title={identity.signedIn ? identity.detail : "Set up workspace"}
aria-label={accountLabel}
>
@@ -1188,7 +1212,7 @@ export function ClinicalMobileSidebar({
closeLabel="Close Clinical Guide menu"
placement="left"
contentClassName={hiddenFrom === "lg" ? "lg:hidden" : "md:hidden"}
- headerLeading={}
+ headerLeading={}
headerClassName={drawerHeader}
titleClassName={drawerHeaderTitle}
closeButtonClassName={drawerHeaderClose}
diff --git a/src/components/clinical-dashboard/account-setup-dialog.tsx b/src/components/clinical-dashboard/account-setup-dialog.tsx
index 366d690b8a..f88dec7466 100644
--- a/src/components/clinical-dashboard/account-setup-dialog.tsx
+++ b/src/components/clinical-dashboard/account-setup-dialog.tsx
@@ -214,7 +214,7 @@ function AccountOrientationPanel() {
data-testid="account-workspace-mark"
className="grid size-tap place-items-center rounded-xl border border-[color:var(--border-lux)] bg-[color:var(--surface)] shadow-[var(--e2),var(--shadow-inset)]"
>
-
+
{pending ? (
diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx
index 871c86ce67..5cf75385aa 100644
--- a/src/components/clinical-dashboard/answer-content.tsx
+++ b/src/components/clinical-dashboard/answer-content.tsx
@@ -1,11 +1,13 @@
"use client";
-import { Fragment, memo, useState } from "react";
+import { Fragment, memo, useId, useState } from "react";
import { CircleAlert, ChevronDown, Copy } from "lucide-react";
import { SafeBoldText } from "@/components/SafeBoldText";
import { chatActionRow, chatAnswerText, chatMicroAction, cn, textMuted } from "@/components/ui-primitives";
import { compactVerificationWordingFor, type VerificationState } from "@/components/ui/verification-notice";
+import type { AnswerState } from "@/components/ui/answer-state";
+import { RetrievalStateBanner } from "@/components/ui/retrieval-state-banner";
import {
cleanDisplayTitle,
comparableAnswerText,
@@ -384,6 +386,8 @@ export function NaturalLanguageAnswer({
preformatted = false,
sourceOnly,
sourceOnlyVerificationState = "source_only",
+ answerState,
+ onOpenStateSource,
bestSource,
sources,
sourceLinks,
@@ -394,6 +398,7 @@ export function NaturalLanguageAnswer({
onOpenSource,
onOpenRailSource,
openSourceIndex = null,
+ showCopyAction = true,
}: {
// Raw answer text (server bold intact); this component owns display
// sanitization so can render the high-yield emphasis.
@@ -402,6 +407,10 @@ export function NaturalLanguageAnswer({
preformatted?: boolean;
sourceOnly: boolean;
sourceOnlyVerificationState?: VerificationState;
+ /** The answer-level state shown beside Source-only when source currency is degraded. */
+ answerState?: AnswerState;
+ /** Direct route used by expanded source-currency detail. */
+ onOpenStateSource?: (sourceId: string, locator?: string) => void;
bestSource: BestSourceRecommendation | null;
sources: SearchResult[];
sourceLinks: SourceLink[];
@@ -435,8 +444,11 @@ export function NaturalLanguageAnswer({
onOpenRailSource?: (index: number) => void;
/** Which rail row the drawer is showing, so the mark and its sentence can light up. */
openSourceIndex?: number | null;
+ /** Historical turns keep their local copy action; the live turn renders the combined utility row outside. */
+ showCopyAction?: boolean;
}) {
const [sourceOnlyNoticeOpen, setSourceOnlyNoticeOpen] = useState(false);
+ const sourceOnlyDetailId = useId();
const { preferences } = useAppPreferences();
const fragments = primaryAnswerDisplayFragments(text, { preformatted, preserveBold: true });
if (!fragments.length) return null;
@@ -499,55 +511,66 @@ export function NaturalLanguageAnswer({
{/* No negative bottom margin. It pulled the rail up by 8px, and the rail
heading used to carry a top border — the two collided and drew a rule
straight through the Source-only pill. */}
-
- {sourceOnly ? (
-
- 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="source-only-disclosure-detail"
+ {sourceOnly || (answerState?.kind === "stale_evidence" && onOpenStateSource) ? (
+
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}
>
-
{suggestions.map((suggestion, index) => (
diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx
index c350df4297..06e1dee966 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 { ShieldAlert } 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";
@@ -13,37 +13,51 @@ import {
UserQuestionBubble,
} from "@/components/clinical-dashboard/answer-content";
import { answerStateForAnswer } from "@/components/clinical-dashboard/answer-copy-payload";
-import {
- AnswerSupportSummaryCard,
- answerSupportPriority,
- primaryVisualTable,
- SafetyFindingsListContent,
-} from "@/components/clinical-dashboard/evidence-panels";
+import { AnswerUtilityActions, SafetyFindingsListContent } from "@/components/clinical-dashboard/evidence-panels";
import { AnswerSourceDrawer } from "@/components/clinical-dashboard/answer-source-drawer";
import { useAnswerSourceSelection } from "@/components/clinical-dashboard/use-answer-source-selection";
import { CanonicalAnswerTables } from "@/components/clinical-dashboard/visual-evidence";
import { annotateSourceAttachments, buildAnswerSourceRows } from "@/components/clinical-dashboard/answer-source-rows";
import { citedDocumentHref } from "@/components/clinical-dashboard/source-actions";
import { AnswerCard, type AnswerSupportStrength } from "@/components/ui/answer-card";
+import { VerificationNotice } from "@/components/ui/verification-notice";
import { Sheet } from "@/components/ui/sheet";
-import { answerSurface, cn, iconTilePremium } from "@/components/ui-primitives";
+import { answerSurface, cn } from "@/components/ui-primitives";
import { type AnswerRenderModel } from "@/lib/answer-render-policy";
import { type AppModeId } from "@/lib/app-modes";
import { extractSafetyFindings } from "@/lib/clinical-safety";
-import type {
- AnswerSection,
- BestSourceRecommendation,
- EvidenceSummary,
- QuoteCard,
- RagAnswer,
- SearchResult,
-} from "@/lib/types";
+import type { BestSourceRecommendation, EvidenceSummary, QuoteCard, RagAnswer, SearchResult } from "@/lib/types";
/**
* Renders a staged answer with inline content and optional clinical notes, evidence, safety findings, and follow-up interfaces.
*
* @returns The staged answer surface.
*/
+/** The header status chips share one shape so they read as one status line. */
+const chipShape =
+ "inline-flex min-h-6 items-center gap-1 rounded-full border px-2 text-3xs font-semibold uppercase tracking-eyebrow";
+/**
+ * An interactive chip is a small pill inside a full-size button, not a small
+ * button. `before:-inset-y-*` hit expansion draws the same 48px region and is
+ * what DocumentTagCloud uses, but it is invisible to `boundingBox()` and so to
+ * every tap-target check in the suite — and the safety chip is the only route to
+ * the safety-critical findings sheet, which is the last control on this surface
+ * that should rest on a target no gate can see. The button carries the height,
+ * the inner pill carries the look.
+ */
+const chipButton =
+ // Negative vertical margin is forbidden here. `-my-3` keeps `boundingBox()`
+ // honest while moving the hit region outside the element's own layout box, so
+ // the chip silently sits on top of its neighbours. Measured in Chromium at
+ // 390px before that was removed: the safety chip covered a 133x9px band of the
+ // support chip beside it and a 133x2px band of the answer prose below, and a
+ // tap in either band opened the chip instead of doing nothing. Keep the full
+ // 48px hitbox in layout; `AnswerCard` gives these chips a row of their own so
+ // the honest height costs nothing.
+ "inline-flex min-h-12 shrink-0 items-center focus-visible:outline-none";
+const chipFocus =
+ "group-focus-visible:outline group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-[color:var(--focus)]";
+
function StagedAnswerResultSurfaceImpl({
answer,
query,
@@ -51,20 +65,18 @@ function StagedAnswerResultSurfaceImpl({
sourceSummary,
renderModel,
weakEvidence,
- answerGrounded,
sources,
- safeAnswerSections,
safetyFindings,
copiedAnswer,
pendingFeedback,
onCopyAnswer,
onSubmitFeedback,
onFollowUpQuote,
+ crossModeQueries,
+ onCrossModeSearch,
followUpSuggestions,
onPickFollowUpSuggestion,
followUpSuggestionsDisabled = false,
- crossModeQueries,
- onCrossModeSearch,
onScopeDocument,
}: {
answer: RagAnswer;
@@ -73,20 +85,18 @@ function StagedAnswerResultSurfaceImpl({
sourceSummary?: EvidenceSummary;
renderModel: AnswerRenderModel;
weakEvidence: boolean;
- answerGrounded: boolean;
sources: SearchResult[];
- safeAnswerSections: Array;
safetyFindings: ReturnType;
copiedAnswer: boolean;
pendingFeedback: AnswerFeedbackType | null;
onCopyAnswer: () => void;
onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void;
onFollowUpQuote?: (quote: QuoteCard) => void;
+ crossModeQueries?: Array;
+ onCrossModeSearch?: (mode: AppModeId, query: string) => void;
followUpSuggestions?: string[];
onPickFollowUpSuggestion?: (suggestion: string) => void;
followUpSuggestionsDisabled?: boolean;
- crossModeQueries?: Array;
- onCrossModeSearch?: (mode: AppModeId, query: string) => void;
/** Narrows the search to one document, from the source drawer's overflow menu. */
onScopeDocument?: (documentId: string) => void;
}) {
@@ -124,8 +134,8 @@ function StagedAnswerResultSurfaceImpl({
: renderModel.trust === "low"
? "limited"
: "unassessed";
- const centralVisualEvidence = primaryVisualTable(answer);
const [safetyFindingsOpen, setSafetyFindingsOpen] = useState(false);
+ const [evidenceGapsOpen, setEvidenceGapsOpen] = useState(false);
/**
* Which source the drawer is showing, whether a claim put it there, and that
* claim's own support status — reset whenever the answer beneath them changes,
@@ -173,11 +183,6 @@ function StagedAnswerResultSurfaceImpl({
() => answerStateForAnswer({ answer, sources, weakEvidence }),
[answer, sources, weakEvidence],
);
- const priority = answerSupportPriority(answer, safeAnswerSections, centralVisualEvidence, safetyFindings, {
- grounded: answerGrounded,
- weakEvidence,
- answerState,
- });
// Built once so both arms of the `ready` / degraded split below stay identical.
// The split exists only because `AnswerCardProps` discriminates on `state` to make
// `onOpenSource` required for a degraded card (DECISIONS §Q1), and a union-typed
@@ -199,6 +204,126 @@ function StagedAnswerResultSurfaceImpl({
// collapsed interactive disclosure is not part of the printed record.
className: sourceOnly ? "hidden print:flex" : undefined,
};
+ /**
+ * The header status line the approved specimen draws: the support chip (owned
+ * 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
+ * findings — never a decorative label. Its count is worded, not bare: "2
+ * safety notes" survives forced-colors and greyscale print, where a coloured
+ * chip alone would not.
+ */
+ const answerMetaChips =
+ safetyFindings.length > 0 ? (
+
+
+
+ {safetyFindings.length} {safetyFindings.length === 1 ? "safety note" : "safety notes"}
+
+
+ ) : null;
+ /**
+ * Evidence gaps sit with the other status chips rather than in the action row.
+ * They are a statement about the answer's evidence, like the safety notes
+ * beside them — and the action row the specimen draws is Copy plus the two
+ * verdicts, which at 390px is already the full width of the row.
+ */
+ const answerMetaChipsWithGaps =
+ renderModel.warnings.length > 0 ? (
+ <>
+ {answerMetaChips}
+ setEvidenceGapsOpen((current) => !current)}
+ className={cn("group", chipButton)}
+ aria-expanded={evidenceGapsOpen}
+ // Unconditional, because the panel below is mounted whether or not it
+ // is open. The conditional attribute this replaces was well formed —
+ // attribute and target appeared and disappeared together, which is
+ // what the feedback trigger in `evidence-panels.tsx` still does, and
+ // correctly. It is only redundant here now that the target is always
+ // present.
+ aria-controls="answer-evidence-gaps-detail"
+ >
+
+
+ {renderModel.warnings.length} evidence {renderModel.warnings.length === 1 ? "gap" : "gaps"}
+ {/* The chip looked identical open and closed, so on a phone the only
+ way to tell was to find the panel. */}
+
+
+
+ >
+ ) : (
+ answerMetaChips
+ );
+
+ /**
+ * 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) {
+ 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.
- */
- const showInlineSupportCard = Boolean(priority || renderModel.warnings.length > 0 || onSubmitFeedback);
return (
@@ -270,7 +389,16 @@ function StagedAnswerResultSurfaceImpl({
reader nothing. */}
{answerState.kind === "ready" ? (
-
+
{answerProse}
) : (
@@ -279,41 +407,46 @@ function StagedAnswerResultSurfaceImpl({
verification={answerVerification}
support={answerSupport}
frame="bare"
+ retrievalStatePlacement={answerState.kind === "stale_evidence" ? "content" : "header"}
+ verificationPlacement="content"
+ metaChips={answerMetaChipsWithGaps}
+ 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
// 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"
- />
+
+
+ {/* The governed caution, placed here rather than above the prose
+ (owner decision, 2026-08-31, matching the approved specimen).
+ `verificationPlacement="content"` on both AnswerCard arms is the
+ other half of this: the card still owns the wording and still
+ refuses to render an answer without it, and this is the surface
+ honouring the obligation that prop documents. The words are
+ unchanged. */}
+
+
+ {/* Kept, though the approved specimen does not draw it: that specimen
+ is one answer with no library matches to show, and this collapsed
+ line is the only route from an answer to the Medication and
+ Differentials records behind it. Removing chrome a picture omits
+ is one thing; removing a navigation route on the same evidence is
+ another. Still collapsed, still below the caution. */}
+ {crossModeQueries?.length && onCrossModeSearch ? (
+
) : 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"). */}
{followUpSuggestions?.length && onPickFollowUpSuggestion ? (
) : null}
-
- {crossModeQueries?.length && onCrossModeSearch ? (
-
- ) : null}
@@ -365,13 +494,23 @@ function StagedAnswerResultSurfaceImpl({
title="Safety-critical source findings"
description="Items come from source text. Verify before clinical use."
closeLabel="Close safety findings"
+ // The warning tones are written out rather than layered onto
+ // `iconTilePremium`: that recipe carries the clinical-accent border and
+ // background, so appending `text-…` recoloured only the glyph — the sheet
+ // opened with an amber shield sitting in a blue tile while the card that
+ // opens it drew an amber one. This matches `AnswerSupportSummaryCard`'s
+ // tile exactly, so the colour the design assigns to the icon tile is the
+ // same on both sides of the tap.
headerLeading={
-
+
}
+ // Neutral for the same reason as the trigger row's count: the header's
+ // icon tile and title carry the state, so the number itself must not be a
+ // status-coloured numeral.
titleAccessory={
-
+
{safetyFindings.length}
}
diff --git a/src/components/clinical-dashboard/answer-source-drawer.tsx b/src/components/clinical-dashboard/answer-source-drawer.tsx
index 5835b07910..cdaf5841e1 100644
--- a/src/components/clinical-dashboard/answer-source-drawer.tsx
+++ b/src/components/clinical-dashboard/answer-source-drawer.tsx
@@ -18,7 +18,6 @@ import { cn, glassOverlaySurface, subtleStatusPill, textMuted } from "@/componen
import { logSourceOpen } from "@/components/clinical-dashboard/source-actions";
import { cleanDisplayTitle, sourceQuoteDisplayText } from "@/components/clinical-dashboard/display-text";
import { SignedImage } from "@/components/clinical-dashboard/signed-image";
-import { useDocumentCoverImageId } from "@/components/clinical-dashboard/use-document-cover";
import { CanonicalAnswerTables } from "@/components/clinical-dashboard/visual-evidence";
import {
answerSourceRailRowId,
@@ -140,9 +139,8 @@ export function AnswerSourceDrawer({
const sourceTables = open ? tablesForSource(tables, sources, openIndex) : [];
const sourceImages = open ? imagesForSource(visualEvidence, sources, openIndex) : [];
const stale = source ? sourceRowIsStale(source) : false;
- // Hooks cannot be conditional, so this asks for the open source's cover on
- // every render and resolves to null while the drawer is closed.
- const { coverImageId, markCoverUnavailable } = useDocumentCoverImageId(source?.documentId);
+ // Null unless a claim opened the drawer; see `sourceSupportSentence`.
+ const supportSentence = sourceSupportSentence(source, activeSupportIndex, activeClaimSupport);
const numbered = sources.length <= NUMBERED_PAGER_LIMIT;
return (
@@ -256,43 +254,23 @@ export function AnswerSourceDrawer({
>
{source ? (
- {/* What the document looks like, next to what it says. A citation is a
- pointer into a physical-looking artefact, and a clinician who has
- seen the front page of the protocol recognises it faster than they
- read its title.
+ {/* Owner decision 2026-08-31: the drawer opens on the passage.
+ What stood here — a front-page cover thumbnail beside a sentence —
+ cost the top third of the panel to say nothing about this page. The
+ thumbnail is the FRONT page and never a render of the cited one (the
+ index holds one cover per document), so it needed its own caption
+ to stop it reading as "p. 12", and a document opened from the source
+ list paired it with a sentence whose whole content was that there
+ was no claim. The passage is what the clinician opened the drawer to
+ check, so the passage is now the first thing in it.
- The caption is not decoration. This is the FRONT page, never a
- render of the cited page — the index stores one cover thumbnail per
- document and no per-page renders — so an uncaptioned picture beside
- "p. 12" would read as page 12 and quietly misrepresent the
- evidence. Say which page it is, and say where the passage actually
- sits. */}
-
- {coverImageId ? (
-
- {/* Same 3:4 frame, surface and accent edge as `DocumentPagePreview`
- on the document search card: one document, two surfaces, one
- look. */}
- markCoverUnavailable(coverImageId)}
- />
-
- Front page
- {typeof source.pageNumber === "number" ? ` · passage on p. ${source.pageNumber}` : null}
-
-
- ) : null}
+ What survives is the sentence that says something: when a claim
+ opened the drawer, how far this page backs THAT claim. */}
+ {supportSentence ? (
) : null}
- ) : null}
+ ) : (
+ /* A retrieved-but-uncited row carries no quote and no snippet, so
+ there is nothing to quote here. Say that plainly: with the cover
+ and the no-claim sentence gone, the alternative is a panel whose
+ only content is a button, which reads as a failure rather than as
+ a document with no captured passage. */
+
+ No passage was captured for this source. Open the original PDF to read the page it points at.
+
+ )}
{sourceTables.length ? (
diff --git a/src/components/clinical-dashboard/answer-source-rail.tsx b/src/components/clinical-dashboard/answer-source-rail.tsx
index 08aa12711f..bdb7b7e0c2 100644
--- a/src/components/clinical-dashboard/answer-source-rail.tsx
+++ b/src/components/clinical-dashboard/answer-source-rail.tsx
@@ -115,7 +115,7 @@ export function AnswerSourceRail({
textMuted,
)}
>
- Sources
+ Cited documents
{citedCount === sources.length
? `${sources.length} cited`
diff --git a/src/components/clinical-dashboard/answer-source-rows.ts b/src/components/clinical-dashboard/answer-source-rows.ts
index 4ce6dee7d4..1670e11505 100644
--- a/src/components/clinical-dashboard/answer-source-rows.ts
+++ b/src/components/clinical-dashboard/answer-source-rows.ts
@@ -173,8 +173,15 @@ export function sourceSupportLabel(source: AnswerSourceRow) {
}
/**
- * The drawer's support clause. `index === null` means the drawer was opened from
- * the source list rather than from a claim, so there is no claim to speak about.
+ * The drawer's support clause, or `null` when there is no claim to speak about.
+ *
+ * `index === null` means the drawer was opened from the source list rather than
+ * from a claim. That case used to print "Opened from the source list, so this is
+ * the document, not a claim." — a sentence whose whole content is the absence of
+ * a claim, sitting where the passage should be. Returning `null` says the same
+ * thing by saying nothing: the drawer asserts support only when a claim is what
+ * opened it, and otherwise goes straight to the passage. Nothing is lost from
+ * the safety side, because the removed wording made no support claim either.
*
* When a claim opened the drawer, `claimSupport` is that claim's recorded
* status — not the row's document-level `sourceStrength`. Those fields are
@@ -185,8 +192,8 @@ export function sourceSupportSentence(
source: AnswerSourceRow | null,
index: number | null,
claimSupport?: "direct" | "partial" | "unsupported" | null,
-) {
- if (!source || index === null) return "Opened from the source list, so this is the document, not a claim.";
+): string | null {
+ if (!source || index === null) return null;
const support =
claimSupport === "direct"
? "Direct"
diff --git a/src/components/clinical-dashboard/auth-panel.tsx b/src/components/clinical-dashboard/auth-panel.tsx
index 8dcef4f4ab..d9bd1ef597 100644
--- a/src/components/clinical-dashboard/auth-panel.tsx
+++ b/src/components/clinical-dashboard/auth-panel.tsx
@@ -312,7 +312,7 @@ function ProviderMark({ provider }: { provider: "Apple" | "Google" | "Microsoft"
function AuthBenefit({ icon: Icon, label }: { icon: typeof SlidersHorizontal; label: string }) {
return (
-
+ {label}
);
diff --git a/src/components/clinical-dashboard/badges.tsx b/src/components/clinical-dashboard/badges.tsx
index 05fc2545cd..b93248e0c5 100644
--- a/src/components/clinical-dashboard/badges.tsx
+++ b/src/components/clinical-dashboard/badges.tsx
@@ -38,7 +38,7 @@ export function StatusBadge({ status }: { status: string }) {
tone.className,
)}
>
-
+
{status}
);
@@ -55,7 +55,7 @@ export function StrengthBadge({ strength }: { strength?: string }) {
-
+
{label}
);
diff --git a/src/components/clinical-dashboard/brand.tsx b/src/components/clinical-dashboard/brand.tsx
index 02cc2b1f8c..77a49b1821 100644
--- a/src/components/clinical-dashboard/brand.tsx
+++ b/src/components/clinical-dashboard/brand.tsx
@@ -1,11 +1,5 @@
import { cn } from "@/components/ui-primitives";
-import {
- BRAND_COUNTER_TRANSFORM,
- BRAND_GLYPH_TRANSFORM_BARE,
- BRAND_POINT,
- BRAND_STROKE_PATH,
- BRAND_VIEWBOX,
-} from "@/lib/brand-mark";
+import { BRAND_COUNTER_TRANSFORM, BRAND_VIEWBOX, brandMarkOptics } from "@/lib/brand-mark";
/**
* Site brand mark: the PsychSift S — two counter-turning strokes divided by one
@@ -47,7 +41,34 @@ const BRAND_MARK_INK: Record = {
contrast: "var(--brand-mark-contrast)",
};
-export function BrandMark({ className, tone = "resting" }: { className?: string; tone?: BrandMarkTone }) {
+/**
+ * Which optical cut of the glyph to draw. The mark is a construction, not a
+ * bitmap, so it does not simply get smaller — two pieces of its negative space
+ * close up before anything else does.
+ *
+ * - `display` — the primary construction. Correct above 32px.
+ * - `chrome` — the small-size cut, for 32px and below: a wider gap between the
+ * strokes and the point slid out of its cradle. Both are the brand's own
+ * committed geometry, not a redraw.
+ *
+ * Sized by className, so the component cannot measure itself and this cannot be
+ * inferred. State it at the call site, matched to the height you are setting.
+ */
+export type BrandMarkOptical = "display" | "chrome";
+
+export function BrandMark({
+ className,
+ tone = "resting",
+ optical = "display",
+}: {
+ className?: string;
+ tone?: BrandMarkTone;
+ optical?: BrandMarkOptical;
+}) {
+ // One call, so the stroke, the point and the centring transform can only be
+ // taken as a set — the brand doc is explicit that mixing one variant's point
+ // with the other's placement puts the glyph off-centre in its box.
+ const { transform, stroke, point } = brandMarkOptics(optical);
return (
);
diff --git a/src/components/clinical-dashboard/clinical-output-helpers.tsx b/src/components/clinical-dashboard/clinical-output-helpers.tsx
index b3a67b43c5..afc0cad114 100644
--- a/src/components/clinical-dashboard/clinical-output-helpers.tsx
+++ b/src/components/clinical-dashboard/clinical-output-helpers.tsx
@@ -190,7 +190,7 @@ export function AnswerViewModeControl({
: "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]",
)}
>
-
+ {mode.shortLabel}{mode.label}
diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx
index de00efc64c..d125156a5c 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/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx
index b125f01f19..58202f710b 100644
--- a/src/components/clinical-dashboard/dashboard-nav.tsx
+++ b/src/components/clinical-dashboard/dashboard-nav.tsx
@@ -251,7 +251,7 @@ export function MobileSectionFab({
}}
onClick={() => setOpen((current) => !current)}
>
- {open ? : }
+ {open ? : }
{(state.badgeLabel ?? (activeItem.count !== null ? String(activeItem.count) : null)) ? (
-
+ {item.label}
diff --git a/src/components/clinical-dashboard/dashboard-shell.tsx b/src/components/clinical-dashboard/dashboard-shell.tsx
index cfd9715691..cdfd03377c 100644
--- a/src/components/clinical-dashboard/dashboard-shell.tsx
+++ b/src/components/clinical-dashboard/dashboard-shell.tsx
@@ -113,7 +113,7 @@ export function UtilityDrawer({
>
-
+ {title}
@@ -141,7 +141,7 @@ export function UtilityDrawer({
-
+ {title}
diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx
index 67bee4f1c5..a5f9b02c83 100644
--- a/src/components/clinical-dashboard/evidence-panels.tsx
+++ b/src/components/clinical-dashboard/evidence-panels.tsx
@@ -7,12 +7,12 @@ import {
CircleAlert,
CircleCheck,
ChevronDown,
+ ChevronRight,
ClipboardCheck,
Copy,
ExternalLink,
FileText,
Loader2,
- MessageSquareWarning,
Plus,
Quote,
RefreshCw,
@@ -21,6 +21,8 @@ import {
ShieldCheck,
Table2,
Target,
+ ThumbsDown,
+ ThumbsUp,
} from "lucide-react";
import { type AnswerFeedbackType } from "@/lib/answer-feedback";
@@ -42,6 +44,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 +174,184 @@ 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 AnswerUtilityActions({
+ copied,
+ onCopy,
+ pendingFeedback = null,
+ onSubmitFeedback,
+}: {
+ copied: boolean;
+ onCopy: () => void;
+ pendingFeedback?: AnswerFeedbackType | null;
+ onSubmitFeedback?: (feedbackType: AnswerFeedbackType) => void;
+}) {
+ const [feedbackOpen, setFeedbackOpen] = useState(false);
+ return (
+
+ {/* Copy sits left; the two verdict controls sit right, as the approved
+ specimen draws them. The verdicts are icon-only because their meaning
+ is the icon — a thumb — and a word beside each one would take the whole
+ 390px row on its own. Each carries a full sentence as its accessible
+ name rather than "Thumbs up". Evidence gaps are NOT here: they are a
+ statement about the answer's evidence and belong with the safety chip
+ in the header, which is also what keeps this row to one line. */}
+
+
+
+ {copied ? "Copied with sources" : "Copy with sources"}
+
+ {onSubmitFeedback ? (
+
+ {/* One tap records the product's existing positive verdict rather
+ than opening a panel to choose the only affirmative option in
+ it. `verified` is that option, and the panel's own question —
+ "Is the answer supported?" — is what a thumb up answers. */}
+ onSubmitFeedback("verified")}
+ className={cn(chatMicroAction, "min-w-12 justify-center px-2 disabled:opacity-60")}
+ aria-label="This answer is supported by its sources"
+ >
+ {pendingFeedback === "verified" ? (
+
+ ) : (
+
+ )}
+
+ {/* The thumb down IS the way in to "report a problem": it opens the
+ list of problem types rather than recording an unlabelled
+ negative, because an unlabelled negative tells a reviewer
+ nothing about which claim failed. */}
+ setFeedbackOpen((current) => !current)}
+ className={cn(chatMicroAction, "min-w-12 justify-center px-2")}
+ aria-expanded={feedbackOpen}
+ aria-controls={feedbackOpen ? "answer-feedback-detail" : undefined}
+ aria-label="Report a problem with this answer"
+ >
+
+
+
+ ) : 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 ? (
+
+
+
+ ) : null}
+
+ );
+}
+
export function AnswerSupportSummaryCard({
priority,
- warnings = [],
safetyTriggerRef,
safetyFindingsCount = 0,
onOpenSafetyFindings,
- pendingFeedback = null,
- onSubmitFeedback,
- density = "comfortable",
}: {
priority: AnswerSupportPriority | null;
- /** 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)]";
+ // 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 gapsDetail =
- warnings.length > 0 && gapsOpen ? (
-
- )
- ) : 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.
-
+ )}
);
}
@@ -1039,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 ;
+ return ;
}
- return ;
+ return ;
}
// Issue 9: governance provenance retained on safety-finding citations lets the safety
@@ -1067,6 +990,19 @@ function safetyFindingGovernanceLabels(citation: SafetyFinding["citation"]): str
return labels;
}
+/**
+ * The safety findings list, as read on a phone.
+ *
+ * The row used to lead with three stacked pills — a kind pill, the source link,
+ * then a governance pill — which at 390px wrapped to three lines and put ~110px
+ * of chrome above the first word of the finding. The clinician is here for the
+ * finding, so the order is now: what kind of finding, then the finding, then
+ * where it came from.
+ *
+ * The kind is drawn as an eyebrow beside its icon rather than as a pill: the
+ * icon and the pill were saying the same thing twice, and one line of them fits
+ * the governance chip alongside instead of below.
+ */
export function SafetyFindingsListContent({ findings, query }: { findings: SafetyFinding[]; query?: string }) {
if (findings.length === 0) return null;
@@ -1080,51 +1016,55 @@ export function SafetyFindingsListContent({ findings, query }: { findings: Safet
// them. Inert outside a flex container.
className="shrink-0 overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)]"
>
- {sortedFindings.map((finding, index) => (
-
- {
+ const severe = finding.kind === "contraindication" || finding.kind === "red_flag";
+ const accent = severe ? "text-[color:var(--danger)]" : "text-[color:var(--warning)]";
+ return (
+
-
-
-
-
-
- {finding.label}
+
+
+
+ {finding.label}
+
+ {/* One `ms-auto` on the group, not on each chip. A citation can
+ carry two governance labels — a currency label and "not locally
+ validated" — and an auto margin on both splits the free space
+ between them, so neither ends up flush right. */}
+
+ {safetyFindingGovernanceLabels(finding.citation).map((label) => (
+
+ {label}
+
+ ))}
- query && logCitationOpen(query, finding.citation)}
- className="inline-flex min-h-tap min-w-0 items-center gap-1 text-xs font-semibold text-[color:var(--primary)] transition hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] lg:min-h-compact-meta"
- aria-label={`Open source ${formatSafetyFindingLabel(finding)}`}
- >
- {formatCompactCitationLabel(finding.citation)}
-
-
- {safetyFindingGovernanceLabels(finding.citation).map((label) => (
-
- {label}
-
- ))}
-
{finding.text}
-
-
- ))}
+
{finding.text}
+ query && logCitationOpen(query, finding.citation)}
+ // `-mb-1.5` trims the row's own bottom padding back, so a full tap
+ // target does not read as a gap under the last finding.
+ className="-mb-1.5 inline-flex min-h-tap min-w-0 items-center gap-1 text-xs font-semibold text-[color:var(--primary)] transition hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] lg:min-h-compact-meta"
+ aria-label={`Open source ${formatSafetyFindingLabel(finding)}`}
+ >
+ {formatCompactCitationLabel(finding.citation)}
+
+
+
+ );
+ })}
);
}
@@ -1256,22 +1196,38 @@ function feedbackToneClass(tone: "success" | "warning" | "danger" | "neutral") {
export function AnswerFeedbackPanel({
pending,
onSubmit,
+ tone = "full",
}: {
pending: AnswerFeedbackType | null;
onSubmit: (feedbackType: AnswerFeedbackType) => void;
+ /**
+ * `"problems"` drops the affirmative option and asks the narrower question.
+ * The thumb down is the only way into this panel on the answer surface, and
+ * offering "Verified" inside a list a reader opened to report a fault is a
+ * mis-click waiting to record the opposite of what they meant.
+ */
+ tone?: "full" | "problems";
}) {
+ const problemsOnly = tone === "problems";
+ const options = problemsOnly
+ ? answerFeedbackOptions.filter((item) => item.tone !== "success")
+ : answerFeedbackOptions;
return (
-
Is the answer supported?
+
+ {problemsOnly ? "What is wrong with this answer?" : "Is the answer supported?"}
+
- Record whether the linked evidence supports the answer. This sends feedback for review; it does not change
- the answer.
+ {problemsOnly
+ ? "Name the fault so a reviewer can find it. This sends feedback for review; it does not change the answer."
+ : "Record whether the linked evidence supports the answer. This sends feedback for review; it does not change the answer."}
- 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/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx
index e14cb17a32..2b076cc52b 100644
--- a/src/components/clinical-dashboard/mode-action-popup.tsx
+++ b/src/components/clinical-dashboard/mode-action-popup.tsx
@@ -892,7 +892,7 @@ export function ModeActionPopup({
className={cn("mode-action-mode-option", active && "mode-action-mode-option-active")}
>
-
+ {mode.label}
diff --git a/src/components/clinical-dashboard/output-panel.tsx b/src/components/clinical-dashboard/output-panel.tsx
index 4c43cf7fd0..9132621eb2 100644
--- a/src/components/clinical-dashboard/output-panel.tsx
+++ b/src/components/clinical-dashboard/output-panel.tsx
@@ -167,7 +167,7 @@ export function ClinicalOutputPanel({
)}
aria-hidden="true"
>
-
+
diff --git a/src/components/clinical-dashboard/patient-profile-panel.tsx b/src/components/clinical-dashboard/patient-profile-panel.tsx
index 41b77ff36a..d88e4b8714 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.
@@ -1193,7 +1214,7 @@ export function DifferentialDetailPage({
- Clinical decision support only. Review before use.
+ Clinical reference — not validated decision support. Review before use.
diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx
index 3a2b1a25b1..cb7dea1daf 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/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx
index cd752fde28..781b272d6c 100644
--- a/src/components/forms/forms-search-results-page.tsx
+++ b/src/components/forms/forms-search-results-page.tsx
@@ -626,7 +626,7 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) {
if (!query.trim()) return [];
// Deferred empty while live has text: wait — do not rank as empty-query "all forms".
if (!deferredQuery.trim()) return [];
- return rankFormRecords(registry.records, deferredQuery);
+ return rankFormRecords(registry.records, deferredQuery, registry.records.length, [], true);
}, [registryReady, registry.records, deferredQuery, query]);
const categoryOptions = useMemo(() => deriveFormCategories(matches), [matches]);
const filterSelection = useMemo(
diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx
index 8cebb8c307..836d9db313 100644
--- a/src/components/formulation/formulation-builder-page.tsx
+++ b/src/components/formulation/formulation-builder-page.tsx
@@ -229,7 +229,9 @@ export function FormulationBuilderPage({
}
// Empty deferred while live query has text would score every mechanism.
if (!deferredQuery.trim()) return [];
- return searchFormulationMechanisms(deferredQuery, { domain }).map((result) => result.mechanism);
+ return searchFormulationMechanisms(deferredQuery, { domain, interpretNaturalLanguage: true }).map(
+ (result) => result.mechanism,
+ );
}, [domain, deferredQuery, query]);
const activeSections = formulationSectionsForTemplate(templateId);
const generatedDraft = formulationDraftFor({
diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx
index 42a80e694e..5355277a4c 100644
--- a/src/components/formulation/formulation-home-page.tsx
+++ b/src/components/formulation/formulation-home-page.tsx
@@ -88,7 +88,7 @@ function FormulationResults({ query }: { query: string }) {
// Empty deferred while live query has text would score every mechanism —
// treat that lag as "no results yet" instead of dumping the full catalogue.
if (!deferredQuery.trim()) return [];
- return searchFormulationMechanisms(deferredQuery, { domains });
+ return searchFormulationMechanisms(deferredQuery, { domains, interpretNaturalLanguage: true });
}, [domains, deferredQuery, query]);
const hasUniqueTopMatch = results.length > 0 && (results.length < 2 || results[0].score !== results[1].score);
@@ -123,7 +123,10 @@ function FormulationResults({ query }: { query: string }) {
options: formulationDomainsInUse.map((item) => {
const withCandidate = pendingRanking
? 0
- : searchFormulationMechanisms(searchQuery, { domains: new Set([...domains, item]) }).length;
+ : searchFormulationMechanisms(searchQuery, {
+ domains: new Set([...domains, item]),
+ interpretNaturalLanguage: true,
+ }).length;
return {
value: item,
label: item,
diff --git a/src/components/privacy-live-signal-perfected-mockups.tsx b/src/components/privacy-live-signal-perfected-mockups.tsx
index 2b96c24961..491c74573b 100644
--- a/src/components/privacy-live-signal-perfected-mockups.tsx
+++ b/src/components/privacy-live-signal-perfected-mockups.tsx
@@ -102,7 +102,7 @@ const SECTIONS: Section[] = [
heading: "Retention",
short: "Retention",
gist: "30-day queries · 90-day logs · hourly cache purge",
- body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Administrator-provided documents remain until removed under the applicable process. Safety-plan working content has no PsychSift retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.",
+ body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows. The operator verified the four expected schedules on production and staging and approved that evidence on 1 September 2026. Administrator-provided documents remain until removed under the applicable process. Safety-plan working content has no PsychSift retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.",
},
{
heading: "Your responsibilities",
diff --git a/src/components/privacy-page-directions-mockups.tsx b/src/components/privacy-page-directions-mockups.tsx
index 4087fe0562..7eb4b4a274 100644
--- a/src/components/privacy-page-directions-mockups.tsx
+++ b/src/components/privacy-page-directions-mockups.tsx
@@ -58,7 +58,7 @@ const SECTIONS: Section[] = [
{
heading: "Retention",
short: "Retention",
- body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Administrator-provided documents remain until removed under the applicable process. Safety-plan working content has no PsychSift retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.",
+ body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows. The operator verified the four expected schedules on production and staging and approved that evidence on 1 September 2026. Administrator-provided documents remain until removed under the applicable process. Safety-plan working content has no PsychSift retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.",
},
{
heading: "Your responsibilities",
diff --git a/src/components/privacy-quiet-signal-page.tsx b/src/components/privacy-quiet-signal-page.tsx
index e9509c1c86..54aa9e6c9f 100644
--- a/src/components/privacy-quiet-signal-page.tsx
+++ b/src/components/privacy-quiet-signal-page.tsx
@@ -201,7 +201,15 @@ export function PrivacyQuietSignalPage() {
data-sticky={chromeSticky ? "true" : "false"}
className={cn(
chromeSticky && "sticky top-0",
- "z-30 border-b border-[color:var(--border)] bg-[color:var(--surface-glass)]/95 shadow-[var(--e1)] backdrop-blur-xl",
+ "z-30 border-b border-[color:var(--border-strong)] shadow-[var(--e2)]",
+ // Phone baseline: a full-width opaque bar with no blur, matching the
+ // `.edge-glass-header` / `.universal-header` phone rule in globals.css.
+ // This page had carried translucent glass at every width, so scrolled
+ // content ghosted straight through the title — visible against the
+ // amber obligation band, which showed through the header text.
+ "bg-[color:var(--surface)] backdrop-blur-none",
+ // From sm the shared glass treatment is correct again.
+ "sm:bg-[color-mix(in_srgb,var(--surface)_72%,transparent)] sm:backdrop-blur-xl",
"print:hidden",
)}
>
diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx
index d919733c45..5844b0ee51 100644
--- a/src/components/pwa-lifecycle.tsx
+++ b/src/components/pwa-lifecycle.tsx
@@ -138,7 +138,7 @@ function NoticeIcon({ icon: Icon, tone }: { icon: LucideIcon; tone: "accent" | "
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl border shadow-[var(--shadow-inset)] ${toneClassName}`}
aria-hidden="true"
>
-
+
);
}
diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx
index c5809d422a..11329a429d 100644
--- a/src/components/services/services-navigator-page.tsx
+++ b/src/components/services/services-navigator-page.tsx
@@ -431,7 +431,7 @@ export function ServicesNavigatorPage() {
const searchableRecords = useMemo(() => (registryReady ? registry.records : []), [registry.records, registryReady]);
const rankedMatches = useMemo(() => {
if (!query.trim()) return searchableRecords;
- const ranked = rankServiceRecords(searchableRecords, deferredQuery);
+ const ranked = rankServiceRecords(searchableRecords, deferredQuery, searchableRecords.length, [], true);
if (ranked.length) return ranked.map((match) => match.service);
return [];
}, [deferredQuery, query, searchableRecords]);
diff --git a/src/components/specifiers/specifier-ui.tsx b/src/components/specifiers/specifier-ui.tsx
index 564fdf5621..fd197dd4d1 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/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx
index bc7db20910..aebb095001 100644
--- a/src/components/specifiers/specifiers-home-page.tsx
+++ b/src/components/specifiers/specifiers-home-page.tsx
@@ -29,6 +29,7 @@ import { cn, primaryControl } from "@/components/ui-primitives";
import { consolidatedModeSearchPath } from "@/lib/consolidated-mode-home-redirect";
import { searchSpecifiers, specifierFamilies, type SpecifierFamily } from "@/lib/specifiers";
import { searchSpecifierCatalog, type SpecifierCatalogMatch } from "@/lib/specifiers-search-index";
+import { interpretSmartSearch } from "@/lib/smart-search-intent";
import {
readResultFilterValue,
readResultFilterValues,
@@ -138,8 +139,14 @@ function SpecifierResults({ query }: { query: string }) {
const filterPanelId = useId();
const [filterOpen, setFilterOpen] = useState(false);
const allGuideMatches = useMemo(() => searchSpecifiers(query, { family: "all", diagnosis: "" }), [query]);
- const allCatalogueMatches = useMemo(() => searchSpecifierCatalog(query), [query]);
- const defaultScope: SpecifierResultScope = allGuideMatches.length > 0 ? "guides" : "catalogue";
+ const allCatalogueMatches = useMemo(() => searchSpecifierCatalog(query, {}, true), [query]);
+ const smartInterpretation = useMemo(() => interpretSmartSearch("specifiers", query), [query]);
+ const defaultScope: SpecifierResultScope =
+ smartInterpretation.naturalLanguage && allCatalogueMatches.length > 0
+ ? "catalogue"
+ : allGuideMatches.length > 0
+ ? "guides"
+ : "catalogue";
const scope = readResultFilterValue(searchParams, "scope", specifierScopeValues, defaultScope);
const familyValues = useMemo(() => new Set(specifierFamilies.map((option) => option.id)), []);
const diagnosisValues = useMemo(() => new Set(diagnosisOptions.map((option) => option.value)), []);
diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx
index 0ee981060e..7ce580d8e4 100644
--- a/src/components/therapy-compass/bindings.tsx
+++ b/src/components/therapy-compass/bindings.tsx
@@ -319,7 +319,7 @@ export function TcProvider({ children }: { children: ReactNode }) {
if (!deferredQuery) return [];
// Defer only the text cost; apply live filter chips/flags immediately so toggles
// match aria-pressed state without waiting for useDeferredValue.
- return searchTherapies(therapies, { ...search, query: deferredSearch.query });
+ return searchTherapies(therapies, { ...search, query: deferredSearch.query }, true);
}, [therapies, deferredSearch.query, search]);
// Same query-deferral shape as `searchResults`, tags/reviewedOnly/briefOnly
// reset — so a facet count never lags behind or races ahead of the query
@@ -329,7 +329,7 @@ export function TcProvider({ children }: { children: ReactNode }) {
const deferredQuery = deferredSearch.query.trim();
if (!liveQuery) return searchTherapies(therapies, EMPTY_SEARCH);
if (!deferredQuery) return [];
- return searchTherapies(therapies, { ...EMPTY_SEARCH, query: deferredSearch.query });
+ return searchTherapies(therapies, { ...EMPTY_SEARCH, query: deferredSearch.query }, true);
}, [therapies, deferredSearch.query, search.query]);
const compareTherapies = useMemo(
() => compareSlugs.map((sl) => bySlug.get(sl)).filter((t): t is Therapy => Boolean(t)),
diff --git a/src/components/therapy-compass/data/select.ts b/src/components/therapy-compass/data/select.ts
index 0c8f6dae33..63458b4d73 100644
--- a/src/components/therapy-compass/data/select.ts
+++ b/src/components/therapy-compass/data/select.ts
@@ -1,5 +1,6 @@
import type { Therapy } from "./types";
import { scoreTherapyCandidate } from "@/lib/therapy-ranking";
+import { expandedSmartSearchQuery } from "@/lib/smart-search-intent";
// ---- text helpers -------------------------------------------------------
@@ -224,8 +225,14 @@ export function matchesAvailability(therapy: Therapy, reviewedOnly: boolean, bri
return true;
}
-export function searchTherapies(therapies: Therapy[], opts: SearchOptions): Therapy[] {
- const q = opts.query.trim().toLowerCase();
+export function searchTherapies(
+ therapies: Therapy[],
+ opts: SearchOptions,
+ interpretNaturalLanguage = false,
+): Therapy[] {
+ const q = (interpretNaturalLanguage ? expandedSmartSearchQuery("therapy-compass", opts.query) : opts.query)
+ .trim()
+ .toLowerCase();
const topics = new Set(opts.tags);
const scored = therapies
.map((t) => {
diff --git a/src/components/therapy-compass/screens/sheets-screen.tsx b/src/components/therapy-compass/screens/sheets-screen.tsx
index 7c4e245344..731388510a 100644
--- a/src/components/therapy-compass/screens/sheets-screen.tsx
+++ b/src/components/therapy-compass/screens/sheets-screen.tsx
@@ -310,7 +310,11 @@ function TherapyPicker() {
const [q, setQ] = useState("");
const matches = useMemo(() => {
const base = q.trim()
- ? searchTherapies(b.therapies, { query: q, tags: [], briefOnly: false, sheetOnly: false, reviewedOnly: false })
+ ? searchTherapies(
+ b.therapies,
+ { query: q, tags: [], briefOnly: false, sheetOnly: false, reviewedOnly: false },
+ true,
+ )
: b.therapies;
// Only offer therapies that actually ship a patient sheet — selecting one now
// navigates to its /sheet subroute, which 404s for records without a sheet.
diff --git a/src/components/ui/answer-card.tsx b/src/components/ui/answer-card.tsx
index f5a8e5af2d..cb98946441 100644
--- a/src/components/ui/answer-card.tsx
+++ b/src/components/ui/answer-card.tsx
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
+import { ShieldCheck, TriangleAlert } from "lucide-react";
import { cn, type SourceMetadataInput } from "@/components/ui-primitives";
import type { AnswerState, DegradedAnswerState } from "@/components/ui/answer-state";
@@ -93,6 +94,49 @@ 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";
+ /**
+ * Where the governed verification sentence is rendered.
+ *
+ * `"content"` moves it below the answer, which is what the approved specimen
+ * draws: the header carries a support chip and the cited count, and the full
+ * caution sits under the action row with the sources it refers to. The card
+ * still owns the wording — the sentence is the same `VerificationNotice`,
+ * placed by the content owner rather than rewritten by it. **A surface that
+ * opts in MUST render `` itself**;
+ * `tests/answer-verification-placement.dom.test.tsx` is what stops that
+ * obligation being quietly dropped, the same way `retrievalStatePlacement`
+ * above requires the content owner to keep the state and its source route.
+ */
+ verificationPlacement?: "header" | "content";
+ /**
+ * Chips rendered under the header status line. The answer surface puts its
+ * safety-notes control here; the card keeps ownership of the support word
+ * above so the two read as one status block.
+ *
+ * The card gives these their own full-width row, so a chip that needs a 48px
+ * tap target should simply be 48px tall. **Do not shrink one back into the
+ * line with a negative margin or a `before:-inset-y-*` pseudo-element** — both
+ * leave the hit region outside the element's layout box, where it covers its
+ * neighbours; `ui-smoke` measures the chip rectangles for exactly that.
+ */
+ metaChips?: 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;
};
@@ -117,6 +161,10 @@ export function AnswerCard({
actions,
onOpenSource,
frame = "raised",
+ retrievalStatePlacement = "header",
+ verificationPlacement = "header",
+ metaChips,
+ metaDetail,
className,
}: AnswerCardProps) {
const bare = frame === "bare";
@@ -157,21 +205,47 @@ export function AnswerCard({
>
{query ? : null}
{/* Above the prose and above the actions, in document order, on screen
- and on print alike. */}
-
+ and on print alike — unless the content owner has taken placement,
+ in which case it renders the same notice below the answer. */}
+ {verificationPlacement === "header" ? : null}
{/* Text, never colour alone - this must survive greyscale print and
- forced-colors the same way StatusMark does. */}
+ forced-colors the same way StatusMark does. The bare frame draws it
+ as a chip so the support word, the surface's safety chip and the
+ cited count read as one status line; the word itself is unchanged,
+ and the icon is decorative beside it rather than a second signal. */}
+ {/* 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
+ a negative margin — which puts the hit region outside the element's
+ layout box, where it silently covers whatever sits beside or below
+ it. The row costs nothing: the status line already wrapped to two
+ lines on a phone, so the height is the same and the overlap is gone. */}
+ {bare && metaChips ?
{metaChips}
: null}
{/*
* Ledger `#227` over `#207`, decided 3 Aug 2026. `#207` required a banner on
* every degraded state, on the reasoning that an adoption failure here is
@@ -194,7 +268,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") ? (
) : 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 ?
-
+ Ward FlowSynthetic patient-flow prototype
diff --git a/src/lib/answer-follow-up.ts b/src/lib/answer-follow-up.ts
index 1d74ebca41..73923bfbd3 100644
--- a/src/lib/answer-follow-up.ts
+++ b/src/lib/answer-follow-up.ts
@@ -124,6 +124,18 @@ type FollowUpTemplate = {
* is asserted over all 48 class×intent cells in tests/answer-follow-up.test.ts, so
* a future menu edit fails loudly instead of silently dropping a chip.
*/
+/**
+ * The one authored way to ask about a gap the answer reported.
+ *
+ * It lives outside the menus because the builder offers it for every menu key,
+ * not only `management` — the menu that happens to list it. A reported gap is a
+ * statement about this answer's evidence, so it is worth asking about whatever
+ * shape the question had; and the `dosing`, `escalation`, `threshold` and
+ * `comparison` menus have no gap item of their own, so without this a gap on
+ * those queries would go unmentioned.
+ */
+const reportedGapQuestion = (topic: string) => `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/brand-mark.ts b/src/lib/brand-mark.ts
index 018321ee88..6ce01d93ac 100644
--- a/src/lib/brand-mark.ts
+++ b/src/lib/brand-mark.ts
@@ -36,6 +36,56 @@ export const BRAND_GLYPH_TRANSFORM_SMALL = "translate(122.7103 51.2) scale(4.080
* mark therefore occupies exactly the slot the tiled version used to. */
export const BRAND_GLYPH_TRANSFORM_BARE = "translate(114.8907 0) scale(5.1006)";
+/** The bare glyph's small-size placement — the counterpart of
+ * BRAND_GLYPH_TRANSFORM_SMALL for the untiled, in-app mark.
+ *
+ * Not a new measurement. The brand doc fixes the two ink boxes: the primary
+ * glyph spans 0 to 55.33 horizontally, the small variant 0 to 65.33, and both
+ * span the same 0 to 100.38 vertically. Every placement here centres that box
+ * in the 512 viewBox, which is verifiable against the three transforms above —
+ * each lands its centre on 256.000 to within 0.003, the rounding of a
+ * two-decimal box figure.
+ *
+ * Two consequences, and both are why this is a derivation rather than a redraw:
+ *
+ * The vertical extent is identical between the variants (the point slides
+ * sideways; its cy and r do not move), so the scale is unchanged at 5.1006 —
+ * 100.3813 x 5.1006 = 512.0, the bare variant's whole point.
+ *
+ * The point moves exactly 10 units out and is the box's right edge, so the box
+ * widens by exactly 10 and its centre by exactly 5. The placement therefore
+ * shifts left by 5 glyph units at this scale: 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 the rule is the one
+ * actually used rather than one that merely fits.
+ *
+ * It travels with BRAND_STROKE_PATH_SMALL and BRAND_POINT_SMALL as a set. The
+ * doc is explicit that mixing one variant's point with the other's placement
+ * puts the glyph off-centre; `brandMarkOptics()` below is the only supported
+ * way to pick, so a caller cannot half-apply the variant. */
+export const BRAND_GLYPH_TRANSFORM_BARE_SMALL = "translate(89.3877 0) scale(5.1006)";
+
+/** The two optical variants of the bare glyph, as whole sets.
+ *
+ * `display` is the primary construction. `chrome` is the small-size cut: the
+ * 4.2-unit gap between the strokes opened to 7.2, and the point slid 10 units
+ * out of its cradle so the crescent opens from 7.26 to 11.55.
+ *
+ * The threshold is 32px, from the brand doc's own "at 32 px and below two
+ * things close up" - below it the cut and the crescent both fall under two
+ * pixels and the dot fuses into the S, which reads as a heavy blob at the top
+ * of a mark whose lower stroke is already the thinner one. That fusion is what
+ * made the mark look top-heavy in app chrome; the deeper `--brand-mark-emphasis`
+ * ink compensates for it, and this fixes it.
+ *
+ * Below about 20px the crescent is under two pixels whatever is done. That is
+ * the size, not the placement, and no variant rescues it. */
+export function brandMarkOptics(variant: "display" | "chrome") {
+ return variant === "chrome"
+ ? { transform: BRAND_GLYPH_TRANSFORM_BARE_SMALL, stroke: BRAND_STROKE_PATH_SMALL, point: BRAND_POINT_SMALL }
+ : { transform: BRAND_GLYPH_TRANSFORM_BARE, stroke: BRAND_STROKE_PATH, point: BRAND_POINT };
+}
+
/** The upper stroke. Four arcs and the straight cut, meeting at two cusps. */
export const BRAND_STROKE_PATH =
"M41.3675 2.6554 A17.7232 17.7232 0 0 0 29.0679 28.0493 A13 13 0 0 1 28.8667 40.1963 " +
diff --git a/src/lib/clinical-safety.ts b/src/lib/clinical-safety.ts
index 25cb8ec5d1..364fbeda35 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/src/lib/developer-area/clinical-answer-failures.ts b/src/lib/developer-area/clinical-answer-failures.ts
new file mode 100644
index 0000000000..cab8955b2b
--- /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/environment-facts.ts b/src/lib/developer-area/environment-facts.ts
new file mode 100644
index 0000000000..7b421f43e9
--- /dev/null
+++ b/src/lib/developer-area/environment-facts.ts
@@ -0,0 +1,64 @@
+import "server-only";
+
+import { isDemoMode } from "@/lib/env";
+import { createSupabaseServerClient } from "@/lib/supabase/server";
+
+export type HubEnvironmentFacts = {
+ demoMode: boolean;
+ documentCount: number | null;
+ email: string | null;
+};
+
+/**
+ * The three facts the developer hub's environment strip cannot read for itself.
+ *
+ * One Supabase client, one auth call and at most one count query per hub load —
+ * the gate above this page (`DeveloperAreaGate`) already resolves the same user
+ * on the same request, so this is deliberately the *second* auth call and not a
+ * third: email and the document count are gathered together rather than through
+ * two independent helpers.
+ *
+ * **The user-session client, never the service-role admin client.**
+ * `public.documents` has row-level security enabled with a single select policy,
+ * `documents owner read` (`owner_id = auth.uid()`), so the database itself scopes
+ * this count to the caller's own documents. `createAdminClient` bypasses RLS and
+ * would report every owner's document total to whoever happened to be signed in.
+ *
+ * **Every failure returns `null`, never `0`.** Zero is a true and meaningful
+ * answer here — an account that has uploaded nothing — so a failed read must not
+ * be able to impersonate it. The strip renders `null` as "document count
+ * unavailable", which is the conservative degradation this repo requires: name
+ * the gap rather than state a number nothing read. The same reasoning is why an
+ * unauthenticated request skips the query outright instead of reporting the `0`
+ * rows RLS would correctly return to it.
+ */
+export async function resolveHubEnvironmentFacts(): Promise {
+ const demoMode = isDemoMode();
+ const unread: HubEnvironmentFacts = { demoMode, documentCount: null, email: null };
+
+ const supabase = await createSupabaseServerClient();
+ if (!supabase) return unread;
+
+ // Both awaits are wrapped, and a returned `{ error }` is only half of what can
+ // go wrong. An aborted request or one that exhausts its network retries makes
+ // the client *reject* rather than resolve with an error, and an unhandled
+ // rejection here would fail the whole page rather than degrade one line of it
+ // — the opposite of this module's contract, and worst during exactly the
+ // Supabase outage that makes the hub worth opening. `demoMode` survives either
+ // way, because it never depended on the network.
+ try {
+ const { data } = await supabase.auth.getUser();
+ const user = data.user;
+ if (!user) return unread;
+
+ const { count, error } = await supabase.from("documents").select("id", { count: "exact", head: true });
+
+ return {
+ demoMode,
+ documentCount: error ? null : (count ?? null),
+ email: user.email ?? null,
+ };
+ } catch {
+ return unread;
+ }
+}
diff --git a/src/lib/developer-area/freshness.ts b/src/lib/developer-area/freshness.ts
index e3a9ef9db8..e5922dc4d1 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/developer-area/hub-panels.ts b/src/lib/developer-area/hub-panels.ts
index f54fab427e..3ed5e69161 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/src/lib/differential-detail.ts b/src/lib/differential-detail.ts
index 17cf9d6589..c6ca103192 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/src/lib/differential-stream.ts b/src/lib/differential-stream.ts
index fa3723a1b5..390e32bf7f 100644
--- a/src/lib/differential-stream.ts
+++ b/src/lib/differential-stream.ts
@@ -298,7 +298,9 @@ export function buildDifferentialStreamModel(stream: DifferentialStreamType, que
}
const knownSlugs = new Set(differentialRecords.map((record) => record.slug));
- const ranked = hasQuery ? rankDifferentialRecords(differentialRecords, trimmedQuery, differentialRecords.length) : [];
+ const ranked = hasQuery
+ ? rankDifferentialRecords(differentialRecords, trimmedQuery, differentialRecords.length, [], true)
+ : [];
const matchBySlug = new Map(ranked.map((match) => [match.record.slug, match]));
const matchedItems = ranked.map((match) =>
diagnosisItemFromRecord(match.record, knownSlugs, { score: match.score, reasons: match.reasons }),
diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts
index 7258ccb912..89b7e895f9 100644
--- a/src/lib/differentials.ts
+++ b/src/lib/differentials.ts
@@ -1,4 +1,5 @@
import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
+import { smartSearchExpansions } from "@/lib/smart-search-intent";
import { buildDiagnosisTitleSlugMap, buildTermLinkMap } from "@/lib/differential-diagnosis-links";
import { cleanDifferentialItem, type DifferentialDetailContext } from "@/lib/differential-detail";
import { loadDifferentialSnapshot } from "@/lib/differential-fixtures";
@@ -566,7 +567,12 @@ export function rankDifferentialRecords(
// Low-weight synonym/acronym/alias terms (see rankMedicationRecords) composed onto the
// catalogue's own symptom-alias expansion for the shared ranker's expanded lane.
expansions: string[] = [],
+ interpretNaturalLanguage = false,
): DifferentialRecordMatch[] {
+ const interpretedExpansions = [
+ ...expansions,
+ ...(interpretNaturalLanguage ? smartSearchExpansions("differentials", query) : []),
+ ];
return rankCatalogRecords(records, query, {
fields: [
{ id: "title", weight: 8, text: (record) => normalizeSearchText(`${record.title} ${record.slug}`) },
@@ -584,7 +590,9 @@ export function rankDifferentialRecords(
phraseBonus: 4,
exactValues: (record) => [normalizeSearchText(record.title), normalizeSearchText(record.slug)],
exactBonus: 10,
- expandTokens: expansions.length ? (terms) => [...expandQueryTerms(terms), ...expansions] : expandQueryTerms,
+ expandTokens: interpretedExpansions.length
+ ? (terms) => [...expandQueryTerms(terms), ...interpretedExpansions]
+ : expandQueryTerms,
limit,
tieBreak: (left, right) =>
differentialStatusRank[left.status] - differentialStatusRank[right.status] ||
diff --git a/src/lib/dsm.ts b/src/lib/dsm.ts
index 7f06d9b67a..bb62f86ea3 100644
--- a/src/lib/dsm.ts
+++ b/src/lib/dsm.ts
@@ -1,5 +1,6 @@
import dsmClinicalContent from "@/data/dsm-clinical-content.json";
import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
+import { smartSearchExpansions } from "@/lib/smart-search-intent";
export type DsmLabeledText = {
label: string;
@@ -256,8 +257,11 @@ export function rankDsmDiagnoses(
query: string,
limit = dsmDiagnoses.length,
expansions: string[] = [],
+ interpretNaturalLanguage = false,
): DsmSearchMatch[] {
- const normalizedExpansions = expansions.map(normalizeSearchText).filter(Boolean);
+ const normalizedExpansions = [...expansions, ...(interpretNaturalLanguage ? smartSearchExpansions("dsm", query) : [])]
+ .map(normalizeSearchText)
+ .filter(Boolean);
return rankCatalogRecords(dsmDiagnoses, query, {
fields: [
{
@@ -315,7 +319,7 @@ export function rankDsmDiagnoses(
export function listDsmDiagnosisSummaries(options: { query?: string; category?: string } = {}) {
const query = options.query?.trim() ?? "";
const records = query
- ? rankDsmDiagnoses(query).map((match) => match.diagnosis)
+ ? rankDsmDiagnoses(query, dsmDiagnoses.length, [], true).map((match) => match.diagnosis)
: [...dsmDiagnoses].sort((left, right) => left.title.localeCompare(right.title));
return records
.filter((diagnosis) => !options.category || diagnosis.category.key === options.category)
diff --git a/src/lib/form-ranker.ts b/src/lib/form-ranker.ts
index c5c756f1ae..7aa8bb226a 100644
--- a/src/lib/form-ranker.ts
+++ b/src/lib/form-ranker.ts
@@ -1,5 +1,6 @@
import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
import type { ServiceRecord, ServiceSearchMatch } from "@/lib/service-ranker";
+import { smartSearchExpansions } from "@/lib/smart-search-intent";
export type FormRecord = ServiceRecord;
export type FormSearchMatch = ServiceSearchMatch;
@@ -151,11 +152,16 @@ export function rankFormRecords(
limit = records.length,
// Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane.
expansions: string[] = [],
+ interpretNaturalLanguage = false,
): FormSearchMatch[] {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return [];
// A bare "service(s)" query belongs to the services catalogue, not forms.
if (/^services?$/.test(normalizedQuery)) return [];
+ const interpretedExpansions = [
+ ...expansions,
+ ...(interpretNaturalLanguage ? smartSearchExpansions("forms", query) : []),
+ ];
return rankCatalogRecords(records, query, {
fields: [
@@ -180,7 +186,7 @@ export function rankFormRecords(
"assessment",
],
broadBonus: 1,
- expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined,
+ expandTokens: interpretedExpansions.length ? (terms) => [...terms, ...interpretedExpansions] : undefined,
limit,
// No tieBreak: forms historically tie-break by catalogue (input) order, which is the
// generic ranker's default.
diff --git a/src/lib/formulation.ts b/src/lib/formulation.ts
index 6061c3711b..675b71015a 100644
--- a/src/lib/formulation.ts
+++ b/src/lib/formulation.ts
@@ -1,4 +1,5 @@
import formulationContentJson from "@/data/formulation-content.json";
+import { expandedSmartSearchQuery } from "@/lib/smart-search-intent";
export type FormulationMechanism = {
id: string;
@@ -221,10 +222,20 @@ export function searchFormulationMechanisms(
// them on average, so asking for Affect OR Risk must widen rather than
// intersect. Empty means no constraint. `domain` is the older one-of-N form,
// still used by the builder page's own select.
- options: { domain?: string; domains?: ReadonlySet } = {},
+ options: {
+ domain?: string;
+ domains?: ReadonlySet;
+ interpretNaturalLanguage?: boolean;
+ expansions?: readonly string[];
+ } = {},
) {
- const normalizedQuery = normalize(query);
+ const normalizedQuery = normalize(
+ options.interpretNaturalLanguage ? expandedSmartSearchQuery("formulation", query) : query,
+ );
const queryTokens = normalizedQuery.split(" ").filter(Boolean);
+ const expansionTokens = Array.from(
+ new Set((options.expansions ?? []).flatMap((expansion) => normalize(expansion).split(" ").filter(Boolean))),
+ );
const domainFacets = options.domains;
return formulationMechanisms
@@ -251,6 +262,12 @@ export function searchFormulationMechanisms(
if (clues.includes(token)) score += 8;
if (haystack.includes(token)) score += 3;
}
+ for (const token of expansionTokens) {
+ if (name.includes(token)) score += 5;
+ if (phrases.includes(token)) score += 4;
+ if (clues.includes(token)) score += 3;
+ if (haystack.includes(token)) score += 1;
+ }
}
return score > 0 ? { mechanism, score } : null;
diff --git a/src/lib/privacy-page-content.tsx b/src/lib/privacy-page-content.tsx
index 298c4e8d20..a09f1d4f05 100644
--- a/src/lib/privacy-page-content.tsx
+++ b/src/lib/privacy-page-content.tsx
@@ -8,9 +8,10 @@ import type { ReactNode } from "react";
* `` spacing on "not a patient-record system" must not drift.
*
* Everything here is a claim about *configured application behaviour* that a
- * reader can check against the repository. Contractual posture — data-processing
- * agreements, provider retention terms, zero-retention arrangements — is an
- * operator matter this page deliberately does not assert. See
+ * reader can check against the repository, or expressly qualified, time-stamped
+ * provider-account evidence. Contractual posture — data-processing agreements,
+ * provider retention terms, zero-retention arrangements — is an operator matter
+ * this page deliberately does not assert. See
* `docs/privacy-impact-assessment.md` and `docs/openai-cross-border-basis.md`.
*/
@@ -22,7 +23,7 @@ export const PRIVACY_DRAFT_DISCLAIMER =
* "describes configured behaviour as of …" — deliberately not "reviewed", which
* would imply a governance sign-off that PIA-5 records as still outstanding.
*/
-export const PRIVACY_CONTENT_AS_OF = "2026-08-27";
+export const PRIVACY_CONTENT_AS_OF = "2026-09-01";
export const PRIVACY_IMPORTANT_SHORT =
"Do not enter identifiable patient details. Processing may include Singapore and the OpenAI API.";
@@ -119,9 +120,10 @@ export const PRIVACY_SECTIONS: PrivacySection[] = [
question and selected source excerpts are also sent. This processing may occur outside Australia. The operator
must verify provider regions, retention terms, contracts, and cross-border obligations.
>,
- "The requests the app sends carry deliberate limits: it asks the provider not to retain the response in the provider's own stored-response history, it never sends your raw account identifier — a keyed pseudonym is used instead when the operator configures one — and it asks for the shortest prompt-cache lifetime the model supports. A requested cache lifetime is a minimum the provider may exceed, not a deletion deadline.",
+ "The requests the app sends carry deliberate limits: it asks the provider not to retain the response in the provider's own stored-response history, it never sends your raw account identifier — a keyed pseudonym is used instead when the operator configures one — and it asks for the shortest prompt-cache lifetime the model supports. OpenAI states that prompt-cache data may remain on its local GPU machines for up to 24 hours; the app's requested 30-minute lifetime is a minimum cache lifetime, not that maximum or a deletion deadline.",
+ "As an interim provider control, the OpenAI organisation inspected on 1 September 2026 has API data sharing and API call logging disabled, and its hosted MCP, web search, file search, image generation, code interpreter, and container-network access disabled. The operator has submitted a Zero Data Retention request, but OpenAI has not yet approved it, and the operator has not yet proved that the production key targets the inspected project. Until approved and configured, OpenAI's standard API controls may retain customer content in abuse-monitoring logs for up to 30 days. These controls reduce optional provider-side processing but do not establish Zero Data Retention, endpoint coverage, or a data-processing agreement.",
"Those are application settings, and they are the limit of what this page can tell you. Whether a data-processing agreement, a zero-retention arrangement, or a particular storage region is in place for the provider account is an operator and legal matter that the application cannot observe or promise.",
- "Clinical Ask may use server-side external authority search only for an evidence gap, unresolved conflict, staleness, or a source marked needs review. Returned authority extracts are discarded after the request; the answer retains attributable citations and retrieval dates. This does not mean an authority, source, answer, or feature has received clinical or governance approval.",
+ "Clinical Ask supports server-side external authority search only for an evidence gap, unresolved conflict, staleness, or a source marked needs review. That hosted-search path is disabled in the inspected provider project while governance review is incomplete. If an operator enables it later, returned authority extracts are discarded after the request; the answer retains attributable citations and retrieval dates. This does not mean an authority, source, answer, or feature has received clinical or governance approval.",
],
},
{
@@ -153,8 +155,8 @@ export const PRIVACY_SECTIONS: PrivacySection[] = [
short: "Retention",
gist: "30-day queries · 90-day logs · hourly cache purge",
body: [
- "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Uploaded documents remain until removed under the applicable process. Completed answer threads in the current browser tab expire no later than 12 hours after the most recent answer and are also cleared by New chat, sign-out, or an account change. Safety-plan working content has no PsychSift retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.",
- "Memory-only Clinical Ask handling is not a zero-retention promise for providers or network infrastructure. Provider retention, regional processing, the separately deployable feedback migration, staging evidence, clinical evaluation, and production readiness must each be verified by the responsible operator before launch.",
+ "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows. The operator verified the four expected schedules on production and staging and approved that evidence on 1 September 2026. Uploaded documents remain until removed under the applicable process. Completed answer threads in the current browser tab expire no later than 12 hours after the most recent answer and are also cleared by New chat, sign-out, or an account change. Safety-plan working content has no PsychSift retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.",
+ "Memory-only Clinical Ask handling is not a zero-retention promise for providers or network infrastructure. Provider retention, regional processing, the separately deployable feedback migration, clinical evaluation, and production readiness must each be verified by the responsible operator before launch.",
"Audit records are the deliberate exception: they are append-only and retained indefinitely by design, because an access trail that expires cannot answer a later question about who reached what.",
],
},
diff --git a/src/lib/rag/answer-composition.ts b/src/lib/rag/answer-composition.ts
index 19e84f0613..73e5d2d679 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 c675507128..7fafb12232 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 61d52e6a6f..41d5ccf9e9 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 2029e7e9ff..00ba9b92da 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 b4df666079..e0d58dfa4a 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 31733a66ed..6281869bb6 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
-// "ignore 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 6465224163..3b7edab9d6 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 6cc3823b79..1ddb92b145 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/src/lib/service-catalog-mapper.ts b/src/lib/service-catalog-mapper.ts
index 49a7647df6..f3932b8bd6 100644
--- a/src/lib/service-catalog-mapper.ts
+++ b/src/lib/service-catalog-mapper.ts
@@ -64,7 +64,12 @@ function acuityLabel(flag: string) {
}
function sourceStatusForService(service: CatalogService): string {
- if (service.confidence === "High" && service.verification_flags.length === 0) {
+ if (
+ service.confidence === "High" &&
+ service.public_source_urls.length > 0 &&
+ Boolean(service.web_review_status.trim()) &&
+ service.verification_flags.length === 0
+ ) {
return "Source checked";
}
if (service.verification_flags.length > 0 || service.confidence === "Medium" || service.confidence === "Low") {
@@ -260,10 +265,6 @@ function buildCriteria(service: CatalogService): ServiceCriterion[] {
criteria.push({ label: clause, tone: "reject" });
}
- if (service.tags.acuity_flags.includes("crisis_high")) {
- criteria.push({ label: "Non-crisis routine referral only", tone: "reject" });
- }
-
for (const flag of service.verification_flags) {
const note = displayField(flag, CARD_MAX);
if (note) criteria.push({ label: note, tone: "caution" });
diff --git a/src/lib/service-catalog.ts b/src/lib/service-catalog.ts
index 7b7dd4bcde..2f6da929dd 100644
--- a/src/lib/service-catalog.ts
+++ b/src/lib/service-catalog.ts
@@ -1,4 +1,5 @@
import servicesSnapshot from "../../data/services-snapshot.json";
+import { mergeCanonicalCatalogServices } from "@/lib/service-governance";
export type CatalogServiceTags = {
catchments: string[];
@@ -7,11 +8,59 @@ export type CatalogServiceTags = {
acuity_flags: string[];
substance_flags: string[];
housing_flags: string[];
+ specialist_groups?: string[];
+ availability_flags?: string[];
+};
+
+export type CatalogServiceEvidenceSource = {
+ sourceId: string;
+ title: string;
+ issuer: string;
+ sourceClass: string;
+ jurisdiction: string;
+ publicationOrEffectiveDate: string;
+ url: string;
+ accessedAt: string;
+ limitations?: string;
+};
+
+export type CatalogServiceClaim = {
+ claimId: string;
+ field: string;
+ text: string;
+ sourceIds: string[];
+ verifiedAt: string;
+ nextReviewAt: string;
+ reviewer: string;
+ confidence: "high" | "medium" | "low";
+ riskLevel: "critical" | "high" | "moderate" | "low";
+ conflictStatus: "none" | "unresolved" | "resolved";
+};
+
+export type CatalogServiceReferralRoute = {
+ routeType: string;
+ summary: string;
+ selfReferral: boolean | null;
+ requiredDocuments: string[];
+};
+
+export type CatalogServiceStructuredContact = {
+ label: string;
+ value: string;
+ kind: string;
+};
+
+export type CatalogServiceStructuredHours = {
+ display: string;
+ timezone: string;
+ verificationStatus: "verified" | "unable_to_verify";
};
export type CatalogService = {
id: string;
+ stable_id?: string;
name: string;
+ aliases?: string[];
sections: string[];
inclusion_criteria: string;
exclusions: string;
@@ -32,6 +81,8 @@ export type CatalogService = {
confidence: string;
confidence_rank?: number;
public_source_urls: string[];
+ service_website?: string;
+ evidence_sources?: CatalogServiceEvidenceSource[];
web_review_status: string;
source_documents: string[];
source_row_count?: number;
@@ -42,6 +93,22 @@ export type CatalogService = {
analyst_notes: string;
search_text: string;
canonical_name_key: string;
+ availability_status?: string;
+ availability_note?: string;
+ presentation_tier?: string;
+ verification_status?: string;
+ last_verified?: string;
+ next_review_at?: string;
+ not_for?: string[];
+ referral_routes?: CatalogServiceReferralRoute[];
+ structured_contacts?: CatalogServiceStructuredContact[];
+ structured_hours?: CatalogServiceStructuredHours;
+ claims?: CatalogServiceClaim[];
+ specialist_groups?: string[];
+ quick_route_intents?: string[];
+ superseded_by?: string;
+ unresolved_issues?: string[];
+ jurisdiction?: string;
};
export type ServiceCatalogSnapshot = {
@@ -72,6 +139,56 @@ function toCleanNumber(value: unknown): number | undefined {
return Number.isFinite(numeric) ? numeric : undefined;
}
+function toCleanOptionalText(value: unknown): string | undefined {
+ const clean = toCleanText(value);
+ return clean || undefined;
+}
+
+function toRecord(value: unknown): Record | null {
+ return typeof value === "object" && value !== null ? (value as Record) : null;
+}
+
+function normalizeEvidenceSources(value: unknown): CatalogServiceEvidenceSource[] {
+ if (!Array.isArray(value)) return [];
+ return value.flatMap((entry) => {
+ const source = toRecord(entry);
+ if (!source) return [];
+ const sourceId = toCleanText(source.sourceId);
+ const title = toCleanText(source.title);
+ const issuer = toCleanText(source.issuer);
+ const sourceClass = toCleanText(source.sourceClass);
+ const jurisdiction = toCleanText(source.jurisdiction);
+ const publicationOrEffectiveDate = toCleanText(source.publicationOrEffectiveDate);
+ const url = toCleanText(source.url);
+ const accessedAt = toCleanText(source.accessedAt);
+ if (
+ !sourceId ||
+ !title ||
+ !issuer ||
+ !sourceClass ||
+ !jurisdiction ||
+ !publicationOrEffectiveDate ||
+ !url ||
+ !accessedAt
+ ) {
+ return [];
+ }
+ return [
+ {
+ sourceId,
+ title,
+ issuer,
+ sourceClass,
+ jurisdiction,
+ publicationOrEffectiveDate,
+ url,
+ accessedAt,
+ limitations: toCleanOptionalText(source.limitations),
+ },
+ ];
+ });
+}
+
function normalizeServiceId(value: unknown, fallback: string): string {
const clean = toCleanText(value, fallback);
if (/^S\d{3}$/i.test(clean)) return clean.toUpperCase();
@@ -116,7 +233,11 @@ export function normalizeCatalogService(raw: unknown, index: number): CatalogSer
acuity_flags: toStringArray(tagsSource.acuity_flags),
substance_flags: toStringArray(tagsSource.substance_flags),
housing_flags: toStringArray(tagsSource.housing_flags),
+ specialist_groups: toStringArray(tagsSource.specialist_groups),
+ availability_flags: toStringArray(tagsSource.availability_flags),
},
+ stable_id: toCleanOptionalText(source.stable_id),
+ aliases: toStringArray(source.aliases),
source_files: toStringArray(source.source_files),
provider: toCleanText(source.provider),
region_catchment: toCleanText(source.region_catchment),
@@ -132,6 +253,8 @@ export function normalizeCatalogService(raw: unknown, index: number): CatalogSer
confidence: toCleanText(source.confidence),
confidence_rank: toCleanNumber(source.confidence_rank),
public_source_urls: toStringArray(source.public_source_urls),
+ service_website: toCleanOptionalText(source.service_website),
+ evidence_sources: normalizeEvidenceSources(source.evidence_sources),
web_review_status: toCleanText(source.web_review_status),
source_documents: toStringArray(source.source_documents),
source_row_count: toCleanNumber(source.source_row_count),
@@ -142,6 +265,18 @@ export function normalizeCatalogService(raw: unknown, index: number): CatalogSer
analyst_notes: toCleanText(source.analyst_notes),
search_text: toCleanText(source.search_text),
canonical_name_key: toCleanText(source.canonical_name_key),
+ availability_status: toCleanOptionalText(source.availability_status),
+ availability_note: toCleanOptionalText(source.availability_note),
+ presentation_tier: toCleanOptionalText(source.presentation_tier),
+ verification_status: toCleanOptionalText(source.verification_status),
+ last_verified: toCleanOptionalText(source.last_verified),
+ next_review_at: toCleanOptionalText(source.next_review_at),
+ not_for: toStringArray(source.not_for),
+ specialist_groups: toStringArray(source.specialist_groups),
+ quick_route_intents: toStringArray(source.quick_route_intents),
+ superseded_by: toCleanOptionalText(source.superseded_by),
+ unresolved_issues: toStringArray(source.unresolved_issues),
+ jurisdiction: toCleanOptionalText(source.jurisdiction),
};
}
@@ -169,9 +304,11 @@ export function normalizeCatalogServices(rawCatalog: unknown): CatalogService[]
export function loadServicesSnapshot(): ServiceCatalogSnapshot {
if (cachedSnapshot) return cachedSnapshot;
const parsed = servicesSnapshot as ServiceCatalogSnapshot;
+ const services = mergeCanonicalCatalogServices(normalizeCatalogServices(parsed));
cachedSnapshot = {
...parsed,
- services: normalizeCatalogServices(parsed),
+ service_count: services.length,
+ services,
};
return cachedSnapshot;
}
diff --git a/src/lib/service-governance.ts b/src/lib/service-governance.ts
new file mode 100644
index 0000000000..4b2b90970c
--- /dev/null
+++ b/src/lib/service-governance.ts
@@ -0,0 +1,453 @@
+import canonicalPart01 from "@/lib/services-canonical-data/part-01";
+import canonicalPart02 from "@/lib/services-canonical-data/part-02";
+
+import type {
+ CatalogService,
+ CatalogServiceClaim,
+ CatalogServiceEvidenceSource,
+ CatalogServiceReferralRoute,
+ CatalogServiceStructuredContact,
+ CatalogServiceStructuredHours,
+ CatalogServiceTags,
+} from "@/lib/service-catalog";
+
+export type ServiceAvailabilityStatus =
+ "active" | "planned" | "temporarily_unavailable" | "closed" | "superseded" | "unknown";
+
+export type ServicePresentationTier = "A_immediate" | "B_common_referral" | "C_specialist_support";
+export type ServiceVerificationStatus =
+ "verified_current_core" | "locally_confirmed" | "legacy_unverified" | "unable_to_verify";
+
+export type CanonicalServiceSourceInput = {
+ id: string;
+ title: string;
+ issuer: string;
+ class: string;
+ url: string;
+ date: string;
+ accessed: string;
+ limitations: string;
+};
+
+export type CanonicalServiceInput = {
+ id: string;
+ name: string;
+ aliases: readonly string[];
+ match: readonly string[];
+ category: string;
+ groups: readonly string[];
+ tier: ServicePresentationTier;
+ status: ServiceAvailabilityStatus;
+ statusNote: string;
+ jurisdiction: string;
+ catchments: readonly string[];
+ population: string;
+ ages: readonly string[];
+ bestUse: string;
+ notFor: readonly string[];
+ routes: readonly {
+ route_type: string;
+ summary: string;
+ self_referral: boolean | null;
+ required_documents: readonly string[];
+ }[];
+ contacts: readonly { label: string; value: string; kind: string }[];
+ hours: {
+ display: string;
+ timezone: string;
+ verification_status: "verified" | "unable_to_verify";
+ };
+ website: string | null;
+ verification: ServiceVerificationStatus;
+ verified: string;
+ review: string;
+ intents: readonly string[];
+ supersededBy: string | null;
+ issues: readonly string[];
+ sources: readonly CanonicalServiceSourceInput[];
+};
+
+const canonicalRecords = [...canonicalPart01, ...canonicalPart02] as unknown as readonly CanonicalServiceInput[];
+
+const URL_PATTERN = /^https?:\/\/[^\s]+$/i;
+const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+const REVIEWER = "OpenAI Deep Research synthesis; repository clinical-owner sign-off pending";
+
+function unique(values: readonly string[]): string[] {
+ const seen = new Set();
+ const result: string[] = [];
+ for (const value of values) {
+ const clean = value.trim();
+ const key = clean.toLowerCase();
+ if (!clean || seen.has(key)) continue;
+ seen.add(key);
+ result.push(clean);
+ }
+ return result;
+}
+
+export function serviceIdentityKey(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/&/g, " and ")
+ .replace(/\([^)]*\)/g, " ")
+ .replace(/\b(?:service|services|program|programme|network|facility|western australia|wa)\b/g, " ")
+ .replace(/[^a-z0-9]+/g, " ")
+ .trim()
+ .replace(/\s+/g, "-");
+}
+
+function slug(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+function sourceEvidence(record: CanonicalServiceInput): CatalogServiceEvidenceSource[] {
+ return record.sources.map((source) => ({
+ sourceId: source.id,
+ title: source.title,
+ issuer: source.issuer,
+ sourceClass: source.class,
+ jurisdiction: record.jurisdiction,
+ publicationOrEffectiveDate: source.date,
+ url: source.url,
+ accessedAt: source.accessed,
+ limitations: source.limitations || undefined,
+ }));
+}
+
+function recordClaims(record: CanonicalServiceInput): CatalogServiceClaim[] {
+ const sourceIds = record.sources.map((source) => source.id);
+ const claim = (field: string, text: string, riskLevel: CatalogServiceClaim["riskLevel"]): CatalogServiceClaim => ({
+ claimId: `${record.id}.${field}`,
+ field,
+ text,
+ sourceIds,
+ verifiedAt: record.verified,
+ nextReviewAt: record.review,
+ reviewer: REVIEWER,
+ confidence: record.verification === "verified_current_core" ? "high" : "medium",
+ riskLevel,
+ conflictStatus: record.issues.length ? "unresolved" : "none",
+ });
+
+ return [
+ claim("availability_status", record.status, "critical"),
+ claim("contacts", record.contacts.map((contact) => contact.value).join("; "), "critical"),
+ claim("hours", record.hours.display, "critical"),
+ claim("catchments", record.catchments.join("; "), "high"),
+ claim("population", record.population, "high"),
+ claim("best_use", record.bestUse, "moderate"),
+ ];
+}
+
+function canonicalTags(record: CanonicalServiceInput): CatalogServiceTags {
+ const groupSet = new Set(record.groups);
+ const isAod = groupSet.has("alcohol_other_drugs");
+ const isHousing = groupSet.has("homelessness_housing");
+ return {
+ catchments: [...record.catchments],
+ age_groups: [...record.ages],
+ setting_flags: unique([
+ record.tier === "A_immediate" ? "digital_phone" : "community",
+ groupSet.has("public_mental_health") ? "public" : "general",
+ ]),
+ acuity_flags: [
+ record.tier === "A_immediate" ? "crisis_high" : record.tier === "B_common_referral" ? "moderate" : "supportive",
+ ],
+ substance_flags: [isAod ? "aod" : "general"],
+ housing_flags: [isHousing ? "housing_support" : "general"],
+ specialist_groups: [...record.groups],
+ availability_flags: [record.status],
+ };
+}
+
+function verificationFlags(record: CanonicalServiceInput): string[] {
+ const flags = [...record.issues];
+ if (record.status === "planned") flags.push("Planned service — not currently referable");
+ if (record.status === "temporarily_unavailable") flags.push("Temporarily unavailable — confirm an alternative route");
+ if (record.status === "closed") flags.push("Closed service — do not refer");
+ if (record.status === "superseded") flags.push("Superseded service — use the replacement pathway");
+ if (record.hours.verification_status === "unable_to_verify") flags.push("Hours could not be verified");
+ return unique(flags);
+}
+
+function structuredRoutes(record: CanonicalServiceInput): CatalogServiceReferralRoute[] {
+ return record.routes.map((route) => ({
+ routeType: route.route_type,
+ summary: route.summary,
+ selfReferral: route.self_referral,
+ requiredDocuments: [...route.required_documents],
+ }));
+}
+
+function structuredContacts(record: CanonicalServiceInput): CatalogServiceStructuredContact[] {
+ return record.contacts.map((contact) => ({ ...contact }));
+}
+
+function structuredHours(record: CanonicalServiceInput): CatalogServiceStructuredHours {
+ return {
+ display: record.hours.display,
+ timezone: record.hours.timezone,
+ verificationStatus: record.hours.verification_status,
+ };
+}
+
+function canonicalToCatalogService(record: CanonicalServiceInput): CatalogService {
+ const evidence = sourceEvidence(record);
+ const contactDetails = record.contacts
+ .map((contact) => contact.value)
+ .filter(Boolean)
+ .join("; ");
+ const route = record.routes[0]?.summary || "Contact service and confirm the referral pathway";
+ const exclusions = record.notFor.join(" | ");
+ const provider = evidence[0]?.issuer ?? "Authoritative service source";
+
+ return {
+ id: record.id,
+ stable_id: record.id,
+ name: record.name,
+ aliases: [...record.aliases],
+ sections: [record.category],
+ inclusion_criteria: `${record.bestUse} | Patient group: ${record.population}`,
+ exclusions,
+ referral_details: [
+ `Contact: ${contactDetails || "Confirm locally"}`,
+ `Referral pathway: ${route}`,
+ `Hours: ${record.hours.display}`,
+ ].join(" | "),
+ tags: canonicalTags(record),
+ source_files: [],
+ provider,
+ region_catchment: record.catchments.join("; "),
+ patient_group: record.population,
+ best_use_indication: record.bestUse,
+ referral_pathway: route,
+ eligibility_referral_criteria: record.population,
+ exclusion_rejection_criteria: exclusions,
+ contact_details: contactDetails,
+ hours: record.hours.display,
+ cost_funding: "",
+ discharge_planning_usefulness: record.bestUse,
+ confidence: record.verification === "verified_current_core" ? "High" : "Medium",
+ confidence_rank: record.verification === "verified_current_core" ? 3 : 2,
+ public_source_urls: evidence.map((source) => source.url),
+ service_website: record.website ?? undefined,
+ evidence_sources: evidence,
+ web_review_status: `Verified ${record.verified}; next review ${record.review}`,
+ source_documents: evidence.map((source) => source.sourceId),
+ source_row_count: 1,
+ merged_aliases: unique([record.name, ...record.aliases]),
+ source_table_lines: "",
+ deep_research_citation_tokens: "",
+ verification_flags: verificationFlags(record),
+ analyst_notes: record.issues.join(" | "),
+ search_text: [
+ record.name,
+ ...record.aliases,
+ record.category,
+ ...record.groups,
+ ...record.catchments,
+ record.population,
+ record.bestUse,
+ ...record.notFor,
+ ...record.intents,
+ contactDetails,
+ ].join("\n"),
+ canonical_name_key: slug(record.name),
+ availability_status: record.status,
+ availability_note: record.statusNote,
+ presentation_tier: record.tier,
+ verification_status: record.verification,
+ last_verified: record.verified,
+ next_review_at: record.review,
+ not_for: [...record.notFor],
+ referral_routes: structuredRoutes(record),
+ structured_contacts: structuredContacts(record),
+ structured_hours: structuredHours(record),
+ claims: recordClaims(record),
+ specialist_groups: [...record.groups],
+ quick_route_intents: [...record.intents],
+ superseded_by: record.supersededBy ?? undefined,
+ unresolved_issues: [...record.issues],
+ jurisdiction: record.jurisdiction,
+ };
+}
+
+function mergeTags(left: CatalogServiceTags, right: CatalogServiceTags): CatalogServiceTags {
+ return {
+ catchments: unique([...left.catchments, ...right.catchments]),
+ age_groups: unique([...left.age_groups, ...right.age_groups]),
+ setting_flags: unique([...left.setting_flags, ...right.setting_flags]),
+ acuity_flags: unique([...left.acuity_flags, ...right.acuity_flags]),
+ substance_flags: unique([...left.substance_flags, ...right.substance_flags]),
+ housing_flags: unique([...left.housing_flags, ...right.housing_flags]),
+ specialist_groups: unique([...(left.specialist_groups ?? []), ...(right.specialist_groups ?? [])]),
+ availability_flags: unique([...(left.availability_flags ?? []), ...(right.availability_flags ?? [])]),
+ };
+}
+
+function legacyKeys(service: CatalogService): Set {
+ return new Set(
+ [service.name, service.canonical_name_key, ...(service.aliases ?? []), ...service.merged_aliases]
+ .flatMap((value) => [serviceIdentityKey(value), slug(value)])
+ .filter(Boolean),
+ );
+}
+
+function canonicalKeys(record: CanonicalServiceInput): Set {
+ return new Set(
+ [record.name, ...record.aliases, ...record.match]
+ .flatMap((value) => [serviceIdentityKey(value), slug(value)])
+ .filter(Boolean),
+ );
+}
+
+function matchingCanonicalRecord(
+ legacy: CatalogService,
+ recordsByKey: ReadonlyMap,
+ usedIds: ReadonlySet,
+): CanonicalServiceInput | undefined {
+ const candidates = [...legacyKeys(legacy)].flatMap((key) => recordsByKey.get(key) ?? []);
+ return candidates.find((record) => !usedIds.has(record.id));
+}
+
+function mergeCanonicalIntoLegacy(legacy: CatalogService, record: CanonicalServiceInput): CatalogService {
+ const canonical = canonicalToCatalogService(record);
+ return {
+ ...legacy,
+ ...canonical,
+ id: legacy.id,
+ stable_id: record.id,
+ provider: canonical.provider || legacy.provider,
+ cost_funding: legacy.cost_funding,
+ source_files: legacy.source_files,
+ source_row_count: legacy.source_row_count,
+ source_table_lines: legacy.source_table_lines,
+ tags: mergeTags(legacy.tags, canonical.tags),
+ merged_aliases: unique([legacy.name, ...legacy.merged_aliases, record.name, ...record.aliases]),
+ canonical_name_key: legacy.canonical_name_key || canonical.canonical_name_key,
+ };
+}
+
+function legacyEvidence(service: CatalogService): CatalogServiceEvidenceSource[] {
+ return service.public_source_urls
+ .filter((url) => URL_PATTERN.test(url))
+ .map((url, index) => ({
+ sourceId: `legacy-${service.id}-${index + 1}`,
+ title: service.sections[0] ?? `${service.name} source`,
+ issuer: "Legacy catalogue source",
+ sourceClass: "Legacy unreviewed source",
+ jurisdiction: "Western Australia",
+ publicationOrEffectiveDate: "Unknown",
+ url,
+ accessedAt: "Unknown",
+ limitations: "Current operational claims were not independently re-verified in the canonical review.",
+ }));
+}
+
+function quarantineLegacyService(service: CatalogService): CatalogService {
+ const sectionGroups = service.sections.map(slug).filter(Boolean);
+ return {
+ ...service,
+ stable_id: service.stable_id || service.id,
+ aliases: service.aliases ?? [],
+ tags: {
+ ...service.tags,
+ specialist_groups: unique([...(service.tags.specialist_groups ?? []), ...sectionGroups]),
+ availability_flags: unique([...(service.tags.availability_flags ?? []), "unknown"]),
+ },
+ availability_status: "unknown",
+ availability_note: "Legacy catalogue record; active status not re-verified",
+ verification_status: "legacy_unverified",
+ not_for: service.not_for ?? [],
+ evidence_sources: service.evidence_sources?.length ? service.evidence_sources : legacyEvidence(service),
+ claims: service.claims ?? [],
+ specialist_groups: unique([...(service.specialist_groups ?? []), ...sectionGroups]),
+ quick_route_intents: service.quick_route_intents ?? [],
+ unresolved_issues: unique([
+ ...(service.unresolved_issues ?? []),
+ "Legacy catalogue record — current contact, hours, eligibility, catchment and availability require re-verification.",
+ ]),
+ verification_flags: unique([
+ ...service.verification_flags,
+ "Legacy catalogue record — verify current operational details before use",
+ ]),
+ };
+}
+
+export function mergeCanonicalCatalogServices(legacyServices: readonly CatalogService[]): CatalogService[] {
+ const recordsByKey = new Map();
+ for (const record of canonicalRecords) {
+ for (const key of canonicalKeys(record)) recordsByKey.set(key, [...(recordsByKey.get(key) ?? []), record]);
+ }
+
+ const usedIds = new Set();
+ const merged = legacyServices.map((legacy) => {
+ const match = matchingCanonicalRecord(legacy, recordsByKey, usedIds);
+ if (!match) return quarantineLegacyService(legacy);
+ usedIds.add(match.id);
+ return mergeCanonicalIntoLegacy(legacy, match);
+ });
+
+ const usedSlugs = new Set(merged.map((service) => service.canonical_name_key || slug(service.name)));
+ for (const record of canonicalRecords) {
+ if (usedIds.has(record.id)) continue;
+ const service = canonicalToCatalogService(record);
+ const desiredSlug = service.canonical_name_key;
+ if (usedSlugs.has(desiredSlug)) service.canonical_name_key = `${desiredSlug}-${record.id.toLowerCase()}`;
+ usedSlugs.add(service.canonical_name_key);
+ merged.push(service);
+ }
+ return merged;
+}
+
+export function canonicalDatasetMetadata(): Record {
+ return {
+ schema_version: "2026-09-01.services-governance.1",
+ evidence_cutoff: "2026-08-23",
+ canonical_record_count: canonicalRecords.length,
+ clinical_owner_sign_off: "pending",
+ };
+}
+
+export function canonicalServiceRecords(): readonly CanonicalServiceInput[] {
+ return canonicalRecords;
+}
+
+export function canonicalServiceValidationErrors(records = canonicalRecords): string[] {
+ const errors: string[] = [];
+ const ids = new Set();
+ for (const record of records) {
+ if (ids.has(record.id)) errors.push(`Duplicate stable ID: ${record.id}`);
+ ids.add(record.id);
+ if (!record.name.trim()) errors.push(`${record.id} has no name`);
+ if (!ISO_DATE_PATTERN.test(record.verified)) errors.push(`${record.id} has invalid verified date`);
+ if (!ISO_DATE_PATTERN.test(record.review)) errors.push(`${record.id} has invalid review date`);
+ if (record.review <= record.verified) errors.push(`${record.id} review date is not later than verified date`);
+ if (record.sources.length === 0) errors.push(`${record.id} has no authoritative source`);
+ for (const source of record.sources) {
+ if (!source.id.trim()) errors.push(`${record.id} has a source without an ID`);
+ if (!URL_PATTERN.test(source.url)) errors.push(`${record.id} has invalid source URL ${source.url}`);
+ if (!ISO_DATE_PATTERN.test(source.accessed)) errors.push(`${record.id} has invalid source access date`);
+ }
+ if (record.tier === "A_immediate" && record.status === "active") {
+ if (!record.contacts.some((contact) => contact.value.trim())) errors.push(`${record.id} has no urgent contact`);
+ if (!record.hours.display.trim()) errors.push(`${record.id} has no urgent hours`);
+ if (record.verification !== "verified_current_core") errors.push(`${record.id} urgent route is not verified`);
+ }
+ if (
+ record.tier === "A_immediate" &&
+ record.notFor.some((value) => /non-crisis routine referral only/i.test(value))
+ ) {
+ errors.push(`${record.id} carries a contradictory non-crisis exclusion`);
+ }
+ }
+ return errors;
+}
+
+const canonicalErrors = canonicalServiceValidationErrors();
+if (canonicalErrors.length) throw new Error(`Invalid canonical services data:\n${canonicalErrors.join("\n")}`);
diff --git a/src/lib/service-ranker.ts b/src/lib/service-ranker.ts
index b67912e51b..f0188bea84 100644
--- a/src/lib/service-ranker.ts
+++ b/src/lib/service-ranker.ts
@@ -1,4 +1,5 @@
import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
+import { smartSearchExpansions } from "@/lib/smart-search-intent";
export type ServiceChipTone = "danger" | "info" | "warning" | "success" | "neutral";
export type ServiceCriterionTone = "meet" | "caution" | "reject";
@@ -131,7 +132,12 @@ export function rankServiceRecords(
limit = records.length,
// Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane.
expansions: string[] = [],
+ interpretNaturalLanguage = false,
): ServiceSearchMatch[] {
+ const interpretedExpansions = [
+ ...expansions,
+ ...(interpretNaturalLanguage ? smartSearchExpansions("services", query) : []),
+ ];
return rankCatalogRecords(records, query, {
fields: [
{ id: "title", weight: 6, text: (service) => normalizeSearchText(`${service.title} ${service.slug}`) },
@@ -148,7 +154,7 @@ export function rankServiceRecords(
phraseBonus: 4,
broadTerms: ["service", "services", "pathway", "pathways"],
broadBonus: 1,
- expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined,
+ expandTokens: interpretedExpansions.length ? (terms) => [...terms, ...interpretedExpansions] : undefined,
limit,
tieBreak: (left, right) => left.title.localeCompare(right.title),
}).map(({ record, score, signals }) => ({
diff --git a/src/lib/services-canonical-data/part-01.ts b/src/lib/services-canonical-data/part-01.ts
new file mode 100644
index 0000000000..18724975d0
--- /dev/null
+++ b/src/lib/services-canonical-data/part-01.ts
@@ -0,0 +1,5 @@
+// Generated from the 2026-08-23 WA Services deep-research package.
+// Operational claims are governed by each record's verified/review dates.
+
+// prettier-ignore
+export default [{"id":"SVC-HOU-002","name":"Crisis Care","aliases":[],"match":["crisis-care"],"category":"Crisis / homelessness / child protection / FDV","groups":["urgent_crisis","child_youth","homelessness_housing"],"tier":"A_immediate","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["WA statewide"],"population":"People in crisis","ages":["mixed","youth"],"bestUse":"After-hours child safety response and crisis information/referral including homelessness and FDV.","notFor":[],"routes":[{"route_type":"phone_contact","summary":"1800 199 008","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"1800 199 008","kind":"phone_or_pathway"}],"hours":{"display":"After-hours / 24-hour crisis line","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.wa.gov.au/service/community-services/community-support/homelessness-services","verification":"verified_current_core","verified":"2026-08-23","review":"2026-11-23","intents":["Homelessness navigation"],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-001","title":"Crisis Care","issuer":"Government of Western Australia","class":"Tier 1 official/government","url":"https://www.wa.gov.au/service/community-services/community-support/homelessness-services","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-SUI-002","name":"Lifeline WA","aliases":[],"match":["lifeline-wa"],"category":"Suicide crisis/support","groups":["urgent_crisis","suicide_crisis_aftercare_postvention"],"tier":"A_immediate","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["WA / national"],"population":"All","ages":["mixed"],"bestUse":"General crisis and suicide support. Immediate danger -> 000.","notFor":[],"routes":[{"route_type":"phone_contact","summary":"13 11 14; text 0477 13 11 14","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"13 11 14; text 0477 13 11 14","kind":"phone_or_pathway"}],"hours":{"display":"24/7","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/getting-help/your-health-and-wellbeing/suicide","verification":"verified_current_core","verified":"2026-08-23","review":"2026-11-23","intents":["Suicidal crisis / suicide-related counselling"],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-002","title":"Lifeline WA; Suicide Call Back Service; Emergency services; Culture Care Connect; WA Suicide Aftercare Services Program; Carer Gateway; …","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/getting-help/your-health-and-wellbeing/suicide","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-SUI-001","name":"Suicide Call Back Service","aliases":[],"match":["suicide-call-back-service"],"category":"Suicide crisis/support","groups":["urgent_crisis","suicide_crisis_aftercare_postvention"],"tier":"A_immediate","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["Australia / WA"],"population":"Anyone affected by suicide","ages":["mixed"],"bestUse":"Phone and online counselling for suicidal thoughts, suicidal crisis or people affected by suicide. Immediate danger -> 000.","notFor":[],"routes":[{"route_type":"phone_contact","summary":"1300 659 467","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"1300 659 467","kind":"phone_or_pathway"}],"hours":{"display":"24/7","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/getting-help/your-health-and-wellbeing/suicide","verification":"verified_current_core","verified":"2026-08-23","review":"2026-11-23","intents":["Suicidal crisis / suicide-related counselling"],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-002","title":"Lifeline WA; Suicide Call Back Service; Emergency services; Culture Care Connect; WA Suicide Aftercare Services Program; Carer Gateway; …","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/getting-help/your-health-and-wellbeing/suicide","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-URG-004","name":"Ambulance Mental Health Co-Response","aliases":[],"match":["ambulance-mental-health-co-response"],"category":"Urgent/crisis","groups":["urgent_crisis","public_mental_health"],"tier":"A_immediate","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["North, South and East metropolitan Perth"],"population":"Triple Zero ambulance callers age 16+ where mental health is primary concern","ages":["mixed","adult"],"bestUse":"Virtual mental-health triage/assessment plus mobile clinician-paramedic co-response when indicated. Not a direct self-referral number.","notFor":[],"routes":[{"route_type":"system_or_clinician_pathway","summary":"Access through 000 ambulance pathway","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"Access through 000 ambulance pathway","kind":"phone_or_pathway"}],"hours":{"display":"12pm-10pm, 7 days","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.health.wa.gov.au/Improving-WA-Health/Ambulance-Mental-Health-Co-Response","verification":"verified_current_core","verified":"2026-08-23","review":"2026-11-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-003","title":"Ambulance Mental Health Co-Response","issuer":"WA Department of Health","class":"Tier 1 official/government","url":"https://www.health.wa.gov.au/Improving-WA-Health/Ambulance-Mental-Health-Co-Response","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-URG-001","name":"Emergency services","aliases":[],"match":["emergency-services"],"category":"Urgent/crisis","groups":["urgent_crisis"],"tier":"A_immediate","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["WA"],"population":"All","ages":["mixed"],"bestUse":"Immediate danger, life-threatening emergency, serious injury or immediate safety threat -> Triple Zero (000) or nearest emergency department.","notFor":[],"routes":[{"route_type":"system_or_clinician_pathway","summary":"000","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"000","kind":"phone_or_pathway"}],"hours":{"display":"24/7","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/getting-help/your-health-and-wellbeing/suicide","verification":"verified_current_core","verified":"2026-08-23","review":"2026-11-23","intents":["Immediate life-threatening danger / severe medical emergency"],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-002","title":"Lifeline WA; Suicide Call Back Service; Emergency services; Culture Care Connect; WA Suicide Aftercare Services Program; Carer Gateway; …","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/getting-help/your-health-and-wellbeing/suicide","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-URG-002","name":"Mental Health Emergency Response Line (MHERL)","aliases":["MHERL","Mental Health Emergency Response Line"],"match":["mental-health-emergency-response-line-mherl","mental-health-emergency-response-line"],"category":"Urgent/crisis","groups":["urgent_crisis","navigation"],"tier":"A_immediate","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["Perth metro / Peel"],"population":"All","ages":["mixed"],"bestUse":"Mental health assessment, crisis support/planning, brief intervention and navigation. Not an emergency service; use 000 for life-threatening emergencies.","notFor":[],"routes":[{"route_type":"phone_contact","summary":"1300 555 788 (Metro); 1800 676 822 (Peel)","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"1300 555 788 (Metro); 1800 676 822 (Peel)","kind":"phone_or_pathway"}],"hours":{"display":"24/7","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/MHERL","verification":"verified_current_core","verified":"2026-08-23","review":"2026-11-23","intents":["Adult mental-health crisis - Perth metro","Mental-health crisis - Peel"],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-004","title":"Mental Health Emergency Response Line (MHERL)","issuer":"East Metropolitan Health Service","class":"Tier 1 official/government","url":"https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/MHERL","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-URG-003","name":"Rurallink","aliases":[],"match":["rurallink"],"category":"Urgent/crisis","groups":["urgent_crisis","navigation"],"tier":"A_immediate","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["Regional and remote WA"],"population":"All","ages":["mixed"],"bestUse":"After-hours regional mental health assessment, crisis support and navigation.","notFor":[],"routes":[{"route_type":"phone_contact","summary":"1800 552 002","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"1800 552 002","kind":"phone_or_pathway"}],"hours":{"display":"Weeknights 4:30pm-8:30am; 24h weekends/public holidays","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/Rurallink","verification":"verified_current_core","verified":"2026-08-23","review":"2026-11-23","intents":["Mental-health crisis - regional WA after hours"],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-005","title":"Rurallink","issuer":"East Metropolitan Health Service","class":"Tier 1 official/government","url":"https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/Rurallink","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-URG-005","name":"Safe Haven services - Royal Perth and Kununurra","aliases":[],"match":["safe-haven-services-royal-perth-and-kununurra"],"category":"Urgent/crisis alternative support","groups":["urgent_crisis"],"tier":"A_immediate","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["Royal Perth Hospital / Kununurra"],"population":"People in mental distress who may otherwise attend ED but do not need intensive clinical/medical support","ages":["mixed"],"bestUse":"Peer-based after-hours support alongside/near ED for mental distress when intensive clinical or medical intervention is not required.","notFor":["Do not use when medical instability, immediate danger or need for intensive psychiatric/medical assessment is present."],"routes":[{"route_type":"phone_contact","summary":"RPH: Victoria Square Safe Haven; Kununurra: 0456 030 979","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"RPH: Victoria Square Safe Haven; Kununurra: 0456 030 979","kind":"phone_or_pathway"}],"hours":{"display":"RPH Fri-Sun 3pm-7:45pm; Kununurra Wed-Fri 4pm-10pm, Sat 2pm-10pm","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/getting-help/other-support-services/hospital-services","verification":"verified_current_core","verified":"2026-08-23","review":"2026-11-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-066","title":"Safe Haven services - Royal Perth and Kununurra","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/getting-help/other-support-services/hospital-services","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]}] as const;
diff --git a/src/lib/services-canonical-data/part-02.ts b/src/lib/services-canonical-data/part-02.ts
new file mode 100644
index 0000000000..3472763cb3
--- /dev/null
+++ b/src/lib/services-canonical-data/part-02.ts
@@ -0,0 +1,5 @@
+// Generated from the 2026-08-23 WA Services deep-research package.
+// Operational claims are governed by each record's verified/review dates.
+
+// prettier-ignore
+export default [{"id":"SVC-AOD-002","name":"Community Alcohol and Drug Services (CADS)","aliases":["CADS","Community Alcohol and Drug Services network"],"match":["community-alcohol-and-drug-services-cads","community-alcohol-and-drug-services"],"category":"AOD","groups":["alcohol_other_drugs","carers_family"],"tier":"B_common_referral","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["WA statewide"],"population":"12+ plus families/carers","ages":["mixed"],"bestUse":"Free confidential assessment, counselling/case management, withdrawal, pharmacotherapy, medical/psychology, diversion, family support and outreach; regional providers differ.","notFor":[],"routes":[{"route_type":"contact_service","summary":"Nearest CADS; self-referral accepted","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"Nearest CADS; self-referral accepted","kind":"text"}],"hours":{"display":"Site-specific","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/getting-help/other-support-services/community-support-and-treatment-services/community-alcohol-and-drug-services","verification":"verified_current_core","verified":"2026-08-23","review":"2027-02-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-006","title":"Community Alcohol and Drug Services (CADS); Next Step Drug and Alcohol Services","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/getting-help/other-support-services/community-support-and-treatment-services/community-alcohol-and-drug-services","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-AOD-007","name":"Drug and Alcohol Clinical Advisory Service (DACAS)","aliases":["DACAS"],"match":["drug-and-alcohol-clinical-advisory-service-dacas","drug-and-alcohol-clinical-advisory-service"],"category":"AOD clinician advice","groups":["alcohol_other_drugs"],"tier":"B_common_referral","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["WA"],"population":"Health professionals","ages":["mixed"],"bestUse":"Specialist clinical telephone advice for health professionals managing AOD issues.","notFor":[],"routes":[{"route_type":"phone_contact","summary":"(08) 6553 0520","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"(08) 6553 0520","kind":"phone_or_pathway"}],"hours":{"display":"Mon-Fri 8am-8pm","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/our-initiatives/our-services","verification":"verified_current_core","verified":"2026-08-23","review":"2027-02-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-007","title":"Drug and Alcohol Clinical Advisory Service (DACAS)","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/our-initiatives/our-services","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-AOD-008","name":"Immediate Drug Assistance and Coordination Centre (IDACC) facility","aliases":["IDACC","Immediate Drug Assistance and Coordination Centre"],"match":["immediate-drug-assistance-and-coordination-centre-idacc-facility","immediate-drug-assistance-and-coordination-centre-facility"],"category":"AOD crisis facility","groups":["urgent_crisis","alcohol_other_drugs"],"tier":"B_common_referral","status":"planned","statusNote":"Planned / not active","jurisdiction":"Western Australia","catchments":["Perth city"],"population":"People/families in AOD crisis","ages":["mixed"],"bestUse":"Planned 24/7 drop-in hub, short-term crisis beds and assertive outreach/care coordination. Do not list as currently open.","notFor":["The full 24/7 Perth facility is planned, not currently operational."],"routes":[{"route_type":"contact_service","summary":"Not yet operational","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"Not yet operational","kind":"text"}],"hours":{"display":"Expected mid-2027","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/our-initiatives/our-projects/immediate-drug-assistance-coordination-centre","verification":"verified_current_core","verified":"2026-08-23","review":"2026-09-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-008","title":"Immediate Drug Assistance and Coordination Centre (IDACC) facility","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/our-initiatives/our-projects/immediate-drug-assistance-coordination-centre","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-AOD-005","name":"Parent and Family Drug Support Line","aliases":[],"match":["parent-and-family-drug-support-line"],"category":"AOD family/carer","groups":["urgent_crisis","alcohol_other_drugs","carers_family"],"tier":"B_common_referral","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["WA statewide"],"population":"Families/carers","ages":["mixed"],"bestUse":"Counselling, information, referral, lived-experience support and family programs. Not an emergency service.","notFor":[],"routes":[{"route_type":"phone_contact","summary":"(08) 9442 5050 Metro; 1800 653 203 Country","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"(08) 9442 5050 Metro; 1800 653 203 Country","kind":"phone_or_pathway"}],"hours":{"display":"24/7 counsellors; lived-experience volunteers 8am-10pm","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://admhss.mhc.wa.gov.au/get-help/parent-and-family-drug-support-line","verification":"verified_current_core","verified":"2026-08-23","review":"2027-02-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-009","title":"Parent and Family Drug Support Line","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://admhss.mhc.wa.gov.au/get-help/parent-and-family-drug-support-line","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-AOD-004","name":"Alcohol and Drug Support Line","aliases":[],"match":["alcohol-and-drug-support-line"],"category":"AOD helpline","groups":["urgent_crisis","alcohol_other_drugs"],"tier":"B_common_referral","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["WA statewide"],"population":"All","ages":["mixed"],"bestUse":"Confidential counselling, information and referral. Not an emergency service.","notFor":[],"routes":[{"route_type":"system_or_clinician_pathway","summary":"(08) 9442 5000 Metro; 1800 198 024 Country","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"(08) 9442 5000 Metro; 1800 198 024 Country","kind":"phone_or_pathway"}],"hours":{"display":"24/7","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://admhss.mhc.wa.gov.au/get-help/alcohol-and-drug-support-line","verification":"verified_current_core","verified":"2026-08-23","review":"2027-02-23","intents":["AOD advice / counselling / navigation"],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-010","title":"Alcohol and Drug Support Line","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://admhss.mhc.wa.gov.au/get-help/alcohol-and-drug-support-line","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-AOD-009","name":"WA residential AOD treatment and withdrawal services","aliases":[],"match":["wa-residential-aod-treatment-and-withdrawal-services"],"category":"AOD residential / withdrawal","groups":["alcohol_other_drugs","residential_recovery"],"tier":"B_common_referral","status":"active","statusNote":"Active network","jurisdiction":"Western Australia","catchments":["WA statewide"],"population":"People seeking voluntary residential withdrawal or rehabilitation; service-specific criteria","ages":["mixed"],"bestUse":"Voluntary specialist residential withdrawal and rehabilitation. Many services accept self-referral; most withdrawal services are free, but program/provider details vary.","notFor":["Not an acute medical emergency pathway","severe withdrawal/medical instability requires appropriate medical assessment."],"routes":[{"route_type":"contact_service","summary":"Use CADS/GP/AOD Support Line or My Services to identify appropriate provider","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"Use CADS/GP/AOD Support Line or My Services to identify appropriate provider","kind":"text"}],"hours":{"display":"Provider-specific","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/getting-help/other-support-services/residential-treatment-services-for-alcohol-and-other-drugs","verification":"verified_current_core","verified":"2026-08-23","review":"2027-02-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-067","title":"WA residential AOD treatment and withdrawal services","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/getting-help/other-support-services/residential-treatment-services-for-alcohol-and-other-drugs","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-AOD-003","name":"Next Step Drug and Alcohol Services","aliases":[],"match":["next-step-drug-and-alcohol-services"],"category":"AOD specialist treatment","groups":["alcohol_other_drugs"],"tier":"B_common_referral","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["East Perth + metropolitan CADS"],"population":"Adults / program-specific","ages":["adult"],"bestUse":"Outpatient services, inpatient withdrawal, pharmacotherapy and integrated CADS; program-specific criteria apply.","notFor":[],"routes":[{"route_type":"system_or_clinician_pathway","summary":"Via Next Step/CADS","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"Via Next Step/CADS","kind":"text"}],"hours":{"display":"Site-specific","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.mhc.wa.gov.au/getting-help/other-support-services/community-support-and-treatment-services/community-alcohol-and-drug-services","verification":"verified_current_core","verified":"2026-08-23","review":"2027-02-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-006","title":"Community Alcohol and Drug Services (CADS); Next Step Drug and Alcohol Services","issuer":"Mental Health Commission WA","class":"Tier 1 official/government","url":"https://www.mhc.wa.gov.au/getting-help/other-support-services/community-support-and-treatment-services/community-alcohol-and-drug-services","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]},{"id":"SVC-ABO-003","name":"Wungening - Ngalla Wirrin Wungening","aliases":[],"match":["wungening-ngalla-wirrin-wungening"],"category":"Aboriginal AOD","groups":["alcohol_other_drugs","aboriginal_torres_strait_islander","carers_family"],"tier":"B_common_referral","status":"active","statusNote":"Active","jurisdiction":"Western Australia","catchments":["Perth metro hubs"],"population":"Aboriginal people/families","ages":["mixed"],"bestUse":"Aboriginal-led culturally safe AOD counselling, groups and family support; self-referral/drop-in/agency referral options.","notFor":[],"routes":[{"route_type":"phone_contact","summary":"(08) 9221 1411; referral@wungening.com.au","self_referral":null,"required_documents":[]}],"contacts":[{"label":"Contact","value":"(08) 9221 1411; referral@wungening.com.au","kind":"email"}],"hours":{"display":"Contact service","timezone":"Australia/Perth","verification_status":"verified"},"website":"https://www.wungening.com.au/how-we-help/alcohol-other-drugs-services/","verification":"verified_current_core","verified":"2026-08-23","review":"2027-02-23","intents":[],"supersededBy":null,"issues":[],"sources":[{"id":"SRC-011","title":"Wungening - Ngalla Wirrin Wungening","issuer":"Wungening Aboriginal Corporation","class":"Tier 2 primary provider","url":"https://www.wungening.com.au/how-we-help/alcohol-other-drugs-services/","date":"Current page; specific update date retained where supplied by source","accessed":"2026-08-23","limitations":"Contacts, hours, vacancies, referral criteria and service availability remain volatile and require scheduled re-verification."}]}] as const;
diff --git a/src/lib/smart-search-intent.ts b/src/lib/smart-search-intent.ts
index b4859916b4..8709458480 100644
--- a/src/lib/smart-search-intent.ts
+++ b/src/lib/smart-search-intent.ts
@@ -1,44 +1,138 @@
import type { AppModeId } from "@/lib/app-modes";
-import { isClinicalAskModeId } from "@/lib/clinical-ask/contracts";
+import { normalizeSearchText } from "@/lib/catalog-search";
-export type SmartSearchSubmissionIntent = "search" | "clinical-ask";
+export const smartNaturalSearchModeIds = [
+ "services",
+ "forms",
+ "differentials",
+ "formulation",
+ "dsm",
+ "specifiers",
+ "therapy-compass",
+] as const satisfies readonly AppModeId[];
-const explicitLookupPattern = /^(?:find|search|look\s*up|lookup|show|open|browse)\b/i;
-const compactCodePattern = /^(?:form\s+)?[a-z]{0,4}\s*\d{1,3}(?:\.\d+)?[a-z]?$/i;
-const questionLeadPattern = /^(?:what|which|when|where|why|how|who|can|could|should|would|is|are|do|does|did)\b/i;
-const synthesisCuePattern =
- /\b(?:best|recommend(?:ed|ation)?|appropriate|consider|next steps?|options?|approach|distinguish|compare|formulat(?:e|ion)|fit|most likely)\b/i;
-const caseSubjectPattern =
- /\b(?:patient|person|client|consumer|presentation|symptoms?|features?|condition|episode|risk|impairment|course|response|they|them|their|he|him|his|she|her|this case)\b/i;
-const caseStateVerbPattern =
- /\b(?:is|are|was|were|has|have|had|presents?|presented|reports?|reported|experiences?|experienced|shows?|showed|remains?|remained|worsens?|worsened|improves?|improved|persists?|persisted|continues?|continued|meets?|met|denies?|denied|needs?|needed|requires?|required)\b/i;
+export type SmartNaturalSearchModeId = (typeof smartNaturalSearchModeIds)[number];
+
+export type SmartSearchInterpretation = {
+ modeId: AppModeId;
+ originalQuery: string;
+ naturalLanguage: boolean;
+ expansions: string[];
+};
+
+type ExpansionRule = { pattern: RegExp; terms: readonly string[] };
+
+const modeExpansionRules: Record = {
+ services: [
+ { pattern: /\b(?:young person|young people|teen(?:ager)?s?)\b/i, terms: ["youth", "child", "adolescent"] },
+ { pattern: /\b(?:after hours|out of hours|overnight)\b/i, terms: ["after hours", "24/7", "crisis"] },
+ {
+ pattern: /\b(?:after discharge|follow[ -]?up|ongoing support|community care)\b/i,
+ terms: ["community", "follow-up", "post-discharge"],
+ },
+ { pattern: /\b(?:urgent|immediate|in crisis|crisis support)\b/i, terms: ["crisis", "emergency", "urgent"] },
+ { pattern: /\b(?:older adult|older person|older people|elderly)\b/i, terms: ["older adult", "older people"] },
+ ],
+ forms: [
+ {
+ pattern: /\b(?:involuntary admission|detain|detention|compulsory admission)\b/i,
+ terms: ["involuntary", "admission", "detention", "assessment"],
+ },
+ { pattern: /\b(?:move|moving)\b/i, terms: ["transfer", "transport", "movement"] },
+ { pattern: /\b(?:extend(?:s|ed|ing)?|extension|continue detention)\b/i, terms: ["extension", "detention"] },
+ { pattern: /\b(?:revoke|revocation|cancel an order)\b/i, terms: ["revocation", "order"] },
+ ],
+ differentials: [
+ { pattern: /\b(?:hearing voices|hear voices|seeing things)\b/i, terms: ["hallucinations", "psychosis"] },
+ { pattern: /\b(?:memory loss|memory problems?|forgetful)\b/i, terms: ["cognitive", "dementia", "memory"] },
+ { pattern: /\b(?:low mood|feeling low)\b/i, terms: ["depression", "depressive"] },
+ { pattern: /\b(?:high mood|elevated mood|little need for sleep)\b/i, terms: ["mania", "hypomania"] },
+ { pattern: /\b(?:confused|confusion|disorientated|disoriented)\b/i, terms: ["delirium", "cognitive"] },
+ ],
+ formulation: [
+ { pattern: /\b(?:keep going over|going over it|cannot stop thinking)\b/i, terms: ["rumination"] },
+ { pattern: /\b(?:what if|constant worry|keeps worrying)\b/i, terms: ["worry"] },
+ { pattern: /\b(?:not perfect|must be perfect|a failure)\b/i, terms: ["perfectionism"] },
+ { pattern: /\b(?:not really there|disconnected|outside myself)\b/i, terms: ["dissociation"] },
+ { pattern: /\b(?:avoid|avoiding|stays away from)\b/i, terms: ["avoidance"] },
+ ],
+ dsm: [
+ { pattern: /\b(?:low mood|feeling low)\b/i, terms: ["depressive", "depression"] },
+ { pattern: /\b(?:high mood|elevated mood|little need for sleep)\b/i, terms: ["mania", "hypomania", "bipolar"] },
+ { pattern: /\b(?:hearing voices|hear voices|seeing things)\b/i, terms: ["psychosis", "schizophrenia"] },
+ { pattern: /\b(?:flashbacks?|after trauma|traumatic event)\b/i, terms: ["trauma", "ptsd"] },
+ { pattern: /\b(?:attention problems?|hyperactive|cannot concentrate)\b/i, terms: ["adhd", "attention"] },
+ ],
+ specifiers: [
+ { pattern: /\b(?:anxious|anxiety symptoms?)\b/i, terms: ["anxious distress"] },
+ { pattern: /\b(?:getting better|partly recovered|fully recovered)\b/i, terms: ["remission"] },
+ { pattern: /\b(?:psychotic|with psychosis)\b/i, terms: ["psychotic features"] },
+ { pattern: /\b(?:seasonal|time of year)\b/i, terms: ["seasonal pattern"] },
+ { pattern: /\b(?:after birth|postpartum|during pregnancy)\b/i, terms: ["peripartum onset"] },
+ ],
+ "therapy-compass": [
+ { pattern: /\b(?:after trauma|traumatic event|flashbacks?)\b/i, terms: ["trauma-focused", "ptsd"] },
+ { pattern: /\b(?:young person|young people|teen(?:ager)?s?)\b/i, terms: ["youth", "child", "adolescent"] },
+ { pattern: /\b(?:constant worry|worrying|anxiety symptoms?)\b/i, terms: ["anxiety", "worry"] },
+ { pattern: /\b(?:low mood|feeling low)\b/i, terms: ["depression", "behavioural activation"] },
+ { pattern: /\b(?:couple|relationship problems?)\b/i, terms: ["couples", "relationship"] },
+ { pattern: /\b(?:emotion regulation|intense emotions?)\b/i, terms: ["dbt", "dialectical behaviour therapy"] },
+ ],
+};
+
+const conversationalLeadPattern =
+ /^(?:(?:please\s+)?(?:show|find|search(?:\s+for)?|look\s+up|help\s+me\s+find)|what|which|where|when|how|can|could|would|is|are|do|does)\b/i;
+const compactCodePattern = /^(?:form\s+)?[a-z]{0,5}\s*\d{1,3}(?:\.\d+)?[a-z]?$/i;
+const embeddedIdentifierPattern = /\b(?=[a-z0-9.]*[a-z])(?=[a-z0-9.]*\d)[a-z0-9.]{3,}\b/i;
function lexicalTokens(value: string): string[] {
return value.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? [];
}
+export function isSmartNaturalSearchMode(modeId: AppModeId): modeId is SmartNaturalSearchModeId {
+ return smartNaturalSearchModeIds.includes(modeId as SmartNaturalSearchModeId);
+}
+
/**
- * Resolves what Enter means without provider access.
+ * Interprets a selected mode's query without a provider call.
*
- * Explicit lookups, compact catalogue codes, terse phrases, and unsupported
- * modes retain deterministic search. Supported modes use Clinical Ask only for
- * clear questions or sufficiently developed synthesis/case statements.
+ * The original query remains the URL and user-visible value. Expansions are
+ * low-weight catalogue vocabulary only: they broaden deterministic ranking and
+ * never generate an answer, infer a diagnosis, or leave the selected mode.
*/
-export function resolveSmartSearchSubmissionIntent(modeId: AppModeId, query: string): SmartSearchSubmissionIntent {
- const trimmed = query.trim();
- if (!trimmed || !isClinicalAskModeId(modeId)) return "search";
- if (explicitLookupPattern.test(trimmed)) return "search";
+export function interpretSmartSearch(modeId: AppModeId, query: string): SmartSearchInterpretation {
+ const originalQuery = query.trim();
+ if (!originalQuery || !isSmartNaturalSearchMode(modeId)) {
+ return { modeId, originalQuery, naturalLanguage: false, expansions: [] };
+ }
- const withoutTerminalPunctuation = trimmed.replace(/[?!.,;:]+$/u, "").trim();
- if (compactCodePattern.test(withoutTerminalPunctuation)) return "search";
+ const withoutTerminalPunctuation = originalQuery.replace(/[?!.,;:]+$/u, "").trim();
+ if (compactCodePattern.test(withoutTerminalPunctuation) || embeddedIdentifierPattern.test(originalQuery)) {
+ return { modeId, originalQuery, naturalLanguage: false, expansions: [] };
+ }
- if (questionLeadPattern.test(trimmed) || trimmed.endsWith("?")) return "clinical-ask";
+ const expansions = modeExpansionRules[modeId]
+ .filter((rule) => rule.pattern.test(originalQuery))
+ .flatMap((rule) => rule.terms)
+ .map(normalizeSearchText)
+ .filter(Boolean)
+ .flatMap((term) => [term, ...term.split(" ").filter((token) => token.length > 1)]);
+ const uniqueExpansions = Array.from(new Set(expansions)).slice(0, 16);
+ const tokenCount = lexicalTokens(originalQuery).length;
+ const naturalLanguage =
+ uniqueExpansions.length > 0 ||
+ originalQuery.endsWith("?") ||
+ conversationalLeadPattern.test(originalQuery) ||
+ tokenCount >= 4;
+
+ return { modeId, originalQuery, naturalLanguage, expansions: uniqueExpansions };
+}
- const developedStatement = lexicalTokens(trimmed).length >= 6;
- const explicitCaseState = caseSubjectPattern.test(trimmed) && caseStateVerbPattern.test(trimmed);
- return developedStatement && (synthesisCuePattern.test(trimmed) || explicitCaseState) ? "clinical-ask" : "search";
+export function smartSearchExpansions(modeId: AppModeId, query: string): string[] {
+ return interpretSmartSearch(modeId, query).expansions;
}
-export function smartSearchUsesClinicalAsk(modeId: AppModeId, query: string): boolean {
- return resolveSmartSearchSubmissionIntent(modeId, query) === "clinical-ask";
+export function expandedSmartSearchQuery(modeId: AppModeId, query: string): string {
+ const interpretation = interpretSmartSearch(modeId, query);
+ return [interpretation.originalQuery, ...interpretation.expansions].filter(Boolean).join(" ");
}
diff --git a/src/lib/specifiers-search-index.ts b/src/lib/specifiers-search-index.ts
index f75e66ad7f..4d6901c307 100644
--- a/src/lib/specifiers-search-index.ts
+++ b/src/lib/specifiers-search-index.ts
@@ -10,6 +10,7 @@
import searchIndex from "../../data/specifiers-search-index.json";
import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
+import { smartSearchExpansions } from "@/lib/smart-search-intent";
export type SpecifierSourceStatus = "source-verified" | "source-needs-formal-review" | "source-not-applicable";
export type SpecifierDefinitionStatus = "defined" | "obvious-no-definition" | "needs-manual-or-clinician-verification";
@@ -85,7 +86,11 @@ function applyFilters(items: SpecifierIndexItem[], filters: SpecifierCatalogFilt
* (src/lib/catalog-search.ts) so tokenization/weighting matches the other modes.
* With an empty query it returns the filtered catalog in stable (label) order.
*/
-export function searchSpecifierCatalog(query: string, filters: SpecifierCatalogFilters = {}): SpecifierCatalogMatch[] {
+export function searchSpecifierCatalog(
+ query: string,
+ filters: SpecifierCatalogFilters = {},
+ interpretNaturalLanguage = false,
+): SpecifierCatalogMatch[] {
const items = applyFilters(specifierIndexItems, filters);
const trimmed = query.trim();
@@ -112,5 +117,8 @@ export function searchSpecifierCatalog(query: string, filters: SpecifierCatalogF
prefixValues: (item) => [normalizeSearchText(item.label)],
prefixBonus: 3,
phraseBonus: 5,
+ expandTokens: interpretNaturalLanguage
+ ? (terms) => [...terms, ...smartSearchExpansions("specifiers", trimmed)]
+ : undefined,
}).map(({ record, score }) => ({ item: record, score }));
}
diff --git a/src/lib/specifiers.ts b/src/lib/specifiers.ts
index db35f2fcc9..3693840c19 100644
--- a/src/lib/specifiers.ts
+++ b/src/lib/specifiers.ts
@@ -682,10 +682,13 @@ function matchesDiagnosisFilter(record: SpecifierRecord, diagnosis: string) {
export function searchSpecifiers(
query: string,
- options: { family?: "all" | SpecifierFamily; diagnosis?: string } = {},
+ options: { family?: "all" | SpecifierFamily; diagnosis?: string; expansions?: readonly string[] } = {},
) {
const normalizedQuery = normalizeSearchText(query);
const tokens = searchTokens(query);
+ const expansionTokens = Array.from(
+ new Set((options.expansions ?? []).flatMap((expansion) => searchTokens(expansion))),
+ );
const diagnosis = normalizeSearchText(options.diagnosis ?? "");
return specifierRecords
@@ -704,6 +707,11 @@ export function searchSpecifiers(
if (keywords.includes(token)) score += 10;
if (haystack.includes(token)) score += 3;
}
+ for (const token of expansionTokens) {
+ if (title.includes(token)) score += 6;
+ if (keywords.includes(token)) score += 4;
+ if (haystack.includes(token)) score += 1;
+ }
return { record, score };
})
diff --git a/src/lib/therapies.ts b/src/lib/therapies.ts
index 3ed946fc89..5211d01ef3 100644
--- a/src/lib/therapies.ts
+++ b/src/lib/therapies.ts
@@ -74,6 +74,6 @@ export type TherapySearchMatch = { record: TherapyIndexRecord; score: number };
* returns the alphabetical library (stable order) so the universal-search
* domain can still surface a browse list.
*/
-export function searchTherapyRecords(query: string): TherapySearchMatch[] {
- return rankTherapyCandidates(therapyRecords, query);
+export function searchTherapyRecords(query: string, expansions: readonly string[] = []): TherapySearchMatch[] {
+ return rankTherapyCandidates(therapyRecords, query, expansions);
}
diff --git a/src/lib/therapy-ranking.ts b/src/lib/therapy-ranking.ts
index 93ac8a6c94..42b6fa796c 100644
--- a/src/lib/therapy-ranking.ts
+++ b/src/lib/therapy-ranking.ts
@@ -71,12 +71,18 @@ export function scoreTherapyCandidate(record: TherapyRankable, query: string): n
export function rankTherapyCandidates(
records: readonly T[],
query: string,
+ expansions: readonly string[] = [],
): Array<{ record: T; score: number }> {
return records
- .map((record) => ({
- record,
- score: scoreTherapyCandidate(record, query),
- }))
+ .map((record) => {
+ const primaryScore = scoreTherapyCandidate(record, query);
+ const expansionScore = Math.max(0, ...expansions.map((expansion) => scoreTherapyCandidate(record, expansion)));
+ return {
+ record,
+ // Expanded terms broaden recall without outranking a direct query match.
+ score: primaryScore + expansionScore * 0.25,
+ };
+ })
.filter((match) => match.score > 0)
.sort((left, right) => right.score - left.score || left.record.name.localeCompare(right.record.name));
}
diff --git a/src/lib/tools-catalog.ts b/src/lib/tools-catalog.ts
index c074627562..9d30c68750 100644
--- a/src/lib/tools-catalog.ts
+++ b/src/lib/tools-catalog.ts
@@ -1,4 +1,4 @@
-import { canAccessFavouritesMode } from "@/lib/app-modes";
+import { appModeHomeHref, canAccessFavouritesMode } from "@/lib/app-modes";
import { CARING_CONTACTS_ROUTES } from "@/lib/caring-contacts-routes";
import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search";
@@ -83,7 +83,11 @@ export const toolCatalogRecords: ToolCatalogRecord[] = [
description: "Build and compare diagnostic possibilities with source-aware prompts.",
bestFor: "Broad or complex presentations",
detail: "Compare diagnostic possibilities, supporting features, red flags, and next-step questions.",
- href: "/differentials",
+ // Tools is in-app navigation, so go straight to the canonical shared home.
+ // The bare namespace is only a compatibility redirect for old bookmarks;
+ // sending a client transition through it can briefly retain the Tools route
+ // shell and its phone geometry before the second navigation settles.
+ href: appModeHomeHref("differentials"),
area: "assessment",
status: "recent",
sourceBacked: true,
@@ -120,7 +124,7 @@ export const toolCatalogRecords: ToolCatalogRecord[] = [
bestFor: "Terminology and abbreviation lookup",
detail:
"Open concise definitions, resolve ambiguous abbreviations, compare terms, and review their direct sources.",
- href: "/dictionary",
+ href: appModeHomeHref("dictionary"),
area: "reference",
status: "ready",
sourceBacked: true,
@@ -200,7 +204,7 @@ export const toolCatalogRecords: ToolCatalogRecord[] = [
description: "Open source-backed service records, referral routes, and eligibility.",
bestFor: "Referrals and coordination",
detail: "Open service records with referral routes, eligibility, source status, and access pathways.",
- href: "/services",
+ href: appModeHomeHref("services"),
area: "coordination",
status: "ready",
sourceBacked: true,
@@ -217,7 +221,7 @@ export const toolCatalogRecords: ToolCatalogRecord[] = [
description: "Find clinical forms and source-backed readiness pathways.",
bestFor: "Forms and workflows",
detail: "Open form search, readiness checks, pathway tasks, and source-backed records.",
- href: "/forms",
+ href: appModeHomeHref("forms"),
area: "coordination",
status: "ready",
sourceBacked: true,
@@ -287,7 +291,7 @@ export const toolCatalogRecords: ToolCatalogRecord[] = [
bestFor: "Bedside scoring and severity banding",
detail:
"Search and complete clinical calculators (PHQ-9, GAD-7, CSSRS, and related scales). Scores support clinical judgement and cite their source — they never replace a full assessment.",
- href: "/calculators",
+ href: appModeHomeHref("calculators"),
area: "assessment",
status: "ready",
sourceBacked: true,
diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts
index 67d66e7296..5c6168512f 100644
--- a/src/lib/ui-copy.ts
+++ b/src/lib/ui-copy.ts
@@ -92,7 +92,8 @@ export const sharedHomePresentation = {
},
calculators: {
title: "Clinical Calculators",
- subtitle: "Validated psychiatry scores with the indication, items, and next actions in one place.",
+ subtitle:
+ "Psychiatry assessment and monitoring tools with scoring guidance, limitations, safety prompts, and source-linked clinical considerations.",
suggestions: ["depression severity", "anxiety screening", "alcohol use"],
},
"therapy-compass": {
diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts
index 1f5159da29..6d76f8130f 100644
--- a/src/lib/universal-search.ts
+++ b/src/lib/universal-search.ts
@@ -22,6 +22,7 @@ import { fetchOwnerRegistryRows, mergeRegistryRecordsWithDefaults } from "@/lib/
import { rankServiceRecords, serviceRecords, type ServiceRecord } from "@/lib/services";
import { searchFormulationMechanisms } from "@/lib/formulation";
import { searchSpecifiers as searchPsychiatricSpecifiers } from "@/lib/specifiers";
+import { smartSearchExpansions } from "@/lib/smart-search-intent";
import { searchTherapyRecords, therapyNeedsReview } from "@/lib/therapies";
import { rankToolRecords } from "@/lib/tools-catalog";
import type { ClinicalQueryAnalysis, SearchResult } from "@/lib/types";
@@ -440,7 +441,7 @@ async function searchDictionaryDomain(args: ResolvedSearchArgs): Promise {
- return searchFormulationMechanisms(args.baseQuery)
+ return searchFormulationMechanisms(args.baseQuery, { expansions: args.expansions })
.slice(0, args.limitPerDomain)
.map(({ mechanism, score }) => ({
id: mechanism.id,
@@ -455,7 +456,7 @@ async function searchFormulationDomain(args: ResolvedSearchArgs): Promise {
- return searchPsychiatricSpecifiers(args.baseQuery)
+ return searchPsychiatricSpecifiers(args.baseQuery, { expansions: args.expansions })
.slice(0, args.limitPerDomain)
.map(({ record, score }) => ({
id: record.slug,
@@ -470,7 +471,7 @@ async function searchSpecifiersDomain(args: ResolvedSearchArgs): Promise {
- return searchTherapyRecords(args.baseQuery)
+ return searchTherapyRecords(args.baseQuery, args.expansions)
.slice(0, args.limitPerDomain)
.map(({ record, score }) => ({
id: record.slug,
@@ -694,7 +695,15 @@ export async function runUniversalSearch(args: RunUniversalSearchArgs): Promise<
// expanded lane. The documents adapter still receives the ORIGINAL query (it self-analyses).
const analysis = analyzeClinicalQuery(args.query);
const baseQuery = applyTypoCorrections(args.query, analysis.typoCorrections);
- const expansions = deriveExpansions(analysis, baseQuery);
+ // Keep the command surface aligned with the selected mode's local search: both
+ // routes score its deterministic vocabulary in the same low-weight expansion
+ // lane. The original query remains untouched for document retrieval.
+ const expansions = Array.from(
+ new Set([
+ ...(args.contextMode ? smartSearchExpansions(args.contextMode, args.query) : []),
+ ...deriveExpansions(analysis, baseQuery),
+ ]),
+ ).slice(0, maxExpansions);
const resolved: ResolvedSearchArgs = { ...args, baseQuery, expansions };
const groups = await Promise.all(
diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json
index 91a3dd86bf..cd0c9649ba 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-09-01T12:38:48.655Z",
"generator": "scripts/generate-drift-manifest.ts",
"postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83",
- "schema_sha256": "4048b5d3ea15289349469e5f3c5b1f8c4328b94116ae09b812fe135005bce0b5",
- "replay_seconds": 11,
+ "schema_sha256": "1d0bc22c4e2fd9f2f45faa372c8dd958d55e785d17438920d436a295a9501875",
+ "replay_seconds": 18,
"snapshot": {
"views": [
{
@@ -7321,7 +7321,7 @@
"postgres=X/postgres",
"service_role=X/postgres"
],
- "def_hash": "cb65883a561cb1f5cd2247213f417a41",
+ "def_hash": "2ebaf978b69f3de0c47d9d0924419c74",
"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()"
},
{
@@ -8425,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"
},
@@ -8550,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"
},
@@ -8565,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"
},
@@ -8615,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/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 0000000000..f5e0651e49
--- /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/migrations/20260901033250_enable_staging_privacy_retention_schedules.sql b/supabase/migrations/20260901033250_enable_staging_privacy_retention_schedules.sql
new file mode 100644
index 0000000000..43e43bc037
--- /dev/null
+++ b/supabase/migrations/20260901033250_enable_staging_privacy_retention_schedules.sql
@@ -0,0 +1,67 @@
+-- Enable and reconcile privacy-retention schedules in environments where
+-- the historical retention migrations ran before pg_cron was available.
+--
+-- Rollback: unschedule only the four named jobs. Drop pg_cron only when
+-- cron.job contains no unrelated jobs.
+
+create extension if not exists pg_cron with schema pg_catalog;
+
+grant usage on schema cron to postgres;
+grant all privileges on all tables in schema cron to postgres;
+
+do $privacy_retention$
+declare
+ job record;
+begin
+ if to_regprocedure('public.purge_expired_rag_queries(integer)') is null then
+ raise exception 'Missing public.purge_expired_rag_queries(integer)';
+ end if;
+ if to_regprocedure('public.purge_expired_rag_query_misses(integer)') is null then
+ raise exception 'Missing public.purge_expired_rag_query_misses(integer)';
+ end if;
+ if to_regprocedure('public.purge_expired_rag_response_cache(integer)') is null then
+ raise exception 'Missing public.purge_expired_rag_response_cache(integer)';
+ end if;
+ if to_regclass('public.rag_retrieval_logs') is null then
+ raise exception 'Missing public.rag_retrieval_logs';
+ end if;
+
+ for job in
+ select jobid
+ from cron.job
+ where jobname in (
+ 'purge-expired-rag-queries',
+ 'purge-rag-retrieval-logs',
+ 'purge-rag-query-misses',
+ 'purge-rag-response-cache',
+ 'purge-expired-rag-response-cache'
+ )
+ loop
+ perform cron.unschedule(job.jobid);
+ end loop;
+
+ perform cron.schedule(
+ 'purge-expired-rag-queries',
+ '30 3 * * *',
+ $job$select public.purge_expired_rag_queries(30);$job$
+ );
+
+ perform cron.schedule(
+ 'purge-rag-retrieval-logs',
+ '0 3 * * *',
+ $job$delete from public.rag_retrieval_logs where created_at < now() - interval '90 days';$job$
+ );
+
+ perform cron.schedule(
+ 'purge-rag-query-misses',
+ '45 3 * * *',
+ $job$select public.purge_expired_rag_query_misses(90);$job$
+ );
+
+ perform cron.schedule(
+ 'purge-rag-response-cache',
+ '15 * * * *',
+ $job$select public.purge_expired_rag_response_cache(1000);$job$
+ );
+end
+$privacy_retention$;
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 0000000000..f0e858bedb
--- /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 aef7a1b059..3241f44211 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,
@@ -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),
@@ -5272,6 +5256,7 @@ begin
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
diff --git a/tests/answer-follow-up.test.ts b/tests/answer-follow-up.test.ts
index cd7a3bd671..0f2b1fcb0b 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/answer-responsiveness-gate.test.ts b/tests/answer-responsiveness-gate.test.ts
index af4b40b718..36cefcace1 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/answer-source-marks.dom.test.tsx b/tests/answer-source-marks.dom.test.tsx
index 6d0755dcc1..0e5e45c7db 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 d8c019065a..b2f367c0a5 100644
--- a/tests/answer-source-rail.dom.test.tsx
+++ b/tests/answer-source-rail.dom.test.tsx
@@ -1,20 +1,10 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-const coverAuth = vi.hoisted(() => ({
- authorizationHeader: { Authorization: "Bearer cover-test" },
- session: { user: { id: "cover-test-user" } },
-}));
-
-vi.mock("@/lib/supabase/client", () => ({
- // The real provider memoizes this value until credentials change. Keeping
- // the mock stable prevents an ordinary state update from masquerading as a
- // token refresh and starting a new cover lookup in the same open drawer.
- useAuthSession: () => coverAuth,
-}));
+import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/clinical-dashboard/signed-image", () => ({
SignedImage: ({
@@ -48,7 +38,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 {
@@ -57,7 +47,6 @@ import {
sourceCapsuleDisplay,
sourceSupportSentence,
} from "@/components/clinical-dashboard/answer-source-rows";
-import { resetDocumentCoverCacheForTests } from "@/components/clinical-dashboard/use-document-cover";
import { normalizeSourceMetadata } from "@/lib/source-metadata";
import type { VisualEvidenceCard } from "@/lib/types";
@@ -268,6 +257,21 @@ describe("answer source drawer", () => {
).toBeInTheDocument();
});
+ it("says so when a source carries no captured passage", async () => {
+ // A retrieved-but-uncited row has neither a quote card nor a snippet. With
+ // the cover and the no-claim sentence gone, the panel would otherwise be a
+ // lone "View original PDF" button, which reads as a failure to load.
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
+ const drawer = screen.getByTestId("answer-source-drawer");
+ expect(within(drawer).getByTestId("answer-source-drawer-no-passage")).toHaveTextContent(
+ "No passage was captured for this source.",
+ );
+ expect(within(drawer).getByRole("link", { name: "View original PDF" })).toBeInTheDocument();
+ });
+
it("degrades the pager to prev / n of m above four sources", async () => {
const user = userEvent.setup();
const many = Array.from({ length: 5 }, (_, index) => row({ id: `m${index}`, title: `Source number ${index + 1}` }));
@@ -317,9 +321,11 @@ describe("answer source drawer", () => {
render();
await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
- expect(screen.getByTestId("answer-source-drawer-support")).toHaveTextContent(
- "Opened from the source list, so this is the document, not a claim.",
- );
+ // No claim opened this drawer, so there is no support sentence at all — not
+ // a line whose whole content is that there is no claim. The passage the
+ // clinician came to read is what the panel opens on.
+ expect(screen.queryByTestId("answer-source-drawer-support")).not.toBeInTheDocument();
+ expect(screen.getByTestId("answer-source-drawer-passage")).toBeInTheDocument();
});
it("warns on a source that is past its review date", async () => {
@@ -394,28 +400,47 @@ 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 () => {
- const user = userEvent.setup();
- render(
- ,
+ // The warnings are a statement about the answer's evidence, never about one
+ // cited page, so they must not be rendered inside the rail or a source row.
+ // They moved out of the utility row and into the header status chips on
+ // 2026-08-31 (the approved specimen draws Copy plus two verdicts and nothing
+ // else in the action row); this pins the new home and the fact that the rail
+ // still does not own them.
+ it("renders the answer's warnings once, from the answer surface, and never inside the source rail", () => {
+ const surface = readFileSync(
+ resolve(process.cwd(), "src/components/clinical-dashboard/answer-result-surface.tsx"),
+ "utf8",
+ );
+ expect(surface).toContain('data-testid="answer-evidence-gaps-trigger"');
+ expect(surface).toContain('id="answer-evidence-gaps-detail"');
+ expect(surface).toContain("renderModel.warnings");
+
+ const rail = readFileSync(
+ resolve(process.cwd(), "src/components/clinical-dashboard/answer-source-rail.tsx"),
+ "utf8",
);
+ expect(rail).not.toContain("evidence-gaps");
+ expect(rail).not.toContain("warnings");
+ });
- await user.click(screen.getByTestId("answer-evidence-gaps-trigger"));
- expect(screen.getByText("Retrieval confidence gate was blocked for low signal.")).toBeInTheDocument();
+ it("keeps the utility row to copy and the two verdicts", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Copy answer with source status" })).toBeInTheDocument();
+ expect(screen.getByTestId("answer-feedback-useful")).toBeInTheDocument();
+ expect(screen.getByTestId("answer-feedback-trigger")).toBeInTheDocument();
+ expect(screen.queryByTestId("answer-evidence-gaps-trigger")).not.toBeInTheDocument();
});
});
describe("support sentence", () => {
it("speaks about the claim only when a claim pointed at the source", () => {
- expect(sourceSupportSentence(SOURCES[0], null)).toContain("not a claim");
+ // No claim, no sentence: the drawer says nothing about support rather than
+ // spending a line to report the absence of a claim.
+ expect(sourceSupportSentence(SOURCES[0], null)).toBeNull();
+ expect(sourceSupportSentence(null, 0)).toBeNull();
expect(sourceSupportSentence(SOURCES[0], 0)).toBe("This page states the claim directly.");
expect(sourceSupportSentence(SOURCES[1], 1)).toContain("supports part of the claim");
expect(sourceSupportSentence(row({ id: "x", title: "Unrelated" }), 2)).toContain("does not state the claim");
- expect(sourceSupportSentence(null, 0)).toContain("not a claim");
});
it("uses the claim's support, not the document's, when a claim opened the drawer", () => {
@@ -547,126 +572,3 @@ describe("source drawer overflow menu", () => {
expect(report).toHaveTextContent("Confirm: report this page");
});
});
-
-describe("answer source drawer cover", () => {
- /**
- * The cover is the one thing on this surface that can misrepresent the
- * evidence by being merely decorative. The index stores ONE first-page
- * thumbnail per document and no per-page renders, so a picture shown beside
- * "p. 12" with no caption reads as page 12. The caption is the guarantee, and
- * that is what these tests pin — not the picture.
- */
- const fetchMock = vi.fn();
-
- beforeEach(() => {
- resetDocumentCoverCacheForTests();
- fetchMock.mockReset();
- vi.stubGlobal("fetch", fetchMock);
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- async function openFirstSource(user: ReturnType) {
- render();
- await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
- }
-
- it("names the front page and where the passage actually sits", async () => {
- fetchMock.mockResolvedValue({ ok: true, json: async () => ({ coverImageId: "cover-1" }) });
- const user = userEvent.setup();
- await openFirstSource(user);
-
- const cover = await screen.findByTestId("answer-source-drawer-cover");
- expect(cover).toHaveTextContent("Front page");
- // The cited page, stated, so the thumbnail cannot be read as that page.
- expect(cover).toHaveTextContent("passage on p. 4");
- expect(within(cover).getByText(/Front page of/i)).toBeInTheDocument();
- });
-
- it("renders no cover when the document has none, and asks only once", async () => {
- fetchMock.mockResolvedValue({ ok: true, json: async () => ({ coverImageId: null }) });
- const user = userEvent.setup();
- await openFirstSource(user);
-
- await screen.findByTestId("answer-source-drawer-support");
- expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
-
- // A text-only upload is the common case; re-asking on every open would spend
- // a document-read rate-limit token to learn the same null.
- const calls = fetchMock.mock.calls.length;
- await user.keyboard("{Escape}");
- await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
- await screen.findByTestId("answer-source-drawer-support");
- expect(fetchMock.mock.calls.length).toBe(calls);
- });
-
- it("retries after a transient failure instead of caching it as a no-cover answer", async () => {
- // The defect this pins: a 429/5xx/offline blip used to be cached exactly
- // like an authoritative `null`, so every later open skipped the request and
- // the thumbnail could not return without a full page reload.
- fetchMock
- .mockRejectedValueOnce(new Error("offline"))
- .mockResolvedValue({ ok: true, json: async () => ({ coverImageId: "cover-1" }) });
- const user = userEvent.setup();
- await openFirstSource(user);
-
- await screen.findByTestId("answer-source-drawer-support");
- expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
-
- await user.keyboard("{Escape}");
- await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
- expect(await screen.findByTestId("answer-source-drawer-cover")).toHaveTextContent("Front page");
- });
-
- it("treats a 404 as an answer and does not keep asking", async () => {
- // 404 means the document is gone or not ours to read. That is a real answer,
- // so it caches — unlike the transient failures above.
- fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
- const user = userEvent.setup();
- await openFirstSource(user);
-
- await screen.findByTestId("answer-source-drawer-support");
- const calls = fetchMock.mock.calls.length;
- await user.keyboard("{Escape}");
- await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
- await screen.findByTestId("answer-source-drawer-support");
- expect(fetchMock.mock.calls.length).toBe(calls);
- expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
- });
-
- it("degrades to no cover when the lookup fails", async () => {
- fetchMock.mockRejectedValue(new Error("offline"));
- const user = userEvent.setup();
- await openFirstSource(user);
-
- await screen.findByTestId("answer-source-drawer-support");
- expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
- // The citation itself is untouched: a missing decoration must never take the
- // passage down with it.
- expect(screen.getByTestId("answer-source-drawer-passage")).toBeInTheDocument();
- });
-
- it("removes only a failed optional cover while preserving source evidence and actions", async () => {
- fetchMock.mockResolvedValue({ ok: true, json: async () => ({ coverImageId: "cover-1" }) });
- const user = userEvent.setup();
- render(
- ,
- );
- await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
-
- const cover = await screen.findByTestId("answer-source-drawer-cover");
- await user.click(within(cover).getByTestId("settle-hidden-signed-image"));
-
- expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
- expect(screen.getByTestId("answer-source-drawer-passage")).toBeInTheDocument();
- expect(screen.getByTestId("answer-source-drawer-pager")).toBeInTheDocument();
- expect(screen.getByTestId("answer-source-drawer-menu-trigger")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "View original PDF" })).toBeInTheDocument();
- expect(screen.getByText("Ordinary evidence")).toBeInTheDocument();
- });
-});
diff --git a/tests/answer-support-priority.dom.test.tsx b/tests/answer-support-priority.dom.test.tsx
index 8c2b1c8595..da3bad0f35 100644
--- a/tests/answer-support-priority.dom.test.tsx
+++ b/tests/answer-support-priority.dom.test.tsx
@@ -1,10 +1,11 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
-import { render, screen } from "@testing-library/react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
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 +135,49 @@ 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("reaches Report a problem through the thumb down, beside Copy with sources", async () => {
+ const user = userEvent.setup();
render(
- undefined}
pendingFeedback={null}
onSubmitFeedback={() => undefined}
/>,
);
- expect(screen.getByTestId("answer-feedback-trigger")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Copy answer with source status" })).toBeInTheDocument();
+
+ const report = screen.getByTestId("answer-feedback-trigger");
+ expect(report).toHaveAccessibleName("Report a problem with this answer");
+ expect(report).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByTestId("answer-review-panel")).not.toBeInTheDocument();
+
+ await user.click(report);
+ const panel = screen.getByTestId("answer-review-panel");
+ expect(panel).toHaveAttribute("data-tone", "problems");
+ // The affirmative option is the thumb up, not an entry in a list opened to
+ // report a fault — offering it here is a mis-click that records the
+ // opposite of what the reader meant.
+ expect(within(panel).queryByRole("button", { name: /Verified/ })).not.toBeInTheDocument();
+ expect(within(panel).getByRole("button", { name: /Wrong source/ })).toBeInTheDocument();
});
- it("the answer surface mounts that card for feedback even without priority or warnings", () => {
+ it("routes the safety sheet from the header chip now that the support card is gone", () => {
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("");
});
});
diff --git a/tests/brand-mark-optics.test.ts b/tests/brand-mark-optics.test.ts
new file mode 100644
index 0000000000..4ecf8cb2d3
--- /dev/null
+++ b/tests/brand-mark-optics.test.ts
@@ -0,0 +1,192 @@
+import { execFileSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ BRAND_GLYPH_TRANSFORM,
+ BRAND_GLYPH_TRANSFORM_BARE,
+ BRAND_GLYPH_TRANSFORM_BARE_SMALL,
+ BRAND_GLYPH_TRANSFORM_SMALL,
+ BRAND_POINT,
+ BRAND_POINT_SMALL,
+ BRAND_STROKE_PATH,
+ BRAND_STROKE_PATH_SMALL,
+ brandMarkOptics,
+} from "@/lib/brand-mark";
+
+/**
+ * The mark is a construction, not a bitmap, and it does not simply get smaller.
+ * Two pieces of its negative space — the 4.2-unit cut between the strokes and
+ * the 7.26-unit crescent around the point — close up at 32px and below, which
+ * fuses the dot into the S and reads as a heavy blob above an already-thinner
+ * lower stroke. That is the top-heavy look; `docs/brand/psychsift-logo.md`
+ * §"Small sizes" specifies a separate optical cut for exactly that range.
+ *
+ * These tests guard the two ways that can go wrong: the placement arithmetic,
+ * and a call site drawing the wrong cut for the size it renders at.
+ */
+
+/** Ink bounding boxes, from docs/brand/psychsift-logo.md. */
+const INK = { displayWidth: 55.33, chromeWidth: 65.33, height: 100.38 };
+const VIEWBOX = 512;
+/** Two-decimal box figures, so the centre lands within a rounding of 256. */
+const CENTRING_TOLERANCE = 0.01;
+
+function parseTransform(transform: string) {
+ const match = /^translate\((-?[\d.]+) (-?[\d.]+)\) scale\(([\d.]+)\)$/.exec(transform);
+ expect(match, `unparseable transform: ${transform}`).not.toBeNull();
+ const [, x, y, scale] = match as RegExpExecArray;
+ return { x: Number(x), y: Number(y), scale: Number(scale) };
+}
+
+describe("the glyph placements all centre their own ink box", () => {
+ it.each([
+ ["BRAND_GLYPH_TRANSFORM (tiled, display)", BRAND_GLYPH_TRANSFORM, INK.displayWidth],
+ ["BRAND_GLYPH_TRANSFORM_SMALL (tiled, chrome)", BRAND_GLYPH_TRANSFORM_SMALL, INK.chromeWidth],
+ ["BRAND_GLYPH_TRANSFORM_BARE (bare, display)", BRAND_GLYPH_TRANSFORM_BARE, INK.displayWidth],
+ ["BRAND_GLYPH_TRANSFORM_BARE_SMALL (bare, chrome)", BRAND_GLYPH_TRANSFORM_BARE_SMALL, INK.chromeWidth],
+ ])("%s lands its centre on 256", (_name, transform, inkWidth) => {
+ const { x, scale } = parseTransform(transform);
+ expect(x + (inkWidth / 2) * scale).toBeCloseTo(VIEWBOX / 2, 1);
+ expect(Math.abs(x + (inkWidth / 2) * scale - VIEWBOX / 2)).toBeLessThan(CENTRING_TOLERANCE * VIEWBOX);
+ });
+});
+
+describe("the bare pair is one construction at two cuts", () => {
+ const display = parseTransform(BRAND_GLYPH_TRANSFORM_BARE);
+ const chrome = parseTransform(BRAND_GLYPH_TRANSFORM_BARE_SMALL);
+
+ it("shares a scale, because only the point's x moves between variants", () => {
+ // BRAND_POINT_SMALL keeps cy and r, so the vertical extent is identical and
+ // the bare scale — which exists to fill the box top to bottom — cannot change.
+ expect(BRAND_POINT_SMALL.cy).toBe(BRAND_POINT.cy);
+ expect(BRAND_POINT_SMALL.r).toBe(BRAND_POINT.r);
+ expect(chrome.scale).toBe(display.scale);
+ expect(chrome.y).toBe(display.y);
+ });
+
+ it("fills the box top to bottom at that scale", () => {
+ expect(INK.height * display.scale).toBeCloseTo(VIEWBOX, 0);
+ });
+
+ it("shifts left by exactly half the point's travel", () => {
+ // The point moves 10 units out and is the box's right edge, so the box
+ // widens by 10 and its centre by 5.
+ const travel = BRAND_POINT_SMALL.cx - BRAND_POINT.cx;
+ expect(travel).toBe(10);
+ expect(INK.chromeWidth - INK.displayWidth).toBeCloseTo(travel, 1);
+ expect(display.x - chrome.x).toBeCloseTo((travel / 2) * display.scale, 2);
+ });
+
+ it("uses the rule the committed tiled pair already used", () => {
+ // The check that this is the rule actually in use, not one that merely fits.
+ const tiledDisplay = parseTransform(BRAND_GLYPH_TRANSFORM);
+ const tiledChrome = parseTransform(BRAND_GLYPH_TRANSFORM_SMALL);
+ expect(tiledDisplay.x - tiledChrome.x).toBeCloseTo(5 * tiledDisplay.scale, 2);
+ });
+});
+
+describe("the variants are selected as whole sets", () => {
+ // Mixing one variant's point with the other's placement puts the glyph
+ // off-centre; the doc calls that out by name. One selector makes it unreachable.
+ it("pairs each stroke with its own point and placement", () => {
+ expect(brandMarkOptics("display")).toEqual({
+ transform: BRAND_GLYPH_TRANSFORM_BARE,
+ stroke: BRAND_STROKE_PATH,
+ point: BRAND_POINT,
+ });
+ expect(brandMarkOptics("chrome")).toEqual({
+ transform: BRAND_GLYPH_TRANSFORM_BARE_SMALL,
+ stroke: BRAND_STROKE_PATH_SMALL,
+ point: BRAND_POINT_SMALL,
+ });
+ });
+
+ it("keeps the cuts genuinely different", () => {
+ expect(BRAND_STROKE_PATH_SMALL).not.toBe(BRAND_STROKE_PATH);
+ expect(BRAND_POINT_SMALL.cx).toBeGreaterThan(BRAND_POINT.cx);
+ });
+});
+
+/** The brand doc's threshold: "at 32 px and below two things close up". */
+const CHROME_CUT_MAX_PX = 32;
+const REM_PX = 16;
+
+/**
+ * Tailwind `h-N` is N/4 rem; a CSS-module class is resolved to its own rule.
+ *
+ * The module lookup must go through the *specific* import identifier, not every
+ * `.module.css` the file imports. `ward-management-navigation.tsx` imports two
+ * modules and BOTH define `.brandGlyph`, at 2.5rem and 2rem — an identifier-blind
+ * resolver merges them and reports every site there as spanning 32-40px, which
+ * is a defect in this helper that reads exactly like a defect in the source.
+ */
+function renderedHeightsPx(className: string, sourcePath: string): number[] {
+ const cssModule = /\{\s*(\w+)\.(\w+)\s*\}/.exec(className);
+ if (!cssModule) {
+ return [...className.matchAll(/(?:^|\s|:)h-(\d+)\b/g)].map(([, n]) => (Number(n) / 4) * REM_PX);
+ }
+ const [, identifier, cssClass] = cssModule;
+ const source = readFileSync(sourcePath, "utf8");
+ const importPath = new RegExp(String.raw`import\s+${identifier}\s+from\s+"([^"]*\.module\.css)"`).exec(source)?.[1];
+ if (!importPath) return [];
+ const css = readFileSync(path.resolve(path.dirname(sourcePath), importPath), "utf8");
+ const rule = new RegExp(String.raw`\.${cssClass}\s*\{[^}]*?height:\s*([\d.]+)rem`, "s").exec(css);
+ return rule ? [Number(rule[1]) * REM_PX] : [];
+}
+
+describe("every production call site draws the cut its size needs", () => {
+ const files = execFileSync("git", ["ls-files", "src"], { encoding: "utf8" })
+ .split("\n")
+ .filter((file) => file.endsWith(".tsx") && !/mockups?\b/.test(file));
+
+ const callSites = files.flatMap((file) => {
+ const source = readFileSync(file, "utf8");
+ return [...source.matchAll(/]*?)\/>/gs)].map((match) => ({
+ file,
+ attributes: match[1],
+ optical: /optical="chrome"/.test(match[1]) ? "chrome" : "display",
+ heights: renderedHeightsPx(
+ /className=(?:"([^"]*)"|(\{[^}]*\}))/.exec(match[1])?.slice(1).find(Boolean) ?? "",
+ file,
+ ),
+ }));
+ });
+
+ it("finds the production call sites at all", () => {
+ // Guards the scan itself: a regex that silently matches nothing would make
+ // every assertion below pass vacuously.
+ expect(callSites.length).toBeGreaterThanOrEqual(10);
+ expect(callSites.every((site) => site.heights.length > 0)).toBe(true);
+ });
+
+ it.each([
+ ["never straddles the threshold, which would need a human decision", "straddle"],
+ ["draws the chrome cut at 32px and below", "chrome"],
+ ["draws the display cut above 32px", "display"],
+ ])("%s", (_name, mode) => {
+ for (const site of callSites) {
+ const smallest = Math.min(...site.heights);
+ const largest = Math.max(...site.heights);
+ if (mode === "straddle") {
+ expect(
+ smallest > CHROME_CUT_MAX_PX || largest <= CHROME_CUT_MAX_PX,
+ `${site.file} renders ${smallest}-${largest}px, spanning the ${CHROME_CUT_MAX_PX}px cut ` +
+ "threshold. One element draws one cut, so pick deliberately rather than by rounding.",
+ ).toBe(true);
+ continue;
+ }
+ if (mode === "chrome" && largest <= CHROME_CUT_MAX_PX) {
+ expect(site.optical, `${site.file} renders at ${largest}px and needs optical="chrome"`).toBe("chrome");
+ }
+ if (mode === "display" && smallest > CHROME_CUT_MAX_PX) {
+ expect(
+ site.optical,
+ `${site.file} renders at ${smallest}px, above the small-cut range; the widened gap is not for it`,
+ ).toBe("display");
+ }
+ }
+ });
+});
diff --git a/tests/bundle-budget.test.ts b/tests/bundle-budget.test.ts
index d5bc28513e..767ef9dc81 100644
--- a/tests/bundle-budget.test.ts
+++ b/tests/bundle-budget.test.ts
@@ -13,9 +13,10 @@ import {
findFixtureSnapshotsInChunks,
gzipBytesOf,
initialDashboardChunkNames,
+ measureBudgetRoutes,
measureChunkPaths,
measureChunks,
- measureBudgetRoutes,
+ measureServerHtmlPayloads,
MOCKUP_ROUTE_SEGMENT,
normalizeManifestRoute,
parseMaxDistance,
@@ -907,3 +908,76 @@ describe("production vs mockup chunk attribution", () => {
expect(result.unparseable.length).toBeGreaterThan(0);
});
});
+
+describe("measureServerHtmlPayloads", () => {
+ it("returns missing status when server page file is absent", () => {
+ const results = measureServerHtmlPayloads("app", undefined, {
+ existsSync: () => false,
+ readFileSync: () => Buffer.alloc(0),
+ });
+ expect(results["/mockups/development/review-state"]).toMatchObject({
+ found: false,
+ status: "missing",
+ });
+ });
+
+ it("measures server page HTML within ceiling as ok", () => {
+ const results = measureServerHtmlPayloads(
+ "app",
+ {
+ "/mockups/development/review-state": {
+ rawBytesCeiling: 1000,
+ gzipBytesCeiling: 200,
+ },
+ },
+ {
+ existsSync: () => true,
+ readFileSync: () => Buffer.from("Review state content"),
+ },
+ );
+ const measurement = results["/mockups/development/review-state"];
+ expect(measurement.found).toBe(true);
+ expect(measurement.status).toBe("ok");
+ expect(measurement.rawBytes).toBeGreaterThan(0);
+ expect(measurement.gzipBytes).toBeGreaterThan(0);
+ });
+
+ it("fails when server page HTML exceeds raw or gzip ceiling", () => {
+ const results = measureServerHtmlPayloads(
+ "app",
+ {
+ "/mockups/development/review-state": {
+ rawBytesCeiling: 10,
+ gzipBytesCeiling: 5,
+ },
+ },
+ {
+ existsSync: () => true,
+ readFileSync: () => Buffer.from("Very long repetitive HTML payload content here"),
+ },
+ );
+ const measurement = results["/mockups/development/review-state"];
+ expect(measurement.found).toBe(true);
+ expect(measurement.status).toBe("fail");
+ expect(measurement.reason).toContain("exceeds");
+ });
+
+ it("finds server page artifact when only page.js candidate exists", () => {
+ const results = measureServerHtmlPayloads(
+ "app",
+ {
+ "/mockups/development/review-state": {
+ rawBytesCeiling: 5000,
+ gzipBytesCeiling: 1000,
+ },
+ },
+ {
+ existsSync: (p) => p.endsWith("page.js"),
+ readFileSync: () => Buffer.from("export default function Page() { return null; }"),
+ },
+ );
+ const measurement = results["/mockups/development/review-state"];
+ expect(measurement.found).toBe(true);
+ expect(measurement.status).toBe("ok");
+ });
+});
diff --git a/tests/calculator-scoring.test.ts b/tests/calculator-scoring.test.ts
index 8b0c98e05a..7c6efd749f 100644
--- a/tests/calculator-scoring.test.ts
+++ b/tests/calculator-scoring.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
-import { calculators, type CalculatorFixture, type CalculatorItem } from "@/components/calculators/calculator-fixtures";
+import {
+ allCalculatorFixtures,
+ calculators,
+ type CalculatorFixture,
+ type CalculatorItem,
+} from "@/components/calculators/calculator-fixtures";
import {
deriveCalculator,
formatResultSummary,
@@ -8,10 +13,9 @@ import {
progressLabel,
type AnswerMap,
} from "@/components/calculators/calculator-ui";
-import { missingValuePhrase } from "@/components/ui/missing-value";
/*
- * Scoring guard for the eight validated instruments in the calculators mode.
+ * Scoring guard for the active calculator catalogue and quarantined MDQ fixture.
*
* `calculator-ui.tsx` held the band lookup, the MDQ three-criterion rule and the
* band-suppression rules with no executing test — the only calculator test in the
@@ -24,7 +28,7 @@ import { missingValuePhrase } from "@/components/ui/missing-value";
*/
function fixture(id: string): CalculatorFixture {
- const calc = calculators.find((entry) => entry.id === id);
+ const calc = allCalculatorFixtures.find((entry) => entry.id === id);
if (!calc) throw new Error(`calculator fixture "${id}" not found`);
return calc;
}
@@ -203,17 +207,20 @@ describe("PHQ-9 severity banding", () => {
it("raises the item-9 self-harm flag only when that item scores above zero", () => {
const flagged = deriveCalculator(phq9, { ...baselineAnswers(phq9), p9: 1 });
- expect(flagged.flags).toEqual(["Item 9 endorsed — complete a structured suicide-risk assessment now."]);
+ expect(flagged.flags).toEqual([
+ "Item 9 endorsed — directly assess suicidal thoughts, self-harm thoughts and immediate safety now.",
+ ]);
const unflagged = deriveCalculator(phq9, { ...baselineAnswers(phq9), p9: 0 });
expect(unflagged.flags).toEqual([]);
});
- it("publishes a provisional band while a zero-floor scale is still filling in", () => {
+ it("withholds a PHQ-9 band while the scale is still filling in", () => {
const partial = deriveCalculator(phq9, { p1: 3, p2: 3 });
expect(partial.complete).toBe(false);
expect(partial.started).toBe(true);
- expect(partial.band?.label).toBe("Mild");
+ expect(partial.band).toBeUndefined();
+ expect(partial.result.label).toBe("Incomplete");
});
});
@@ -247,7 +254,7 @@ describe("band suppression for scales that cannot read zero", () => {
// The dash this used to assert was the incidental rendering of the same
// withholding the line above pins; the phrase now says it in words, and is
// read from the primitive so the two cannot drift apart.
- expect(partial.result.label).toBe(missingValuePhrase("withheld_until_complete"));
+ expect(partial.result.label).toBe("Incomplete");
const complete = deriveCalculator(k10, answersForScore(k10, 30));
expect(complete.complete).toBe(true);
@@ -332,12 +339,12 @@ describe("result summary text", () => {
it("appends progress while the scale is incomplete", () => {
const state = deriveCalculator(phq9, { p1: 3, p2: 3 });
expect(state.complete).toBe(false);
- expect(formatResultSummary(phq9, state)).toBe("PHQ-9 6/27 — Mild (2 of 9 answered)");
+ expect(formatResultSummary(phq9, state)).toBe("PHQ-9 6/27 — Incomplete (2 of 9 answered)");
});
it("counts endorsements rather than answers for checkbox-only scales", () => {
const cage = fixture("cage");
const state = deriveCalculator(cage, { c1: 1, c2: 1, c3: 0, c4: 0 });
- expect(progressLabel(state)).toBe("2 of 4 endorsed");
+ expect(progressLabel(state)).toBe("4 of 4 answered · 2 endorsed");
});
});
diff --git a/tests/calculators-clinical-safety.dom.test.tsx b/tests/calculators-clinical-safety.dom.test.tsx
new file mode 100644
index 0000000000..791bc4f8ff
--- /dev/null
+++ b/tests/calculators-clinical-safety.dom.test.tsx
@@ -0,0 +1,197 @@
+/** @vitest-environment jsdom */
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+import {
+ allCalculatorFixtures,
+ calculators,
+ calculatorEvidence,
+ quarantinedCalculators,
+ type CalculatorFixture,
+} from "@/components/calculators/calculator-fixtures";
+import { actionsForBand } from "@/components/calculators/calculator-pathways";
+import { CalculatorsSearchPage } from "@/components/calculators/search-page";
+import { NextActionsPanel } from "@/components/calculators/search-detail";
+import { CopyResultButton, deriveCalculator, type AnswerMap } from "@/components/calculators/calculator-ui";
+import { sharedHomePresentation } from "@/lib/ui-copy";
+
+function fixture(id: string): CalculatorFixture {
+ const found = allCalculatorFixtures.find((calculator) => calculator.id === id);
+ if (!found) throw new Error(`Missing calculator fixture: ${id}`);
+ return found;
+}
+
+function explicitAnswers(calc: CalculatorFixture, value = 0): AnswerMap {
+ return Object.fromEntries(calc.items.map((item) => [item.id, value]));
+}
+
+describe("calculator clinical catalogue", () => {
+ it("fails closed for unsafe or rights-blocked instruments", () => {
+ const activeIds = calculators.map((calculator) => calculator.id);
+ expect(activeIds).toEqual(["phq9", "gad7", "k10", "cage", "auditc"]);
+ expect(activeIds).not.toContain("sadpersons");
+ expect(activeIds).not.toContain("ybocs");
+ expect(activeIds).not.toContain("mdq");
+
+ expect(quarantinedCalculators.map((calculator) => calculator.id)).toEqual(
+ expect.arrayContaining(["mdq", "sadpersons", "ybocs"]),
+ );
+ });
+
+ it("requires evidence, rights and review metadata for every active instrument", () => {
+ for (const calc of calculators) {
+ expect(calc.instrumentVersion).toBeTruthy();
+ expect(calc.sourceIds.length).toBeGreaterThan(0);
+ expect(calc.claimIds.length).toBeGreaterThan(0);
+ expect(calc.rights.status).toBe("available");
+ expect(calc.lastReviewed).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(calc.nextReview).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(calc.releaseStatus).toBe("available");
+ }
+ });
+
+ it("resolves every source and claim reference", () => {
+ const sourceIds = new Set(calculatorEvidence.sources.map((source) => source.id));
+ const claimIds = new Set(calculatorEvidence.claims.map((claim) => claim.id));
+
+ for (const calc of allCalculatorFixtures) {
+ for (const sourceId of calc.sourceIds) expect(sourceIds.has(sourceId)).toBe(true);
+ for (const claimId of calc.claimIds) expect(claimIds.has(claimId)).toBe(true);
+ }
+
+ for (const claim of calculatorEvidence.claims) {
+ expect(claim.sourceIds.length).toBeGreaterThan(0);
+ for (const sourceId of claim.sourceIds) expect(sourceIds.has(sourceId)).toBe(true);
+ }
+
+ for (const source of calculatorEvidence.sources) {
+ expect(source.type).toBeTruthy();
+ expect(source.version).toBeTruthy();
+ expect(source.url).toMatch(/^https:\/\//);
+ expect(source.claimsSupported.length).toBeGreaterThan(0);
+ expect(source.limitations.length).toBeGreaterThan(0);
+ for (const claimId of source.claimsSupported) expect(claimIds.has(claimId)).toBe(true);
+ }
+ });
+
+ it("keeps deterministic treatment and disposition language out of score bands", () => {
+ const forbidden =
+ /\b(pharmacotherapy|start an? ssri|initiate|ect|admission|discharge|specialist referral|augmentation|treatment warranted)\b/i;
+
+ for (const calc of allCalculatorFixtures) {
+ for (const band of calc.bands) expect(band.interpretation).not.toMatch(forbidden);
+ }
+ });
+});
+
+describe("completion is not inferred from a partial score", () => {
+ it("withholds a PHQ-9 band and considerations until all nine items are answered", () => {
+ const calc = fixture("phq9");
+ const partial = deriveCalculator(calc, { p1: 1 });
+
+ expect(partial.started).toBe(true);
+ expect(partial.complete).toBe(false);
+ expect(partial.band).toBeUndefined();
+ expect(partial.result.label).toBe("Incomplete");
+ expect(actionsForBand(calc, partial)).toEqual([]);
+ });
+
+ it("surfaces the PHQ-9 item 9 safety alert before completion", () => {
+ const calc = fixture("phq9");
+ const partial = deriveCalculator(calc, { p9: 1 });
+
+ expect(partial.complete).toBe(false);
+ expect(partial.flags).toContain(
+ "Item 9 endorsed — directly assess suicidal thoughts, self-harm thoughts and immediate safety now.",
+ );
+ expect(calc.items.find((item) => item.id === "p9")?.flagClaimId).toBe("claim:phq9:safety-flag");
+
+ render();
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Item 9 endorsed — directly assess suicidal thoughts, self-harm thoughts and immediate safety now.",
+ );
+ });
+
+ it("does not publish a final GAD-7 band from six answers", () => {
+ const calc = fixture("gad7");
+ const answers = Object.fromEntries(calc.items.slice(0, 6).map((item) => [item.id, 0]));
+ const partial = deriveCalculator(calc, answers);
+
+ expect(partial.complete).toBe(false);
+ expect(partial.band).toBeUndefined();
+ expect(partial.result.label).toBe("Incomplete");
+ });
+
+ it("does not turn an impairment-only MDQ into a completed negative screen", () => {
+ const calc = fixture("mdq");
+ const partial = deriveCalculator(calc, { mimp: 0 });
+
+ expect(partial.complete).toBe(false);
+ expect(partial.result.label).toBe("Incomplete");
+ });
+
+ it.each([
+ ["a symptom response", "m1"],
+ ["the co-occurrence response", "mco"],
+ ["the impairment response", "mimp"],
+ ])("does not complete MDQ when %s is missing", (_label, omittedItemId) => {
+ const calc = fixture("mdq");
+ const answers = explicitAnswers(calc, 0);
+ delete answers[omittedItemId];
+
+ const partial = deriveCalculator(calc, answers);
+ expect(partial.complete).toBe(false);
+ expect(partial.band).toBeUndefined();
+ expect(partial.result.label).toBe("Incomplete");
+ });
+
+ it("allows a completed explicit-negative MDQ result in the quarantined fixture", () => {
+ const calc = fixture("mdq");
+ const answers = explicitAnswers(calc, 0);
+ const complete = deriveCalculator(calc, answers);
+
+ expect(complete.complete).toBe(true);
+ expect(complete.result.label).toBe("Negative screen");
+ });
+
+ it("disables copying a result until the instrument is complete", () => {
+ const calc = fixture("phq9");
+ const partial = deriveCalculator(calc, { p1: 1 });
+ render();
+
+ expect(screen.getByRole("button", { name: "Copy result" })).toBeDisabled();
+ });
+
+ it("renders a completed clinical consideration with its source link", () => {
+ const calc = fixture("phq9");
+ const complete = deriveCalculator(calc, explicitAnswers(calc));
+
+ render();
+ expect(
+ screen.getByRole("link", { name: "The PHQ-9: Validity of a Brief Depression Severity Measure" }),
+ ).toHaveAttribute("href", "https://pmc.ncbi.nlm.nih.gov/articles/PMC1495268/");
+ });
+});
+
+describe("calculator mode copy", () => {
+ it("describes assessment, limitations and source-linked considerations without claiming all tools are validated", () => {
+ expect(sharedHomePresentation.calculators.subtitle).toBe(
+ "Psychiatry assessment and monitoring tools with scoring guidance, limitations, safety prompts, and source-linked clinical considerations.",
+ );
+ });
+
+ it("states the calculator interface privacy boundary on the live catalogue", () => {
+ render();
+
+ for (const notice of screen.getAllByText(/Calculator answers remain in this browser session/)) {
+ expect(notice).toHaveTextContent(
+ "Calculator answers remain in this browser session and are not intentionally submitted by this calculator interface. Application telemetry and clinical-record documentation are governed separately.",
+ );
+ }
+ });
+});
diff --git a/tests/calculators-mode.dom.test.tsx b/tests/calculators-mode.dom.test.tsx
index 1873397fbe..01c71a4785 100644
--- a/tests/calculators-mode.dom.test.tsx
+++ b/tests/calculators-mode.dom.test.tsx
@@ -116,7 +116,9 @@ describe("calculator mode routing", () => {
expect(screen.getByTestId("shared-home-empty-state")).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 2, name: "Clinical Calculators" })).toBeInTheDocument();
expect(
- screen.getByText("Validated psychiatry scores with the indication, items, and next actions in one place."),
+ screen.getByText(
+ "Psychiatry assessment and monitoring tools with scoring guidance, limitations, safety prompts, and source-linked clinical considerations.",
+ ),
).toBeInTheDocument();
const showAll = screen.getByTestId("calculators-show-all");
@@ -163,9 +165,9 @@ describe("calculator filter predicates", () => {
const idsFor = (time: "quick" | "standard" | "extended") =>
filterCalculatorRecords(records, "", { ...emptyFilters(), time }).map(({ calc }) => calc.id);
- expect(idsFor("quick")).toEqual(["gad7", "cage", "auditc", "sadpersons"]);
- expect(idsFor("standard")).toEqual(["phq9", "k10", "mdq"]);
- expect(idsFor("extended")).toEqual(["ybocs"]);
+ expect(idsFor("quick")).toEqual(["gad7", "cage", "auditc"]);
+ expect(idsFor("standard")).toEqual(["phq9", "k10"]);
+ expect(idsFor("extended")).toEqual([]);
});
it("derives candidate counts from the same predicates", () => {
@@ -176,7 +178,7 @@ describe("calculator filter predicates", () => {
time: "all",
};
- expect(calculatorDomainCandidateCount(records, "", filters, "anxiety")).toBe(4);
+ expect(calculatorDomainCandidateCount(records, "", filters, "anxiety")).toBe(2);
expect(calculatorProgressCandidateCount(records, "", filters, "in-progress")).toBe(1);
expect(calculatorTimeCandidateCount(records, "", filters, "quick")).toBe(0);
});
@@ -199,7 +201,7 @@ describe("calculator results surface", () => {
const { container } = render();
expect(screen.getByRole("heading", { level: 1, name: "depression" })).toBeVisible();
- expect(screen.getByRole("status")).toHaveTextContent("2 calculators");
+ expect(screen.getByRole("status")).toHaveTextContent("1 calculator");
expect(container.querySelector('[data-testid="calculators-phone-dock"]')).toBeNull();
const showAll = screen.getByTestId("calculators-show-all");
expect(showAll).toHaveAttribute("href", "/calculators/search");
diff --git a/tests/caring-contact-route-files.test.ts b/tests/caring-contact-route-files.test.ts
index 6c6b6f3c2e..0e452f6823 100644
--- a/tests/caring-contact-route-files.test.ts
+++ b/tests/caring-contact-route-files.test.ts
@@ -83,4 +83,96 @@ describe("Caring Contact mockup route registration", () => {
expect(source, `${file} imports mockup code`).not.toMatch(/caring-contacts\/mockups/);
}
});
+
+ it("enforces full screen registration parity between adoption-contract.json and ui-caring-contacts-workspace.spec.ts", () => {
+ const adoptionContract = JSON.parse(
+ readFileSync(resolve(process.cwd(), "docs/design-system/adoption-contract.json"), "utf8"),
+ );
+ const workspaceSurface = adoptionContract.productionSurfaces.find(
+ (surface: { id: string }) => surface.id === "caring-contacts-workspace",
+ );
+ expect(workspaceSurface).toBeDefined();
+ const declaredRoutes: string[] = workspaceSurface.routes;
+ expect(declaredRoutes.length).toBeGreaterThan(0);
+
+ const specSource = readFileSync(resolve(process.cwd(), "tests/ui-caring-contacts-workspace.spec.ts"), "utf8");
+
+ // Resolve route constant expressions used by WORKSPACE_SCREENS so each
+ // declared adoption-contract route is compared to its own registration,
+ // not a PATIENTS_ROUTE fallback that would pass for any non-root route.
+ const constValues = new Map();
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const match of specSource.matchAll(/^const\s+([A-Z0-9_]+)\s*=\s*"([^"]+)";/gm)) {
+ if (!constValues.has(match[1])) {
+ constValues.set(match[1], match[2]);
+ changed = true;
+ }
+ }
+ for (const match of specSource.matchAll(/^const\s+([A-Z0-9_]+)\s*=\s*`([^`]+)`;/gm)) {
+ const name = match[1];
+ const expr = match[2];
+ let ok = true;
+ const resolved = expr.replace(/\$\{([A-Z0-9_]+)\}/g, (_whole, ref: string) => {
+ if (!constValues.has(ref)) {
+ ok = false;
+ return "";
+ }
+ return constValues.get(ref)!;
+ });
+ if (ok && !constValues.has(name)) {
+ constValues.set(name, resolved);
+ changed = true;
+ }
+ }
+ }
+
+ const screensBlock = specSource.match(/const WORKSPACE_SCREENS = \[([\s\S]*?)\] as const;/);
+ expect(screensBlock, "WORKSPACE_SCREENS must be declared in ui-caring-contacts-workspace.spec.ts").not.toBeNull();
+ const registeredRoutes = new Set();
+ for (const match of screensBlock![1].matchAll(/route:\s*([A-Z0-9_]+)/g)) {
+ const resolved = constValues.get(match[1]);
+ expect(resolved, `WORKSPACE_SCREENS route constant ${match[1]} must resolve`).toBeDefined();
+ registeredRoutes.add(resolved!);
+ }
+
+ const declaredUrlPaths = declaredRoutes.map((routeFile) => {
+ expect(existsSync(resolve(process.cwd(), routeFile)), `${routeFile} must exist on disk`).toBe(true);
+ return (
+ "/" +
+ routeFile
+ .replace(/^src\/app\//, "")
+ .replace(/\/page\.tsx$/, "")
+ .replace(/^page\.tsx$/, "")
+ );
+ });
+
+ const matchesDeclaredPattern = (registered: string, declared: string) => {
+ if (registered === declared) return true;
+ const pattern = new RegExp(
+ `^${declared
+ .split("/")
+ .map((segment) =>
+ segment.startsWith("[") && segment.endsWith("]") ? "[^/]+" : segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ )
+ .join("/")}$`,
+ );
+ return pattern.test(registered);
+ };
+
+ for (const urlPath of declaredUrlPaths) {
+ expect(
+ [...registeredRoutes].some((registered) => matchesDeclaredPattern(registered, urlPath)),
+ `Route ${urlPath} must be registered in WORKSPACE_SCREENS in tests/ui-caring-contacts-workspace.spec.ts`,
+ ).toBe(true);
+ }
+
+ for (const registered of registeredRoutes) {
+ expect(
+ declaredUrlPaths.some((declared) => matchesDeclaredPattern(registered, declared)),
+ `WORKSPACE_SCREENS route ${registered} must correspond to a declared caring-contacts-workspace adoption-contract route`,
+ ).toBe(true);
+ }
+ });
});
diff --git a/tests/check-docs-links.test.ts b/tests/check-docs-links.test.ts
index c25234504e..fc3392cb68 100644
--- a/tests/check-docs-links.test.ts
+++ b/tests/check-docs-links.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { appliedInboxFallbackPath } from "../scripts/check-docs-links.mjs";
+import { appliedInboxFallbackPath, markdownAnchorSlugs } from "../scripts/check-docs-links.mjs";
describe("appliedInboxFallbackPath", () => {
it("maps a pending inbox UUID citation to the applied sibling", () => {
@@ -18,3 +18,43 @@ describe("appliedInboxFallbackPath", () => {
expect(appliedInboxFallbackPath("docs/outstanding-issues-inbox/not-a-uuid.json")).toBeNull();
});
});
+
+describe("markdownAnchorSlugs", () => {
+ it("extracts and slugifies standard and formatted headings", () => {
+ const md = `
+# Title
+
+## 1. Quick Start
+
+### Some \`code\` & [Link text](http://example.com)
+
+## L0 — Structural (1)
+
+### PR 1 — clinical status semantics
+
+## Duplicate Heading
+
+## Duplicate Heading
+`;
+ const slugs = markdownAnchorSlugs(md);
+ expect(slugs.has("title")).toBe(true);
+ expect(slugs.has("1-quick-start")).toBe(true);
+ expect(slugs.has("some-code--link-text")).toBe(true);
+ expect(slugs.has("l0--structural-1")).toBe(true);
+ expect(slugs.has("pr-1--clinical-status-semantics")).toBe(true);
+ expect(slugs.has("duplicate-heading")).toBe(true);
+ expect(slugs.has("duplicate-heading-1")).toBe(true);
+ });
+
+ it("recognizes explicit HTML anchor ids and names", () => {
+ const md = `
+
+
+
+`;
+ const slugs = markdownAnchorSlugs(md);
+ expect(slugs.has("custom-target")).toBe(true);
+ expect(slugs.has("legacy-name")).toBe(true);
+ expect(slugs.has("span-anchor")).toBe(true);
+ });
+});
diff --git a/tests/check-runtime.test.ts b/tests/check-runtime.test.ts
index 53a05cb4fb..646bf352ce 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/clinical-safety.test.ts b/tests/clinical-safety.test.ts
index 71f3b8fe72..a981e58e98 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/codex-cloud-diagnose.test.ts b/tests/codex-cloud-diagnose.test.ts
index 7536b4e5fc..55485c5147 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/design-sync-contract.test.ts b/tests/design-sync-contract.test.ts
index a4c5cf3878..633260e95f 100644
--- a/tests/design-sync-contract.test.ts
+++ b/tests/design-sync-contract.test.ts
@@ -22,7 +22,23 @@ function withPreviewCompilerFixture(tsconfig: object, run: (fixtureRoot: string)
}
describe("design-sync public contract", () => {
- it("keeps sources, entry exports, previews and published props in parity", () => {
+ /**
+ * An explicit timeout, because the global 30 s one is a whole-suite hazard for
+ * this test rather than a generous ceiling for it.
+ *
+ * Measured 2026-09-01 on this worktree: the child process this spawns finishes
+ * in ~7 s standalone and the test itself takes ~9 s, so 30 s looks like ample
+ * headroom 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 this exact test
+ * exceeded 30 000 ms inside `npm run test` and failed a gate for a diff that
+ * touched nothing under `.design-sync/**`. A ~3x stretch is ordinary contention
+ * on a loaded machine, so the budget has to absorb it.
+ *
+ * Not quarantined, and it must not be: it did not fail on its assertion, and a
+ * wrong time budget is a different defect from an unstable test. 60 s keeps a
+ * genuine hang detectable while surviving realistic load.
+ */
+ it("keeps sources, entry exports, previews and published props in parity", { timeout: 60_000 }, () => {
expect(() =>
execFileSync(process.execPath, ["scripts/check-design-sync-contract.mjs"], { cwd: root, stdio: "pipe" }),
).not.toThrow();
@@ -51,7 +67,8 @@ describe("design-sync public contract", () => {
expect(previewTsconfig.compilerOptions.paths[config.pkg]).toEqual(["./entry.tsx"]);
});
- it("passes the actual configured preview TypeScript project", () => {
+ /** ~10 s measured the same way, and exposed to exactly the same stretch. */
+ it("passes the actual configured preview TypeScript project", { timeout: 60_000 }, () => {
expect(() =>
execFileSync(
process.execPath,
diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts
index cdb88044c8..f290e5b6ae 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, readLayers, 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,51 @@ 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([]);
+ });
+
+ // 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);
+ 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 +609,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/developer-clinical-answer-failures-page.dom.test.tsx b/tests/developer-clinical-answer-failures-page.dom.test.tsx
new file mode 100644
index 0000000000..32a72de3e2
--- /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 0000000000..40b973093e
--- /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-components.dom.test.tsx b/tests/developer-hub-components.dom.test.tsx
index 6d447ddd68..2b24fd3566 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-hub-environment-facts.test.ts b/tests/developer-hub-environment-facts.test.ts
new file mode 100644
index 0000000000..d92f865929
--- /dev/null
+++ b/tests/developer-hub-environment-facts.test.ts
@@ -0,0 +1,180 @@
+import { readFileSync } from "node:fs";
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+// resolveHubEnvironmentFacts() supplies three of the four facts on the developer
+// hub's environment strip. Two of its rules are the reason it exists rather than
+// being inlined into the page: the document count must be scoped to the caller's
+// own documents by the database, and a count it could not read must report as
+// absent rather than as zero. An empty corpus and a failed query look identical
+// on screen if that second rule ever slips.
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+});
+
+type LoadOptions = {
+ user?: { id: string; email?: string } | null;
+ count?: number | null;
+ error?: { message: string } | null;
+ demoMode?: boolean;
+ /** The client rejects instead of resolving, as it does on an aborted request. */
+ rejectCount?: boolean;
+ rejectAuth?: boolean;
+};
+
+const selectCalls: { columns: string; options: unknown }[] = [];
+
+async function load({
+ user = null,
+ count = null,
+ error = null,
+ demoMode = false,
+ rejectCount = false,
+ rejectAuth = false,
+}: LoadOptions = {}) {
+ selectCalls.length = 0;
+ vi.doMock("server-only", () => ({}));
+ vi.doMock("@/lib/env", () => ({ isDemoMode: () => demoMode }));
+ vi.doMock("@/lib/supabase/server", () => ({
+ createSupabaseServerClient: vi.fn(async () => ({
+ auth: {
+ getUser: vi.fn(async () => {
+ if (rejectAuth) throw new Error("fetch failed");
+ return { data: { user } };
+ }),
+ },
+ from: vi.fn(() => ({
+ select: vi.fn((columns: string, options: unknown) => {
+ selectCalls.push({ columns, options });
+ return rejectCount ? Promise.reject(new Error("fetch failed")) : Promise.resolve({ count, error });
+ }),
+ })),
+ })),
+ }));
+ return import("../src/lib/developer-area/environment-facts");
+}
+
+describe("resolveHubEnvironmentFacts", () => {
+ it("reports the demo/live environment even when Supabase is not configured", async () => {
+ vi.doMock("server-only", () => ({}));
+ vi.doMock("@/lib/env", () => ({ isDemoMode: () => true }));
+ vi.doMock("@/lib/supabase/server", () => ({ createSupabaseServerClient: vi.fn(async () => null) }));
+ const { resolveHubEnvironmentFacts } = await import("../src/lib/developer-area/environment-facts");
+
+ // The env read never depends on the database, which is why it was able to
+ // ship before the other two facts.
+ await expect(resolveHubEnvironmentFacts()).resolves.toEqual({
+ demoMode: true,
+ documentCount: null,
+ email: null,
+ });
+ });
+
+ it("does not count anything for a request with no signed-in user", async () => {
+ const { resolveHubEnvironmentFacts } = await load({ user: null, count: 4 });
+
+ await expect(resolveHubEnvironmentFacts()).resolves.toEqual({
+ demoMode: false,
+ documentCount: null,
+ email: null,
+ });
+ // Not merely "returns null": the query must not run at all. Row-level
+ // security would correctly return 0 rows to an anonymous caller, and
+ // rendering that as "0 documents" would state something false about the
+ // corpus rather than about the session.
+ expect(selectCalls).toHaveLength(0);
+ });
+
+ it("reports the owner's document count and email for a signed-in user", async () => {
+ const { resolveHubEnvironmentFacts } = await load({
+ user: { id: "user-1", email: "clinician@example.com" },
+ count: 2851,
+ });
+
+ await expect(resolveHubEnvironmentFacts()).resolves.toEqual({
+ demoMode: false,
+ documentCount: 2851,
+ email: "clinician@example.com",
+ });
+ expect(selectCalls).toEqual([{ columns: "id", options: { count: "exact", head: true } }]);
+ });
+
+ it("keeps an empty corpus distinct from a count it could not read", async () => {
+ const empty = await load({ user: { id: "user-1" }, count: 0 });
+ await expect(empty.resolveHubEnvironmentFacts()).resolves.toMatchObject({ documentCount: 0 });
+
+ vi.resetModules();
+ const failed = await load({ user: { id: "user-1" }, count: 0, error: { message: "permission denied" } });
+ await expect(failed.resolveHubEnvironmentFacts()).resolves.toMatchObject({ documentCount: null });
+ });
+
+ it("reports a missing count as unavailable rather than as zero", async () => {
+ const { resolveHubEnvironmentFacts } = await load({ user: { id: "user-1" }, count: null });
+
+ await expect(resolveHubEnvironmentFacts()).resolves.toMatchObject({ documentCount: null });
+ });
+
+ it("has no email to report for a user record that carries none", async () => {
+ const { resolveHubEnvironmentFacts } = await load({ user: { id: "user-1" }, count: 3 });
+
+ await expect(resolveHubEnvironmentFacts()).resolves.toMatchObject({ email: null, documentCount: 3 });
+ });
+
+ /**
+ * A returned `{ error }` is only half of what can go wrong. The client rejects
+ * rather than resolves when a request is aborted or exhausts its network
+ * retries, and an unhandled rejection would fail the entire developer hub page
+ * instead of degrading one line of it — during exactly the Supabase outage
+ * that makes the page worth opening. Raised in review of PR #2495.
+ */
+ it("degrades to unavailable when the count read rejects instead of returning an error", async () => {
+ const { resolveHubEnvironmentFacts } = await load({
+ user: { id: "user-1", email: "clinician@example.com" },
+ rejectCount: true,
+ demoMode: false,
+ });
+
+ // Not merely "does not throw": the facts that were already read must survive.
+ await expect(resolveHubEnvironmentFacts()).resolves.toEqual({
+ demoMode: false,
+ documentCount: null,
+ email: null,
+ });
+ });
+
+ it("degrades to unavailable when the auth read itself rejects", async () => {
+ const { resolveHubEnvironmentFacts } = await load({ rejectAuth: true, demoMode: true });
+
+ // `demoMode` never touched the network, so it must still be reported: an
+ // outage should not make the page claim it cannot tell demo from live.
+ await expect(resolveHubEnvironmentFacts()).resolves.toEqual({
+ demoMode: true,
+ documentCount: null,
+ email: null,
+ });
+ });
+
+ /**
+ * The owner-scoping guarantee is structural, not behavioural: it holds because
+ * this module uses the cookie-bound user client, which row-level security
+ * scopes to `owner_id = auth.uid()`. The service-role admin client bypasses RLS
+ * entirely, so importing it here would silently turn one account's count into
+ * every account's — with no failing assertion anywhere, because the mock in the
+ * tests above would still answer. A source assertion is the only thing that can
+ * catch that substitution.
+ */
+ it("reads through the user-session client and never the service-role client", () => {
+ const source = readFileSync(new URL("../src/lib/developer-area/environment-facts.ts", import.meta.url), "utf8");
+ // Import statements only. The module's own comment names `createAdminClient`
+ // to explain why it is wrong here, so a whole-file substring search for that
+ // identifier would fail on the documentation rather than on the code.
+ const imports = source.split("\n").filter((line) => line.startsWith("import "));
+
+ expect(imports.some((line) => line.includes('"@/lib/supabase/server"'))).toBe(true);
+ expect(imports.some((line) => line.includes("supabase/admin"))).toBe(false);
+ // Catches a dynamic import or a re-export that no import line would show.
+ expect(source).not.toContain("supabase/admin");
+ });
+});
diff --git a/tests/developer-hub-page.dom.test.tsx b/tests/developer-hub-page.dom.test.tsx
index a91cbd6947..d869ef0ad3 100644
--- a/tests/developer-hub-page.dom.test.tsx
+++ b/tests/developer-hub-page.dom.test.tsx
@@ -29,6 +29,36 @@ vi.mock("@/components/account-data-provider", () => ({
}),
}));
+/**
+ * The page reads all four strip facts through one server-only helper, so the
+ * helper is the seam. Mocking it rather than Supabase keeps this file a *page*
+ * contract: what the page renders for a given set of facts, not how those facts
+ * are fetched. `tests/developer-hub-environment-facts.test.ts` owns the other
+ * half — owner scoping, and that a failed read reports null rather than zero.
+ *
+ * The default is the shape an unauthenticated local render produces, which is
+ * what every test that does not care about the strip should see.
+ */
+const environment = vi.hoisted(() => ({
+ value: { demoMode: true, documentCount: null as number | null, email: null as string | null },
+}));
+
+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
@@ -77,8 +107,8 @@ describe("developer hub page — synthetic-data warning", () => {
* it in place was a source comment, so the next layout rewrite could drop it
* with every gate still green. That is what these pin.
*/
- it("states that the data is synthetic and that nothing here is validated decision support", () => {
- const { container } = render();
+ it("states that the data is synthetic and that nothing here is validated decision support", async () => {
+ const { container } = render(await DeveloperHubPage());
const warning = container.querySelector("svg.lucide-shield-alert")?.closest("p");
expect(warning, "no paragraph carries the ShieldAlert warning icon").not.toBeNull();
@@ -89,9 +119,9 @@ describe("developer hub page — synthetic-data warning", () => {
expect(warning).toHaveTextContent(/nothing here is validated clinical decision support/);
});
- it("keeps the warning above the blocking-work band, not buried below the panels", () => {
+ it("keeps the warning above the blocking-work band, not buried below the panels", async () => {
p1.value = 3;
- const { container } = render();
+ const { container } = render(await DeveloperHubPage());
const warning = container.querySelector("svg.lucide-shield-alert")?.closest("p");
const band = screen.getByTestId("developer-hub-needs-you-now");
@@ -100,21 +130,21 @@ describe("developer hub page — synthetic-data warning", () => {
expect(warning!.compareDocumentPosition(band) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
- it("marks the warning icon decorative so its sentence is not announced twice", () => {
- const { container } = render();
+ it("marks the warning icon decorative so its sentence is not announced twice", async () => {
+ const { container } = render(await DeveloperHubPage());
expect(container.querySelector("svg.lucide-shield-alert")).toHaveAttribute("aria-hidden", "true");
});
});
describe("developer hub page — needs-you-now band", () => {
- it("reports the snapshot's own P1 count", () => {
+ it("reports the snapshot's own P1 count", async () => {
// No override, so this is the real committed snapshot: the band must agree
// with the data the route actually loads, not merely with itself. When the
// snapshot has no P1s the page must omit the band rather than render a
// settled-looking "0 blocking items" line — that is the same contract as
// the explicit zero-override case below.
const { counts } = loadLedgerSnapshot();
- render();
+ render(await DeveloperHubPage());
if (counts.p1 > 0) {
expect(screen.getByTestId("developer-hub-needs-you-now")).toHaveTextContent(String(counts.p1));
} else {
@@ -122,13 +152,13 @@ describe("developer hub page — needs-you-now band", () => {
}
});
- it("carries no text beyond the computed count", () => {
+ it("carries no text beyond the computed count", async () => {
// Spec 8.1, and the whole reason the band exists: a literal such as "red for
// 26 days" cannot age, so static prose here is the same defect class as a
// stale snapshot. Asserting the exact string is what makes that unfakeable
// — any added claim changes it.
p1.value = 7;
- render();
+ render(await DeveloperHubPage());
const band = screen.getByTestId("developer-hub-needs-you-now");
expect(band.textContent).toBe("7 blocking items in the task ledger.");
@@ -137,23 +167,23 @@ describe("developer hub page — needs-you-now band", () => {
expect(band.textContent?.match(/\d+/g)).toEqual(["7"]);
});
- it("says 'item' for one and 'items' for more than one", () => {
+ it("says 'item' for one and 'items' for more than one", async () => {
p1.value = 1;
- render();
+ render(await DeveloperHubPage());
expect(screen.getByTestId("developer-hub-needs-you-now").textContent).toBe("1 blocking item in the task ledger.");
cleanup();
p1.value = 2;
- render();
+ render(await DeveloperHubPage());
expect(screen.getByTestId("developer-hub-needs-you-now").textContent).toBe("2 blocking items in the task ledger.");
});
- it("renders nothing rather than a reassuring all-clear when there are no blockers", () => {
+ it("renders nothing rather than a reassuring all-clear when there are no blockers", async () => {
// A band reading "0 blocking items" would be a settled-looking statement
// about work the page cannot see. The override keeps this branch explicit
// even on days the committed snapshot already has no P1s.
p1.value = 0;
- render();
+ render(await DeveloperHubPage());
expect(screen.queryByTestId("developer-hub-needs-you-now")).toBeNull();
// The page still rendered: this is a dropped band, not a dead page.
@@ -162,20 +192,20 @@ describe("developer hub page — needs-you-now band", () => {
});
describe("developer hub page — group sections", () => {
- it("renders an anchor and a heading for a group that has panels", () => {
- const { container } = render();
+ it("renders an anchor and a heading for a group that has panels", async () => {
+ const { container } = render(await DeveloperHubPage());
expect(container.querySelector("#developer-hub-clinical")).not.toBeNull();
expect(screen.getByRole("heading", { name: "Clinical trust" })).toBeInTheDocument();
});
- it("omits the anchor entirely for an empty group", () => {
+ it("omits the anchor entirely for an empty group", async () => {
// `useResolvedPageSections` drops a declared section whose anchor is not
// rendered — that is what lets phases 2-4 add panels without touching the
// navigation. Rendering the section unconditionally would make that
// mechanism inert here and leave a jump to a bare heading above an empty
// grid.
emptyGroups.value = new Set(["clinical"]);
- const { container } = render();
+ const { container } = render(await DeveloperHubPage());
expect(container.querySelector("#developer-hub-clinical")).toBeNull();
expect(screen.queryByRole("heading", { name: "Clinical trust" })).toBeNull();
@@ -203,8 +233,8 @@ describe("developer hub page — section headings", () => {
* boundary — both modules are ordinary JavaScript under vitest. Do not
* weaken it, and do not "improve" the page into deriving them.
*/
- it("gives every nav section the exact heading its nav entry declares", () => {
- const { container } = render();
+ it("gives every nav section the exact heading its nav entry declares", async () => {
+ const { container } = render(await DeveloperHubPage());
const rendered = developerHubNavSections
.map((section) => ({ section, element: container.querySelector(`#${section.id}`) }))
@@ -222,11 +252,11 @@ describe("developer hub page — section headings", () => {
}
});
- it("renders no panel grid for the environment section, which is not a panel group", () => {
+ it("renders no panel grid for the environment section, which is not a panel group", async () => {
// `developerHubNavSections` declares `developer-hub-environment`, but it is
// a navigable section rather than a panel group: it renders its own anchor
// and `sr-only` heading around the strip, and must never gain a panel grid.
- const { container } = render();
+ const { container } = render(await DeveloperHubPage());
const environment = container.querySelector("#developer-hub-environment");
expect(environment).not.toBeNull();
@@ -260,22 +290,113 @@ describe("developer hub page — environment strip", () => {
}
});
- it("reports the deployed build sha when the platform provides one", () => {
+ it("reports the deployed build sha when the platform provides one", async () => {
clearBuildShaVariables();
process.env.RAILWAY_GIT_COMMIT_SHA = "ce4b1cb72".padEnd(40, "0");
- render();
+ render(await DeveloperHubPage());
expect(screen.getByTestId("developer-hub-environment-strip")).toHaveTextContent("build ce4b1cb");
});
- it("says build unknown rather than inventing one when the platform provides none", () => {
+ it("says build unknown rather than inventing one when the platform provides none", async () => {
clearBuildShaVariables();
- render();
+ render(await DeveloperHubPage());
const strip = screen.getByTestId("developer-hub-environment-strip");
expect(strip).toHaveTextContent("build unknown");
- // The three facts the plan scoped to Phase 2 still name their own gaps
- // rather than claiming a value the page never read.
- expect(strip).toHaveTextContent("environment unknown");
+ // With the default facts (nothing read), the other two still name their own
+ // gaps rather than claiming a value. Both directions are covered below.
expect(strip).toHaveTextContent("document count unavailable");
expect(strip).toHaveTextContent("account unknown");
});
+
+ afterEach(() => {
+ environment.value = { demoMode: true, documentCount: null, email: null };
+ });
+
+ it("says Demo corpus when the app is serving the synthetic corpus", async () => {
+ environment.value = { ...environment.value, demoMode: true };
+ render(await DeveloperHubPage());
+ const strip = screen.getByTestId("developer-hub-environment-strip");
+ expect(strip).toHaveTextContent("Demo corpus");
+ expect(strip).not.toHaveTextContent("environment unknown");
+ });
+
+ /**
+ * The branch that matters. Before this was wired the strip said "environment
+ * unknown" in both directions, which was honest; the failure to avoid now is
+ * the opposite one, where a page reads demo mode and still reports live data.
+ */
+ it("says Live data only when it has actually read that the app is not in demo mode", async () => {
+ environment.value = { ...environment.value, demoMode: false };
+ render(await DeveloperHubPage());
+ const strip = screen.getByTestId("developer-hub-environment-strip");
+ expect(strip).toHaveTextContent("Live data");
+ expect(strip).not.toHaveTextContent("environment unknown");
+ expect(strip).not.toHaveTextContent("Demo corpus");
+ });
+
+ it("reports the document count it read, grouped for reading", async () => {
+ environment.value = { ...environment.value, documentCount: 2851 };
+ render(await DeveloperHubPage());
+ expect(screen.getByTestId("developer-hub-environment-strip")).toHaveTextContent("2,851 documents");
+ });
+
+ /**
+ * Zero is a true answer — an account that has uploaded nothing — and it must
+ * survive to the screen as "0 documents". If the page ever coerced a falsy
+ * count into the unavailable branch, an empty corpus would be indistinguishable
+ * from a failed read, which is the one confusion this strip exists to prevent.
+ */
+ it("distinguishes an empty corpus from a count it could not read", async () => {
+ environment.value = { ...environment.value, documentCount: 0 };
+ render(await DeveloperHubPage());
+ expect(screen.getByTestId("developer-hub-environment-strip")).toHaveTextContent("0 documents");
+
+ cleanup();
+ environment.value = { ...environment.value, documentCount: null };
+ render(await DeveloperHubPage());
+ expect(screen.getByTestId("developer-hub-environment-strip")).toHaveTextContent("document count unavailable");
+ });
+
+ it("names the signed-in account, and says so plainly when there is none", async () => {
+ environment.value = { ...environment.value, email: "clinician@example.com" };
+ render(await DeveloperHubPage());
+ expect(screen.getByTestId("developer-hub-environment-strip")).toHaveTextContent("clinician@example.com");
+
+ cleanup();
+ environment.value = { ...environment.value, email: null };
+ render(await DeveloperHubPage());
+ 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();
+ });
});
diff --git a/tests/developer-ingestion-page.dom.test.tsx b/tests/developer-ingestion-page.dom.test.tsx
index ad883566ad..8e646dae7e 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/differential-detail.test.ts b/tests/differential-detail.test.ts
index d0efc3b8f1..b40c8b17e0 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");
});
});
@@ -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/differentials.test.ts b/tests/differentials.test.ts
index fe51023bae..4c33dbb712 100644
--- a/tests/differentials.test.ts
+++ b/tests/differentials.test.ts
@@ -484,6 +484,18 @@ describe("composeDifferentialSearchResults", () => {
});
describe("ranked differential search", () => {
+ it("interprets presentation language without leaving the differential catalogue", () => {
+ const matches = rankDifferentialRecords(
+ differentialRecords,
+ "What can cause hearing voices?",
+ differentialRecords.length,
+ [],
+ true,
+ );
+ expect(matches.length).toBeGreaterThan(0);
+ expect(matches.some(({ record }) => record.slug.includes("psychosis"))).toBe(true);
+ });
+
it("ranks title matches above content-only matches", () => {
const matches = rankDifferentialRecords(differentialRecords, "delirium");
expect(matches.length).toBeGreaterThan(0);
diff --git a/tests/dsm.test.ts b/tests/dsm.test.ts
index 127a3969df..b19a1eed1d 100644
--- a/tests/dsm.test.ts
+++ b/tests/dsm.test.ts
@@ -12,6 +12,12 @@ import {
} from "@/lib/dsm";
describe("DSM clinical catalogue", () => {
+ it("interprets natural-language symptom phrases as diagnosis catalogue search", () => {
+ expect(rankDsmDiagnoses("Which diagnoses involve elevated mood?", 3, [], true)[0]?.diagnosis.slug).toBe(
+ "bipolar-i-disorder",
+ );
+ });
+
it("loads every supplied diagnosis and keeps slugs unique", () => {
expect(dsmDiagnoses).toHaveLength(146);
expect(new Set(dsmDiagnoses.map((diagnosis) => diagnosis.slug)).size).toBe(146);
diff --git a/tests/eval-retrieval.test.ts b/tests/eval-retrieval.test.ts
index 05bf5e0390..c7e82bc7a9 100644
--- a/tests/eval-retrieval.test.ts
+++ b/tests/eval-retrieval.test.ts
@@ -3,6 +3,8 @@ import {
capturedRagCaseToGoldenCase,
evaluateGoldenRetrievalCase,
loadGoldenRetrievalCases,
+ parseArgs,
+ resolveEvaluationCases,
retrievalLimitForGoldenCase,
summarizeGoldenRetrievalResults,
textContainsClinicalTerm,
@@ -647,3 +649,69 @@ describe("textContainsClinicalTerm word-boundary matching", () => {
expect(textContainsClinicalTerm("intramuscularly administered", "im")).toBe(false);
});
});
+
+describe("parseArgs --allow-partial", () => {
+ it("defaults allowPartial to false", () => {
+ const args = parseArgs(["--mode", "quality"]);
+ expect(args.allowPartial).toBe(false);
+ });
+
+ it("sets allowPartial to true when flag is present", () => {
+ const args = parseArgs(["--allow-partial"]);
+ expect(args.allowPartial).toBe(true);
+ });
+});
+
+describe("resolveEvaluationCases", () => {
+ const dummyCases = [
+ {
+ id: "case-1",
+ query: "Clozapine ANC monitoring",
+ expectedQueryClass: "table_threshold",
+ expectedDocumentSubstrings: ["Clozapine"],
+ expectedContentTerms: ["anc"],
+ topK: 8,
+ expectTableEvidence: false,
+ },
+ {
+ id: "case-2",
+ query: "Lithium level check",
+ expectedQueryClass: "table_threshold",
+ expectedDocumentSubstrings: ["Lithium"],
+ expectedContentTerms: ["level"],
+ topK: 8,
+ expectTableEvidence: false,
+ },
+ ];
+
+ it("returns all cases when selection is full", () => {
+ const resolved = resolveEvaluationCases({ allCases: dummyCases });
+ expect(resolved).toHaveLength(2);
+ });
+
+ it("throws when selection is empty", () => {
+ expect(() => resolveEvaluationCases({ allCases: dummyCases, query: "non-existent query" })).toThrow(
+ /No retrieval eval cases matched/,
+ );
+ expect(() => resolveEvaluationCases({ allCases: [] })).toThrow(/No retrieval eval cases matched/);
+ });
+
+ it("throws when cases are dropped by limit or query without --allow-partial", () => {
+ expect(() => resolveEvaluationCases({ allCases: dummyCases, limit: 1, allowPartial: false })).toThrow(
+ /without --allow-partial/,
+ );
+ expect(() => resolveEvaluationCases({ allCases: dummyCases, query: "Clozapine", allowPartial: false })).toThrow(
+ /without --allow-partial/,
+ );
+ });
+
+ it("permits running partial cases when allowPartial is true", () => {
+ const limited = resolveEvaluationCases({ allCases: dummyCases, limit: 1, allowPartial: true });
+ expect(limited).toHaveLength(1);
+ expect(limited[0].id).toBe("case-1");
+
+ const queried = resolveEvaluationCases({ allCases: dummyCases, query: "Lithium", allowPartial: true });
+ expect(queried).toHaveLength(1);
+ expect(queried[0].id).toBe("case-2");
+ });
+});
diff --git a/tests/extractive-answer-formatting.test.ts b/tests/extractive-answer-formatting.test.ts
index bc8b959f34..d6a869f502 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/forms-search-filters.dom.test.tsx b/tests/forms-search-filters.dom.test.tsx
index 8c30b130e2..00910e850c 100644
--- a/tests/forms-search-filters.dom.test.tsx
+++ b/tests/forms-search-filters.dom.test.tsx
@@ -53,6 +53,12 @@ function escapePattern(value: string) {
}
describe("FormsSearchResultsPage filters", () => {
+ it("interprets natural-language detention extension queries as form search", () => {
+ expect(
+ rankFormRecords(formRecords, "Which form extends detention?", formRecords.length, [], true)[0]?.service.slug,
+ ).toBe("form-7b");
+ });
+
it("renders functional category, clinical-risk, and availability facets", async () => {
const user = userEvent.setup();
const query = "form";
diff --git a/tests/formulation.test.ts b/tests/formulation.test.ts
index d223ace659..a282723e40 100644
--- a/tests/formulation.test.ts
+++ b/tests/formulation.test.ts
@@ -44,7 +44,9 @@ describe("clinical formulation content", () => {
});
it("matches patient language and clinical clues to mechanisms", () => {
- expect(searchFormulationMechanisms("I keep going over it")[0]?.mechanism.id).toBe("rumination");
+ expect(
+ searchFormulationMechanisms("I keep going over it", { interpretNaturalLanguage: true })[0]?.mechanism.id,
+ ).toBe("rumination");
expect(searchFormulationMechanisms("What if something goes wrong")[0]?.mechanism.id).toBe("worry");
expect(searchFormulationMechanisms("It goes from zero to one hundred")[0]?.mechanism.id).toBe(
"emotional-dysregulation",
diff --git a/tests/in-page-nav-route-sections.dom.test.tsx b/tests/in-page-nav-route-sections.dom.test.tsx
index ddf2d79d28..053b6c402e 100644
--- a/tests/in-page-nav-route-sections.dom.test.tsx
+++ b/tests/in-page-nav-route-sections.dom.test.tsx
@@ -40,6 +40,16 @@ import { serviceRecords } from "@/lib/services";
import { specifierCatalogItems, curatedEnrichmentFor } from "@/lib/specifiers-content";
import { specifierRecords } from "@/lib/specifiers";
+/**
+ * `/mockups/development` is the only async route in this table, and the only one
+ * that would otherwise reach Supabase from a cross-route navigation contract.
+ * Its facts are stubbed so this file keeps testing anchors, not data access;
+ * `tests/developer-hub-environment-facts.test.ts` owns that half.
+ */
+vi.mock("@/lib/developer-area/environment-facts", () => ({
+ resolveHubEnvironmentFacts: async () => ({ demoMode: true, documentCount: null, email: null }),
+}));
+
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }),
@@ -86,7 +96,12 @@ afterEach(cleanup);
type RouteCase = {
name: string;
sections: readonly PageSection[];
- render: () => ReactElement;
+ /**
+ * Async because a route component may be an async Server Component —
+ * `/mockups/development` awaits its environment facts. Callers must `await`
+ * this before handing it to `render`.
+ */
+ render: () => ReactElement | Promise;
/**
* Anchors that legitimately depend on the record. Each is asserted present on
* a fixture that has the data and absent on one that does not, rather than
@@ -203,7 +218,7 @@ const routes: RouteCase[] = [
{
name: "/mockups/development",
sections: developerHubNavSections,
- render: () => ,
+ render: () => DeveloperHubPage(),
},
factsheetRoute("medRich"),
factsheetRoute("medLite"),
@@ -217,8 +232,8 @@ const routes: RouteCase[] = [
describe("in-page navigation section contracts", () => {
it.each(routes.map((route) => [route.name, route] as const))(
"%s renders an anchor for every declared section",
- (_name, route) => {
- const { container } = render(route.render());
+ async (_name, route) => {
+ const { container } = render(await route.render());
for (const section of route.sections) {
const found = sectionTargetIds(section).some((id) => container.querySelector(`#${CSS.escape(id)}`));
@@ -240,10 +255,10 @@ describe("in-page navigation section contracts", () => {
it.each(routes.map((route) => [route.name, route] as const))(
"%s gives every anchor the shared in-page scroll margin",
- (_name, route) => {
+ async (_name, route) => {
// Information-page sections carried no scroll-mt at all before the shared
// header existed, so without this every jump lands underneath it.
- const { container } = render(route.render());
+ const { container } = render(await route.render());
for (const section of route.sections) {
if (route.absent?.includes(section.id)) continue;
diff --git a/tests/issues-report.test.ts b/tests/issues-report.test.ts
index f73b24b41b..4af54ee49f 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");
+ });
});
diff --git a/tests/master-search-header.dom.test.tsx b/tests/master-search-header.dom.test.tsx
index 5b73271c2d..53d566921c 100644
--- a/tests/master-search-header.dom.test.tsx
+++ b/tests/master-search-header.dom.test.tsx
@@ -120,22 +120,32 @@ describe("MasterSearchHeader DOM", () => {
expect(props.onAsk).toHaveBeenCalledOnce();
});
- it("shows a governed Smart cue only when the server capability and intent both allow it", () => {
+ 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 provider-free Smart search while preserving the ordinary search action", () => {
const props = {
...defaultHeaderProps(),
query: "Which service is best for ongoing support after discharge?",
searchMode: "services" as const,
};
- const { rerender } = render();
+ const { rerender } = render();
- expect(screen.getByTestId("smart-search-intent-cue")).toHaveTextContent("Smart answer");
- expect(screen.getByRole("button", { name: "Get Smart answer" })).toBeInTheDocument();
- expect(screen.getByText("Smart answer selected for Services.")).toBeInTheDocument();
+ expect(screen.getByTestId("smart-search-intent-cue")).toHaveTextContent("Smart search");
+ expect(screen.getByRole("button", { name: "Search services" })).toBeInTheDocument();
+ expect(screen.getByText("Smart search selected for Services.")).toBeInTheDocument();
+ expect(screen.queryByText(/Smart answer/i)).not.toBeInTheDocument();
- rerender();
+ rerender();
expect(screen.queryByTestId("smart-search-intent-cue")).not.toBeInTheDocument();
- expect(screen.queryByRole("button", { name: "Get Smart answer" })).not.toBeInTheDocument();
- expect(screen.queryByTestId("smart-search-rotating-text")).not.toBeInTheDocument();
+ expect(screen.getByTestId("smart-search-rotating-text")).toHaveTextContent("Smart search");
});
it("routes Factsheets Browse all sheets to the Topics page", async () => {
diff --git a/tests/maturity-governance-docs.test.ts b/tests/maturity-governance-docs.test.ts
index 12de4c2e0d..0f18f24859 100644
--- a/tests/maturity-governance-docs.test.ts
+++ b/tests/maturity-governance-docs.test.ts
@@ -9,10 +9,11 @@ describe("maturity governance documentation", () => {
it("keeps the privacy assessment explicit about approval and operational evidence", () => {
const pia = read("docs/privacy-impact-assessment.md");
expect(pia).toContain("Status:** Draft for governance approval");
- expect(pia).toContain("Revised:** 2026-08-23");
+ expect(pia).toContain("Revised:** 2026-09-01");
expect(pia).toMatch(/\| PIA-2 \| Mitigated \|/);
expect(pia).toContain("PIA-1");
- expect(pia).toContain("remaining privacy governance launch blocker");
+ expect(pia).toContain("close the six release-blocking");
+ expect(pia).toContain("clinical PHI-minimisation acceptance");
expect(pia).not.toMatch(/PIA-1[^\n]*and \*\*PIA-2\*\*[^\n]*launch-blockers/);
});
diff --git a/tests/privacy-readiness-contract.test.ts b/tests/privacy-readiness-contract.test.ts
index 995531bbd1..cdb7900116 100644
--- a/tests/privacy-readiness-contract.test.ts
+++ b/tests/privacy-readiness-contract.test.ts
@@ -6,6 +6,10 @@ const manifest = JSON.parse(
readFileSync(new URL("../docs/governance/privacy-readiness.v1.json", import.meta.url), "utf8"),
);
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
+const retentionParityMigration = readFileSync(
+ new URL("../supabase/migrations/20260901033250_enable_staging_privacy_retention_schedules.sql", import.meta.url),
+ "utf8",
+);
describe("privacy readiness contract", () => {
it("accepts the honest structural register", () => {
@@ -17,15 +21,58 @@ describe("privacy readiness contract", () => {
expect(railwayDpa.evidenceReferences).toContain("docs/privacy-impact-assessment.md");
});
- it("fails release closed while CI validates the structural register", () => {
- expect(validatePrivacyReadiness(manifest, { release: true })).toContain(
- "PRIV-PROVIDER-OPENAI-ZDR: release-blocking status pending",
- );
+ it("fails release closed on the remaining human and environment blockers", () => {
+ const releaseErrors = validatePrivacyReadiness(manifest, { release: true });
+ expect(releaseErrors).toContain("PRIV-LEGAL-RAILWAY-DPA: release-blocking status pending");
+ expect(releaseErrors.filter((error: string) => error.includes("release-blocking status"))).toHaveLength(6);
+ expect(releaseErrors).not.toContain("PRIV-PROVIDER-PRODUCTION-HMAC-SECRET: release-blocking status partial");
+ expect(releaseErrors).not.toContain("PRIV-PROVIDER-RETENTION-SCHEDULE-PARITY: release-blocking status partial");
expect(packageJson.scripts["check:production-readiness"]).toContain("check:privacy-readiness:release");
expect(packageJson.scripts["check:production-readiness:ci"]).toContain("check:privacy-readiness");
expect(packageJson.scripts["check:production-readiness:ci"]).not.toContain("check:privacy-readiness:release");
});
+ it("records current provider evidence without promoting repository-only OpenAI claims", () => {
+ const byId = new Map(manifest.requirements.map((item: { id: string }) => [item.id, item]));
+
+ expect(byId.get("PRIV-PROVIDER-PRODUCTION-HMAC-SECRET")).toMatchObject({
+ status: "verified",
+ verifiedByRole: "Production platform owner",
+ });
+ expect(byId.get("PRIV-PROVIDER-OPENAI-ZDR")).toMatchObject({
+ status: "pending",
+ externalEvidenceReference: expect.stringContaining("API input/output sharing disabled"),
+ });
+ expect(byId.get("PRIV-PROVIDER-OPENAI-ZDR")).toMatchObject({
+ externalEvidenceReference: expect.stringContaining("API call logging to Disabled"),
+ });
+ expect(byId.get("PRIV-PROVIDER-OPENAI-ZDR")).toMatchObject({
+ externalEvidenceReference: expect.stringContaining("disabled hosted MCP, web search, file search"),
+ });
+ expect(byId.get("PRIV-PROVIDER-OPENAI-ZDR")).toMatchObject({
+ externalEvidenceReference: expect.stringContaining("submitted and acknowledged by OpenAI"),
+ });
+ expect(byId.get("PRIV-LEGAL-OPENAI-DPA")).toMatchObject({ status: "pending" });
+ expect(byId.get("PRIV-PROVIDER-RETENTION-SCHEDULE-PARITY")).toMatchObject({
+ status: "verified",
+ verifiedByRole: "Database operations owner",
+ });
+ expect(byId.get("PRIV-LEGAL-APP8-CROSS-BORDER-BASIS")).toMatchObject({ status: "pending" });
+ expect(byId.get("PRIV-CLINICAL-PHI-MINIMISATION")).toMatchObject({ status: "partial" });
+ });
+
+ it("reconciles all four staging privacy-retention schedules after enabling pg_cron", () => {
+ expect(retentionParityMigration).toContain("create extension if not exists pg_cron with schema pg_catalog");
+ expect(retentionParityMigration).toContain("'purge-expired-rag-queries'");
+ expect(retentionParityMigration).toContain("'purge-rag-retrieval-logs'");
+ expect(retentionParityMigration).toContain("'purge-rag-query-misses'");
+ expect(retentionParityMigration).toContain("'purge-rag-response-cache'");
+ expect(retentionParityMigration).toContain("select public.purge_expired_rag_queries(30)");
+ expect(retentionParityMigration).toContain("interval '90 days'");
+ expect(retentionParityMigration).toContain("select public.purge_expired_rag_query_misses(90)");
+ expect(retentionParityMigration).toContain("select public.purge_expired_rag_response_cache(1000)");
+ });
+
it("rejects contradictory external verification and forbidden accepted-decision rollback", () => {
const changed = structuredClone(manifest);
const requirement = changed.requirements.find((item: { id: string }) => item.id === "PRIV-LEGAL-OPENAI-DPA");
@@ -34,6 +81,8 @@ describe("privacy readiness contract", () => {
{ status: "accepted_decision", date: "2026-08-22" },
{ status: "verified", date: "2026-08-23" },
];
+ delete requirement.externalEvidenceReference;
+ delete requirement.verifiedByRole;
const errors = validatePrivacyReadiness(changed, { checkFiles: false });
expect(errors).toContain("PRIV-LEGAL-OPENAI-DPA: transition accepted_decision -> verified is not allowed");
expect(errors).toContain("PRIV-LEGAL-OPENAI-DPA: verified external evidence requires externalEvidenceReference");
diff --git a/tests/privacy-ui.test.ts b/tests/privacy-ui.test.ts
index 30770a1761..b7ba106288 100644
--- a/tests/privacy-ui.test.ts
+++ b/tests/privacy-ui.test.ts
@@ -4,7 +4,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
-import PrivacyPage from "@/app/privacy/page";
+import PrivacyPage, { metadata } from "@/app/privacy/page";
import { PrivacyInputNotice } from "@/components/privacy-input-notice";
vi.mock("next/navigation", () => ({
@@ -91,19 +91,32 @@ describe("privacy UI", () => {
expect(markup).toContain("retains attributable citations and retrieval dates");
expect(markup).toContain("not a zero-retention promise");
expect(markup).toContain("production readiness must each be verified");
+ expect(markup).toContain("verified the four expected schedules on production and staging");
+ expect(markup).not.toContain("staging evidence, clinical evaluation");
// The provider section states only what the application itself does. A
// zero-retention or no-training claim is an operator/contractual matter the
// app cannot observe, and docs/openai-cross-border-basis.md still records it
// as unresolved — so it must not appear here.
expect(markup).toContain("a keyed pseudonym is used instead when the operator configures one");
- expect(markup).toContain("not a deletion deadline");
+ expect(markup).toContain("not that maximum or a deletion deadline");
+ expect(markup).toContain("API data sharing and API call logging disabled");
+ expect(markup).toContain("hosted MCP, web search, file search, image generation, code interpreter");
+ expect(markup).toContain("has submitted a Zero Data Retention request");
+ expect(markup).toContain("OpenAI has not yet approved it");
+ expect(markup).toContain("abuse-monitoring logs for up to 30 days");
+ expect(markup).toContain("prompt-cache data may remain on its local GPU machines for up to 24 hours");
+ expect(markup).toContain("has not yet proved that the production key targets the inspected project");
+ expect(markup).toContain("do not establish Zero Data Retention");
+ expect(markup).toContain("hosted-search path is disabled in the inspected provider project");
expect(markup).not.toContain("zero-retention arrangement is in place");
expect(markup).not.toContain("not used for training");
// Status line: describes configured behaviour, never asserts a review.
expect(markup).toContain("Describes configured behaviour as of");
expect(markup).not.toContain("Reviewed on");
+ expect(metadata.description).toContain("current provider controls");
+ expect(metadata.description).toContain("pending privacy approvals");
});
it("exposes every section as a stable anchor so /privacy#retention can be linked", () => {
diff --git a/tests/rag-extractive-procedural-artifact.test.ts b/tests/rag-extractive-procedural-artifact.test.ts
index 1cb30e5628..21a99a4859 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", () => {
diff --git a/tests/registry-service-facets.test.ts b/tests/registry-service-facets.test.ts
index 7cb3b818d6..85e0805d18 100644
--- a/tests/registry-service-facets.test.ts
+++ b/tests/registry-service-facets.test.ts
@@ -14,10 +14,10 @@ function roundTrip(record: ServiceRecord): ServiceRecord {
}
describe("registry service facet payloads", () => {
- it("preserves all six tag dimensions for the 219 default service records", () => {
+ it("preserves all six tag dimensions for the 227 default service records", () => {
const records = defaultServiceRecords();
- expect(records).toHaveLength(219);
+ expect(records).toHaveLength(227);
for (const record of records) {
const row = recordToRow(record, ownerId, "service");
const restored = rowToServiceRecord(row as RegistryRecordRow);
@@ -25,8 +25,17 @@ describe("registry service facet payloads", () => {
expect(row.catalog_payload).toEqual(record.catalogPayload);
// Compare with the raw fixture payload rather than parsing both sides
// through the same helper, which could let a parser regression agree
- // with itself.
- expect(serviceCatalogTags(restored)).toEqual(record.catalogPayload?.tags);
+ // with itself. Facets intentionally project only their six supported
+ // dimensions; governance-only tag metadata remains in catalog_payload.
+ const rawTags = (record.catalogPayload?.tags ?? {}) as Record;
+ expect(serviceCatalogTags(restored)).toEqual({
+ catchments: rawTags.catchments ?? [],
+ age_groups: rawTags.age_groups ?? [],
+ setting_flags: rawTags.setting_flags ?? [],
+ acuity_flags: rawTags.acuity_flags ?? [],
+ substance_flags: rawTags.substance_flags ?? [],
+ housing_flags: rawTags.housing_flags ?? [],
+ });
}
});
diff --git a/tests/repo-awareness-gate.test.ts b/tests/repo-awareness-gate.test.ts
index 352bb5e01f..d66824de4d 100644
--- a/tests/repo-awareness-gate.test.ts
+++ b/tests/repo-awareness-gate.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { compareSnapshots } from "../scripts/check-repo-awareness-snapshot";
+import { checkRepoAwarenessSnapshot, compareSnapshots } from "../scripts/check-repo-awareness-snapshot";
import { generate } from "../scripts/generate-repo-awareness-snapshot";
const regenerated = generate();
@@ -68,3 +68,43 @@ describe("compareSnapshots", () => {
expect(compareSnapshots(committed, regenerated).join(" ")).toMatch(/legacy_section/);
});
});
+
+describe("checkRepoAwarenessSnapshot", () => {
+ it("skips gracefully with exit 0 when git is missing or fails in the environment", () => {
+ const logs: string[] = [];
+ const errors: string[] = [];
+ let exitCode: number | null = null;
+
+ const code = checkRepoAwarenessSnapshot({
+ generateImpl: () => {
+ throw new Error("spawnSync git ENOENT");
+ },
+ log: (msg) => logs.push(msg),
+ error: (msg) => errors.push(msg),
+ exit: (c) => {
+ exitCode = c;
+ },
+ });
+
+ expect(code).toBe(0);
+ expect(exitCode).toBeNull();
+ expect(logs.join(" ")).toContain("git is not available");
+ expect(errors).toHaveLength(0);
+ });
+
+ it("passes with exit 0 when committed snapshot matches generated snapshot", () => {
+ const logs: string[] = [];
+ const errors: string[] = [];
+
+ const code = checkRepoAwarenessSnapshot({
+ generateImpl: () => regenerated,
+ readCommittedImpl: () => structuredClone(regenerated),
+ log: (msg) => logs.push(msg),
+ error: (msg) => errors.push(msg),
+ });
+
+ expect(code).toBe(0);
+ expect(logs.join(" ")).toContain("in step with");
+ expect(errors).toHaveLength(0);
+ });
+});
diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts
index bd42b51719..71e7f96484 100644
--- a/tests/search-route-ownership.test.ts
+++ b/tests/search-route-ownership.test.ts
@@ -440,11 +440,12 @@ describe("shared-search route ownership", () => {
label: "ClinicalDashboard async function ask",
});
- // Smart intent must be intercepted before ordinary namespaced navigation.
- // Everything else still routes to the selected mode's deterministic surface.
+ // Natural-language Smart search uses the same selected-mode route as a
+ // keyword lookup. The shared composer must not divert it to Clinical Ask.
expect(ask).toMatch(
- /const modeDestination = appModeHomeHref\(searchMode, \{[\s\S]*?run: true,[\s\S]*?\}\);[\s\S]*?if \(submitSmartSearch\(trimmedQuery, \(\) => setModeSearchSubmitted\(true\)\)\) return;[\s\S]*?if \(trimmedQuery && !isDashboardModeHref\(modeDestination\)\) \{[\s\S]*?router\.push\(modeDestination\);\n return;/,
+ /const modeDestination = appModeHomeHref\(searchMode, \{[\s\S]*?run: true,[\s\S]*?\}\);[\s\S]*?if \(trimmedQuery && !isDashboardModeHref\(modeDestination\)\) \{[\s\S]*?router\.push\(modeDestination\);\n return;/,
);
+ expect(ask).not.toContain("submitSmartSearch");
});
it("does not treat catalogue search docks as tool-detail footer-search pages", () => {
diff --git a/tests/service-facets.test.ts b/tests/service-facets.test.ts
index 4111148475..8fca36175e 100644
--- a/tests/service-facets.test.ts
+++ b/tests/service-facets.test.ts
@@ -24,7 +24,7 @@ const records = mapCatalogToServiceRecords(loadServicesSnapshot().services);
describe("service facets", () => {
it("carries the full typed tag payload onto every ServiceRecord (the PR C runtime spike)", () => {
- expect(records).toHaveLength(219);
+ expect(records).toHaveLength(227);
for (const record of records) {
expect(record.catalogPayload).toBeTruthy();
const tags = serviceCatalogTags(record);
@@ -43,10 +43,10 @@ describe("service facets", () => {
expect(general + aod).toBe(records.length);
});
- it("finds housing_flags a near-partition, not an exact one — 4 services carry two values", () => {
+ it("finds housing_flags a near-partition, not an exact one — 5 services carry two values", () => {
const counts = records.map((record) => serviceCatalogTags(record).housing_flags.length);
const multi = counts.filter((count) => count > 1).length;
- expect(multi).toBe(4);
+ expect(multi).toBe(5);
expect(counts.every((count) => count >= 1)).toBe(true);
});
diff --git a/tests/services-catalog.test.ts b/tests/services-catalog.test.ts
index 9f769f6052..7eb6a76b6f 100644
--- a/tests/services-catalog.test.ts
+++ b/tests/services-catalog.test.ts
@@ -20,12 +20,12 @@ import {
} from "@/lib/services";
describe("services catalogue", () => {
- it("loads 219 services from snapshot", () => {
+ it("loads the governed 227-service catalogue from snapshot", () => {
const snapshot = loadServicesSnapshot();
- expect(snapshot.service_count).toBe(219);
- expect(snapshot.services).toHaveLength(219);
- expect(serviceRecords).toHaveLength(219);
- expect(loadServiceRecords()).toHaveLength(219);
+ expect(snapshot.service_count).toBe(227);
+ expect(snapshot.services).toHaveLength(227);
+ expect(serviceRecords).toHaveLength(227);
+ expect(loadServiceRecords()).toHaveLength(227);
});
it("maps 13yarn with phone and medium confidence", () => {
@@ -42,19 +42,25 @@ describe("services catalogue", () => {
it("compacts pipe-joined best-use blobs on summary cards", () => {
const snapshot = loadServicesSnapshot();
- const crisisCare = snapshot.services.find((service) => service.canonical_name_key === "crisis-care");
- expect(crisisCare?.best_use_indication?.includes("|")).toBe(true);
- expect(crisisCare!.best_use_indication.length).toBeGreaterThan(140);
+ const adultHomeTreatment = snapshot.services.find(
+ (service) => service.canonical_name_key === "adult-home-treatment-team",
+ );
+ expect(adultHomeTreatment?.best_use_indication?.includes("|")).toBe(true);
+ expect(adultHomeTreatment!.best_use_indication.length).toBeGreaterThan(140);
- const record = catalogToServiceRecord(crisisCare!);
+ const record = catalogToServiceRecord(adultHomeTreatment!);
const bestUseCard = record.summaryCards?.find((card) => card.id === "best-use");
- expect(bestUseCard?.title).toBe("After-hours crisis, homelessness, FDV, child-safety concerns");
+ expect(bestUseCard?.title).toBe(
+ "Intensive daily home treatment as alternative to admission or as step between hospital and community",
+ );
expect(bestUseCard?.title?.length).toBeLessThanOrEqual(120);
expect(bestUseCard?.title?.includes("|")).toBe(false);
expect(record.criteria?.some((criterion) => criterion.label.includes("|"))).toBe(false);
expect(
record.criteria?.some(
- (criterion) => criterion.label === "After-hours crisis, homelessness, FDV, child-safety concerns",
+ (criterion) =>
+ criterion.label ===
+ "Intensive daily home treatment as alternative to admission or as step between hospital and community",
),
).toBe(true);
});
@@ -65,10 +71,10 @@ describe("services catalogue", () => {
(service) => service.canonical_name_key === "community-alcohol-and-drug-services-cads-network",
);
expect(cads?.referral_details.includes("|")).toBe(true);
- expect(cads?.best_use_indication.includes("|")).toBe(true);
+ expect(cads?.best_use_indication.includes("|")).toBe(false);
const record = catalogToServiceRecord(cads!);
- expect(record.route).toBe("Self-referral accepted; clinician referral form available");
+ expect(record.route).toBe("Nearest CADS; self-referral accepted");
expect(record.route).not.toContain("|");
expect(record.eligibility).not.toContain("|");
expect(record.cost).toBe("Free/confidential");
@@ -76,7 +82,7 @@ describe("services catalogue", () => {
expect(record.subtitle).not.toContain("|");
const primaryRoute = record.referralInfo?.find((row) => row.label === "Primary route");
- expect(primaryRoute?.value).toBe("Self-referral accepted; clinician referral form available");
+ expect(primaryRoute?.value).toBe("Nearest CADS; self-referral accepted");
expect(primaryRoute?.value).not.toEqual(cads!.referral_details);
expect(primaryRoute?.value).not.toContain("|");
expect(record.referralInfo?.every((row) => !row.value?.includes("|"))).toBe(true);
@@ -164,11 +170,15 @@ describe("services catalogue", () => {
it("compacts raw best-use fallbacks for stale seeded summary cards", () => {
const snapshot = loadServicesSnapshot();
- const crisisCare = snapshot.services.find((service) => service.canonical_name_key === "crisis-care");
- expect(crisisCare).toBeTruthy();
+ const adultHomeTreatment = snapshot.services.find(
+ (service) => service.canonical_name_key === "adult-home-treatment-team",
+ );
+ expect(adultHomeTreatment).toBeTruthy();
- const compacted = compactBestUseTitle(crisisCare!.best_use_indication);
- expect(compacted).toBe("After-hours crisis, homelessness, FDV, child-safety concerns");
+ const compacted = compactBestUseTitle(adultHomeTreatment!.best_use_indication);
+ expect(compacted).toBe(
+ "Intensive daily home treatment as alternative to admission or as step between hospital and community",
+ );
expect(compacted.includes("|")).toBe(false);
expect(compacted.length).toBeLessThanOrEqual(120);
});
@@ -215,6 +225,17 @@ describe("services catalogue", () => {
expect(searchServiceRecords("13YARN")[0]?.service.slug).toBe("13yarn");
});
+ it("interprets natural-language youth follow-up searches inside the services catalogue", () => {
+ const matches = rankServiceRecords(
+ serviceRecords,
+ "Where can a young person get support after discharge?",
+ serviceRecords.length,
+ [],
+ true,
+ );
+ expect(matches.some(({ service }) => service.slug.includes("youth-community"))).toBe(true);
+ });
+
it("maps representative services into the four browse groups", () => {
const records = loadServiceRecords();
const yarn = records.find((service) => service.slug === "13yarn");
@@ -274,7 +295,7 @@ describe("services catalogue", () => {
it("normalizes raw catalog services consistently", () => {
const snapshot = loadServicesSnapshot();
const normalized = normalizeCatalogServices(snapshot);
- expect(normalized).toHaveLength(219);
+ expect(normalized).toHaveLength(227);
expect(normalized[0]?.id).toMatch(/^S\d{3}$/);
});
});
diff --git a/tests/services-navigator-scope-empty-state.dom.test.tsx b/tests/services-navigator-scope-empty-state.dom.test.tsx
index e878c3ef62..dccdb9fbc8 100644
--- a/tests/services-navigator-scope-empty-state.dom.test.tsx
+++ b/tests/services-navigator-scope-empty-state.dom.test.tsx
@@ -102,9 +102,9 @@ describe("services scope segment vs the query-empty state", () => {
});
it("marks a zero-count care lens as unavailable from the active facet set", () => {
- // The committed catalogue has three General-setting services and none of
- // them are AOD-specific. The AOD lens would therefore be a dead end.
- paramsState.search = "setting_flags=general";
+ // The committed catalogue has home-based services but none of them are
+ // AOD-specific. The AOD lens would therefore be a dead end.
+ paramsState.search = "housing_flags=home_based";
render();
fireEvent.click(screen.getByTestId("service-filter-trigger-desktop"));
diff --git a/tests/services-safety-regressions.test.ts b/tests/services-safety-regressions.test.ts
new file mode 100644
index 0000000000..f0b3e9073c
--- /dev/null
+++ b/tests/services-safety-regressions.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+
+import { catalogToServiceRecord } from "@/lib/service-catalog-mapper";
+import { loadServicesSnapshot, normalizeCatalogService, type CatalogService } from "@/lib/service-catalog";
+
+describe("services safety regressions", () => {
+ it("loads the committed canonical records into the services snapshot", () => {
+ const emergencyServices = loadServicesSnapshot().services.find((service) => service.name === "Emergency services");
+
+ expect(emergencyServices).toBeDefined();
+ expect(emergencyServices?.public_source_urls).toContain(
+ "https://www.mhc.wa.gov.au/getting-help/your-health-and-wellbeing/suicide",
+ );
+ });
+
+ it("retains canonical governance fields when normalising a catalogue record", () => {
+ const service = normalizeCatalogService(
+ {
+ id: "SVC-TEST-001",
+ name: "Governed service",
+ stable_id: "SVC-TEST-001",
+ aliases: ["Governed alias"],
+ tags: { availability_flags: ["active"] },
+ availability_status: "active",
+ evidence_sources: [
+ {
+ sourceId: "SRC-TEST-001",
+ title: "Official source",
+ issuer: "Test issuer",
+ sourceClass: "Tier 1",
+ jurisdiction: "Western Australia",
+ publicationOrEffectiveDate: "2026-08-23",
+ url: "https://example.test/source",
+ accessedAt: "2026-08-23",
+ },
+ ],
+ },
+ 0,
+ );
+
+ expect(service).toMatchObject({
+ stable_id: "SVC-TEST-001",
+ aliases: ["Governed alias"],
+ tags: { availability_flags: ["active"] },
+ availability_status: "active",
+ evidence_sources: [{ sourceId: "SRC-TEST-001" }],
+ });
+ });
+ it("never turns a crisis service into a non-crisis rejection", () => {
+ const yarn = loadServicesSnapshot().services.find((service) => service.canonical_name_key === "13yarn");
+ expect(yarn).toBeDefined();
+
+ const record = catalogToServiceRecord(yarn!);
+ expect(record.criteria?.some((criterion) => criterion.label === "Non-crisis routine referral only")).toBe(false);
+ });
+
+ it("does not call an unsourced record source checked", () => {
+ const yarn = loadServicesSnapshot().services.find((service) => service.canonical_name_key === "13yarn");
+ expect(yarn).toBeDefined();
+
+ const unsourced: CatalogService = {
+ ...yarn!,
+ confidence: "High",
+ public_source_urls: [],
+ verification_flags: [],
+ web_review_status: "",
+ };
+
+ const record = catalogToServiceRecord(unsourced);
+ expect(record.source?.status).not.toBe("Source checked");
+ });
+});
diff --git a/tests/services.test.ts b/tests/services.test.ts
index 55eabc46e1..db50474ebb 100644
--- a/tests/services.test.ts
+++ b/tests/services.test.ts
@@ -12,7 +12,7 @@ import {
describe("service records", () => {
it("loads the full WA services catalogue", () => {
- expect(serviceRecords.length).toBe(219);
+ expect(serviceRecords.length).toBe(227);
expect(serviceRecords.some((service) => service.slug === "13yarn")).toBe(true);
});
diff --git a/tests/session-start-hook.test.ts b/tests/session-start-hook.test.ts
index 89811dd940..faead50258 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 7ed3390ac2..8fc6847a7a 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/shared-home-empty-state.dom.test.tsx b/tests/shared-home-empty-state.dom.test.tsx
index 85d5115fa9..401e8acc4b 100644
--- a/tests/shared-home-empty-state.dom.test.tsx
+++ b/tests/shared-home-empty-state.dom.test.tsx
@@ -80,7 +80,8 @@ const expectedPresentations = [
{
modeId: "calculators",
title: "Clinical Calculators",
- subtitle: "Validated psychiatry scores with the indication, items, and next actions in one place.",
+ subtitle:
+ "Psychiatry assessment and monitoring tools with scoring guidance, limitations, safety prompts, and source-linked clinical considerations.",
iconClass: "lucide-calculator",
},
{
diff --git a/tests/smart-search-intent.test.ts b/tests/smart-search-intent.test.ts
index 0a9e712fe4..85f27d055b 100644
--- a/tests/smart-search-intent.test.ts
+++ b/tests/smart-search-intent.test.ts
@@ -1,52 +1,50 @@
import { describe, expect, it } from "vitest";
import { appModeIds } from "@/lib/app-modes";
-import { clinicalAskModeIds } from "@/lib/clinical-ask/contracts";
-import { resolveSmartSearchSubmissionIntent } from "@/lib/smart-search-intent";
+import { interpretSmartSearch, isSmartNaturalSearchMode, smartNaturalSearchModeIds } from "@/lib/smart-search-intent";
-describe("resolveSmartSearchSubmissionIntent", () => {
- it.each(clinicalAskModeIds)("routes explicit questions to governed Clinical Ask in %s", (mode) => {
- expect(resolveSmartSearchSubmissionIntent(mode, "Which option is best for this presentation?")).toBe(
- "clinical-ask",
- );
- expect(resolveSmartSearchSubmissionIntent(mode, "How should this be approached")).toBe("clinical-ask");
+describe("interpretSmartSearch", () => {
+ it.each(smartNaturalSearchModeIds)("keeps natural-language questions inside %s search", (mode) => {
+ const interpretation = interpretSmartSearch(mode, "Which catalogue result fits this presentation?");
+ expect(interpretation.naturalLanguage).toBe(true);
+ expect(interpretation.originalQuery).toBe("Which catalogue result fits this presentation?");
});
- it.each(clinicalAskModeIds)("routes developed clinical case statements to Clinical Ask in %s", (mode) => {
- expect(resolveSmartSearchSubmissionIntent(mode, "presentation is persistent and worse after discharge")).toBe(
- "clinical-ask",
+ it("adds only mode-scoped catalogue vocabulary", () => {
+ expect(interpretSmartSearch("services", "services for young people after discharge").expansions).toEqual(
+ expect.arrayContaining(["youth", "adolescent", "community", "post-discharge"]),
+ );
+ expect(interpretSmartSearch("forms", "which form extends detention?").expansions).toEqual(
+ expect.arrayContaining(["extension", "detention"]),
+ );
+ expect(interpretSmartSearch("differentials", "causes of hearing voices").expansions).toEqual(
+ expect.arrayContaining(["hallucinations", "psychosis"]),
+ );
+ expect(interpretSmartSearch("formulation", "why do I keep going over it?").expansions).toContain("rumination");
+ expect(interpretSmartSearch("dsm", "diagnoses involving elevated mood").expansions).toEqual(
+ expect.arrayContaining(["mania", "hypomania", "bipolar"]),
+ );
+ expect(interpretSmartSearch("specifiers", "specifier for anxiety symptoms").expansions).toContain(
+ "anxious distress",
+ );
+ expect(interpretSmartSearch("therapy-compass", "therapy for emotion regulation").expansions).toEqual(
+ expect.arrayContaining(["dbt", "dialectical behaviour therapy"]),
);
- expect(
- resolveSmartSearchSubmissionIntent(mode, "consider the most appropriate pathway for ongoing community care"),
- ).toBe("clinical-ask");
});
- it.each(clinicalAskModeIds)("keeps catalogue phrases, codes, commands, and fragments in search for %s", (mode) => {
- for (const query of [
- "cognitive therapy for anxiety",
- "DBT for borderline personality disorder",
- "services for young people",
- "best interests form",
- "form 4A?",
- "find crisis services",
- "look up section 26",
- "community treatment order",
- "Services are available for young people in Perth",
- "Forms are used for community treatment orders",
- "Cognitive therapy is used for anxiety disorders",
- "persistent symptoms after",
- ]) {
- expect(resolveSmartSearchSubmissionIntent(mode, query), query).toBe("search");
- }
+ it("keeps compact catalogue codes literal after terminal punctuation", () => {
+ expect(interpretSmartSearch("forms", "form 4A?")).toMatchObject({ naturalLanguage: false, expansions: [] });
+ expect(interpretSmartSearch("dsm", "F31.81?")).toMatchObject({ naturalLanguage: false, expansions: [] });
});
- it.each(appModeIds.filter((mode) => !clinicalAskModeIds.includes(mode as never)))(
- "never invokes Clinical Ask in unsupported mode %s",
+ it.each(appModeIds.filter((mode) => !smartNaturalSearchModeIds.includes(mode as never)))(
+ "does not advertise interpretation in unsupported mode %s",
(mode) => {
- expect(resolveSmartSearchSubmissionIntent(mode, "Which option is best for this presentation?")).toBe("search");
- expect(resolveSmartSearchSubmissionIntent(mode, "presentation is persistent and worse after discharge")).toBe(
- "search",
- );
+ expect(isSmartNaturalSearchMode(mode)).toBe(false);
+ expect(interpretSmartSearch(mode, "Which option fits this presentation?")).toMatchObject({
+ naturalLanguage: false,
+ expansions: [],
+ });
},
);
});
diff --git a/tests/specifiers-content.test.ts b/tests/specifiers-content.test.ts
index fdfe8cc82b..5858f787d1 100644
--- a/tests/specifiers-content.test.ts
+++ b/tests/specifiers-content.test.ts
@@ -187,6 +187,12 @@ describe("specifiers content catalog", () => {
});
describe("searchSpecifierCatalog", () => {
+ it("interprets natural-language specifier descriptions inside the catalogue", () => {
+ expect(searchSpecifierCatalog("Which specifier describes anxiety symptoms?", {}, true)[0]?.item.label).toContain(
+ "anxious distress",
+ );
+ });
+
it("finds a known specifier by label", () => {
const results = searchSpecifierCatalog("anxious distress");
expect(results.length).toBeGreaterThan(0);
diff --git a/tests/specifiers-search-filters.dom.test.tsx b/tests/specifiers-search-filters.dom.test.tsx
index 8a3aab4bd8..7257c602f6 100644
--- a/tests/specifiers-search-filters.dom.test.tsx
+++ b/tests/specifiers-search-filters.dom.test.tsx
@@ -32,6 +32,17 @@ describe("SpecifiersHomePage filters", () => {
expect(screen.getByRole("link", { name: "Clear search" })).toHaveAttribute("href", "/specifiers/search");
});
+ it("defaults natural-language searches to their interpreted catalogue results", () => {
+ render();
+
+ const catalogue = screen.getByRole("region", { name: "Full specifier catalogue matches" });
+ // Several disorder-specific catalogue rows share the same specifier label; assert the
+ // intended Smart match is present in the default catalogue lane rather than unique.
+ const matches = within(catalogue).getAllByRole("link", { name: /with anxious distress/i });
+ expect(matches.length).toBeGreaterThan(0);
+ expect(matches[0]).toBeVisible();
+ });
+
it("filters the catalogue before applying the 24-item display limit", () => {
const matches = searchSpecifierCatalog("disorder").filter(({ item }) => item.categoryId === "per");
expect(matches.length).toBeGreaterThan(0);
diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts
index cc3cbf011a..bb8c3e1f62 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");
+ });
+});
diff --git a/tests/therapy-ranking.test.ts b/tests/therapy-ranking.test.ts
index 45c6c41f40..929e40956a 100644
--- a/tests/therapy-ranking.test.ts
+++ b/tests/therapy-ranking.test.ts
@@ -35,6 +35,19 @@ const fullTherapyRecords = JSON.parse(
) as Therapy[];
describe("shared Therapy ranker", () => {
+ it("interprets natural-language therapy needs inside catalogue search", () => {
+ expect(
+ searchTherapies(
+ fullTherapyRecords,
+ {
+ ...EMPTY_SEARCH,
+ query: "Which therapy helps with emotion regulation?",
+ },
+ true,
+ )[0]?.slug,
+ ).toBe("dialectical-behaviour-therapy-dbt");
+ });
+
it("uses one normalized scoring contract for catalogue and universal callers", () => {
expect(scoreTherapyCandidate(records[0], "cognitive behavioural therapy")).toBeGreaterThan(
scoreTherapyCandidate(records[1], "cognitive behavioural therapy"),
diff --git a/tests/tools-catalog.test.ts b/tests/tools-catalog.test.ts
index 0b25ae78cb..950ad60d80 100644
--- a/tests/tools-catalog.test.ts
+++ b/tests/tools-catalog.test.ts
@@ -5,6 +5,7 @@ import {
toolCatalogRecords,
toolCatalogRecordsForSession,
} from "../src/lib/tools-catalog";
+import { appModeHomeHref, type AppModeId } from "../src/lib/app-modes";
import { tools as mockupToolFixtures } from "../src/components/tools-page-mockups/tool-fixtures";
describe("tools catalog", () => {
@@ -23,8 +24,19 @@ describe("tools catalog", () => {
}
});
- it("links calculators to the production calculators page", () => {
- expect(toolCatalogRecordById("calculators").href).toBe("/calculators");
+ it("links shared-home tools directly to their canonical mode homes", () => {
+ const sharedHomeTools = [
+ ["differentials", "differentials"],
+ ["clinical-dictionary", "dictionary"],
+ ["services", "services"],
+ ["forms", "forms"],
+ ["calculators", "calculators"],
+ ] as const satisfies readonly (readonly [Parameters[0], AppModeId])[];
+
+ for (const [toolId, modeId] of sharedHomeTools) {
+ expect(toolCatalogRecordById(toolId).href).toBe(appModeHomeHref(modeId));
+ expect(toolCatalogRecordById(toolId).href).toBe(`/?mode=${modeId}`);
+ }
});
// Ward Flow is deliberately absent from this catalogue — see
diff --git a/tests/typecheck-config-isolation.test.ts b/tests/typecheck-config-isolation.test.ts
new file mode 100644
index 0000000000..0313b7b5c7
--- /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/tests/ui-clinical-ask.spec.ts b/tests/ui-clinical-ask.spec.ts
index 525873f36c..f475964f2b 100644
--- a/tests/ui-clinical-ask.spec.ts
+++ b/tests/ui-clinical-ask.spec.ts
@@ -1,70 +1,22 @@
import AxeBuilder from "@axe-core/playwright";
-import { expect, test, type Page, type Route } from "playwright/test";
+import { expect, test, type Page } from "playwright/test";
import { visibleByTestId } from "./playwright-settlement";
-const clinicalAskEnabled = process.env.CLINICAL_ASK_ENABLED === "true";
-const governedModes = [
- ["services", "Services"],
- ["forms", "Forms"],
- ["differentials", "Differentials"],
- ["formulation", "Formulation"],
- ["dsm", "DSM-5 Diagnosis"],
- ["specifiers", "Specifiers"],
- ["therapy-compass", "Therapy"],
+const smartModes = [
+ ["services", "/services/search", "Where can a young person get support after discharge?"],
+ ["forms", "/forms/search", "Which form extends detention?"],
+ ["differentials", "/differentials/search", "What can cause hearing voices?"],
+ ["formulation", "/formulation/search", "Why do I keep going over it?"],
+ ["dsm", "/dsm/search", "Which diagnoses involve elevated mood?"],
+ ["specifiers", "/specifiers/search", "Which specifier describes anxiety symptoms?"],
+ ["therapy-compass", "/therapy-compass/search", "Which therapy helps with emotion regulation?"],
] as const;
function composer(page: Page) {
return visibleByTestId(page, "global-search-input");
}
-function finalFrame(response: Record) {
- return `event: final\ndata: ${JSON.stringify({ type: "final", payload: { response, feedback: null } })}\n\n`;
-}
-
-function answered(mode: (typeof governedModes)[number][0], label: string) {
- return {
- state: "answered",
- mode,
- lead: { id: "lead", text: `Synthetic governed answer for ${label}`, evidenceIds: ["e1"] },
- sections: [
- {
- id: "section",
- title: "Supported option",
- claims: [{ id: "claim", text: "Review the synthetic pathway.", evidenceIds: ["e1"] }],
- },
- ],
- evidence: [
- {
- id: "e1",
- tier: "catalogue",
- title: "Synthetic authority source",
- publisher: "Synthetic authority",
- jurisdiction: null,
- href: "/synthetic-source",
- extract: "Synthetic governed extract.",
- reviewState: "reviewed",
- publishedAt: null,
- updatedAt: null,
- retrievedAt: null,
- },
- ],
- conflicts: [],
- missingInformation: [],
- followUps: [],
- handoffs: [],
- };
-}
-
-async function fulfillSse(route: Route, response: Record) {
- await route.fulfill({
- status: 200,
- contentType: "text/event-stream; charset=utf-8",
- headers: { "cache-control": "no-store" },
- body: finalFrame(response),
- });
-}
-
test.beforeEach(async ({ page }) => {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url());
@@ -77,168 +29,60 @@ test.beforeEach(async ({ page }) => {
});
});
-test("@critical keeps dormant deployments in ordinary search without a Smart promise", async ({ page }) => {
- test.skip(clinicalAskEnabled, "Default-flag proof runs with Clinical Ask disabled.");
- await page.goto("/?mode=services");
- const input = composer(page);
- await expect(input).toBeVisible();
- await input.fill("Which service is best for ongoing support after discharge?");
- await expect(page.getByTestId("smart-search-intent-cue")).toHaveCount(0);
- await expect(page.getByTestId("smart-search-rotating-text")).toHaveCount(0);
- await input.press("Enter");
- await expect(page).toHaveURL(/\/services\/search\?.*run=1/);
-});
-
-test("@critical routes governed Smart questions in all seven modes without URL persistence", async ({ page }) => {
- test.skip(!clinicalAskEnabled, "Enabled Smart proof requires CLINICAL_ASK_ENABLED=true.");
+test("@critical keeps natural-language Smart search inside all seven selected modes", async ({ page }) => {
+ let clinicalAskRequests = 0;
await page.route("**/api/clinical-ask/stream", async (route) => {
- const request = route.request().postDataJSON() as { mode: (typeof governedModes)[number][0] };
- const label = governedModes.find(([mode]) => mode === request.mode)?.[1] ?? request.mode;
- await fulfillSse(route, answered(request.mode, label));
+ clinicalAskRequests += 1;
+ await route.abort("blockedbyclient");
});
- for (const [mode, label] of governedModes) {
+ for (const [mode, pathname, query] of smartModes) {
await page.goto(`/?mode=${mode}`);
const input = composer(page);
await expect(input).toBeVisible();
- await input.fill("Which option is best for ongoing support after discharge?");
- await expect(page.getByTestId("smart-search-intent-cue")).toBeVisible();
+ await input.fill(query);
+ await expect(page.getByTestId("smart-search-intent-cue")).toContainText("Smart search");
+ await expect(page.getByRole("button", { name: "Get Smart answer" })).toHaveCount(0);
await input.press("Enter");
- await expect(page.getByRole("heading", { name: `Synthetic governed answer for ${label}` })).toBeVisible();
- const url = new URL(page.url());
- expect(url.searchParams.has("q")).toBe(false);
- expect(url.searchParams.has("query")).toBe(false);
- expect(url.searchParams.has("run")).toBe(false);
+
+ await expect(page).toHaveURL((url) => {
+ return url.pathname === pathname && url.searchParams.get("q") === query && url.searchParams.get("run") === "1";
+ });
+ await expect(page.locator(".search-band-subject:visible")).toContainText(query);
}
+
+ expect(clinicalAskRequests).toBe(0);
});
-test("@critical keeps lookup commands and punctuation-terminated form codes in ordinary search", async ({ page }) => {
- test.skip(!clinicalAskEnabled, "Enabled Smart proof requires CLINICAL_ASK_ENABLED=true.");
+test("@critical keeps compact codes literal and uses the ordinary Forms result route", async ({ page }) => {
+ let clinicalAskRequests = 0;
+ await page.route("**/api/clinical-ask/stream", async (route) => {
+ clinicalAskRequests += 1;
+ await route.abort("blockedbyclient");
+ });
+
await page.goto("/?mode=forms");
const input = composer(page);
await input.fill("form 4A?");
await expect(page.getByTestId("smart-search-intent-cue")).toHaveCount(0);
await input.press("Enter");
- await expect(page).toHaveURL(/\/forms\/search\?.*run=1/);
-});
-
-test("@critical retains offline questions only in tab memory and returns to an empty focused search", async ({
- page,
-}) => {
- test.skip(!clinicalAskEnabled, "Enabled Smart proof requires CLINICAL_ASK_ENABLED=true.");
- await page.goto("/?mode=services");
- const input = composer(page);
- const question = "Which service is best for ongoing support after discharge?";
- const historyLength = await page.evaluate(() => history.length);
- await page.context().setOffline(true);
- await page.evaluate(() => window.dispatchEvent(new Event("offline")));
- await input.fill(question);
- await input.press("Enter");
-
- await expect(page.getByRole("heading", { name: "Clinical Ask could not complete" })).toBeVisible();
- expect(new URL(page.url()).searchParams.has("q")).toBe(false);
- expect(await page.evaluate(() => history.length)).toBe(historyLength);
- expect(
- await page.evaluate(
- (rawQuestion) => Object.values(localStorage).some((value) => value.includes(rawQuestion)),
- question,
- ),
- ).toBe(false);
-
- await page.getByRole("button", { name: "Return to search" }).click();
- await page.context().setOffline(false);
- await expect(input).toBeFocused();
- await expect(input).toHaveValue("");
- expect(new URL(page.url()).searchParams.has("q")).toBe(false);
-});
-
-test("@critical keeps mode-unavailable questions private and never offers an automatic fallback", async ({ page }) => {
- test.skip(!clinicalAskEnabled, "Enabled Smart proof requires CLINICAL_ASK_ENABLED=true.");
- await page.route("**/api/clinical-ask/stream", (route) =>
- route.fulfill({
- status: 200,
- contentType: "text/event-stream; charset=utf-8",
- body: 'event: error\ndata: {"type":"error","code":"mode_unavailable","retryable":false,"message":"Smart answers are not available for this mode."}\n\n',
- }),
- );
- await page.goto("/?mode=services");
- const input = composer(page);
- const question = "Which service is best for ongoing support after discharge?";
- const historyLength = await page.evaluate(() => history.length);
- await input.fill(question);
- await input.press("Enter");
-
- await expect(page.getByText("Smart answers are not available for this mode.")).toBeVisible();
- await expect(page.getByRole("button", { name: "Retry Smart answer" })).toHaveCount(0);
- await expect(input).toHaveValue(question);
- expect(new URL(page.url()).searchParams.has("q")).toBe(false);
- expect(await page.evaluate(() => history.length)).toBe(historyLength);
- expect(
- await page.evaluate(
- (rawQuestion) => Object.values(localStorage).some((value) => value.includes(rawQuestion)),
- question,
- ),
- ).toBe(false);
-});
-
-test("@critical retries explicitly and continues only after required clarification", async ({ page }) => {
- test.skip(!clinicalAskEnabled, "Enabled Smart proof requires CLINICAL_ASK_ENABLED=true.");
- let requestCount = 0;
- const requestBodies: Array<{ clarificationAnswers?: Record }> = [];
- await page.route("**/api/clinical-ask/stream", async (route) => {
- requestCount += 1;
- requestBodies.push(route.request().postDataJSON());
- if (requestCount === 1) {
- await route.fulfill({
- status: 200,
- contentType: "text/event-stream; charset=utf-8",
- body: 'event: error\ndata: {"type":"error","code":"provider_unavailable","retryable":true,"message":"Synthetic retry."}\n\n',
- });
- return;
- }
- if (requestCount === 2) {
- await fulfillSse(route, {
- state: "clarification_required",
- mode: "services",
- suggestions: [],
- clarifications: [
- {
- id: "services:careSetting",
- field: "careSetting",
- prompt: "Which care setting is relevant?",
- required: true,
- },
- ],
- });
- return;
- }
- await fulfillSse(route, answered("services", "Services"));
+ await expect(page).toHaveURL((url) => {
+ return url.pathname === "/forms/search" && url.searchParams.get("q") === "form 4A?";
});
+ expect(clinicalAskRequests).toBe(0);
+});
- await page.goto("/?mode=services");
+test("@critical keeps unsupported modes free of a Smart promise", async ({ page }) => {
+ await page.goto("/?mode=documents");
const input = composer(page);
- await input.fill("Which service is best for ongoing support after discharge?");
- await input.press("Enter");
- await page.getByRole("button", { name: "Retry Smart answer" }).click();
-
- const clarification = page.getByRole("textbox", { name: "Which care setting is relevant?" });
- const continueButton = page.getByRole("button", { name: "Continue with confirmed context" });
- await expect(clarification).toBeFocused();
- await expect(continueButton).toBeDisabled();
- await clarification.fill("community");
- await expect(continueButton).toBeEnabled();
- await continueButton.click();
- await expect(page.getByRole("heading", { name: "Synthetic governed answer for Services" })).toBeVisible();
- expect(requestBodies[2]?.clarificationAnswers).toEqual({ "services:careSetting": "community" });
+ await input.fill("Which document should I read for this presentation?");
+ await expect(page.getByTestId("smart-search-intent-cue")).toHaveCount(0);
+ await expect(page.getByTestId("smart-search-rotating-text")).toHaveCount(0);
});
-test("@critical keeps the Smart workspace accessible and within required viewports", async ({ page }, testInfo) => {
- test.skip(!clinicalAskEnabled, "Enabled Smart proof requires CLINICAL_ASK_ENABLED=true.");
- await page.route("**/api/clinical-ask/stream", (route) =>
- fulfillSse(route, answered("differentials", "Differentials")),
- );
- for (const width of [320, 390, 768, 1440]) {
- await page.setViewportSize({ width, height: width < 768 ? 844 : 900 });
+test("@critical keeps the one-composer Smart cue accessible across phone and desktop", async ({ page }, testInfo) => {
+ for (const width of [320, 1440]) {
+ await page.setViewportSize({ width, height: width === 320 ? 844 : 900 });
await page.emulateMedia({
colorScheme: "dark",
reducedMotion: "reduce",
@@ -246,13 +90,14 @@ test("@critical keeps the Smart workspace accessible and within required viewpor
});
await page.goto("/?mode=differentials");
const input = composer(page);
- await input.fill("Which differential is best supported by this persistent presentation?");
- await input.press("Enter");
- await expect(page.getByRole("heading", { name: "Synthetic governed answer for Differentials" })).toBeVisible();
- const overflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth);
- expect(overflow).toBeLessThanOrEqual(2);
+ await input.fill("What can cause hearing voices?");
+ await expect(page.getByText("Smart search selected for Differentials.")).toHaveCount(1);
+ if (width >= 640) await expect(page.getByTestId("smart-search-intent-cue")).toBeVisible();
+ await expect(page.locator('[data-testid="global-search-input"]:visible')).toHaveCount(1);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(2);
+
const axe = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
- await testInfo.attach(`smart-axe-${width}`, {
+ await testInfo.attach(`smart-search-axe-${width}`, {
body: JSON.stringify(axe.violations),
contentType: "application/json",
});
diff --git a/tests/ui-copy.test.ts b/tests/ui-copy.test.ts
index 2d6c194c27..6864918552 100644
--- a/tests/ui-copy.test.ts
+++ b/tests/ui-copy.test.ts
@@ -87,7 +87,8 @@ const EXPECTED_MODE_TITLES: Record<
calculators: {
sharedTitle: "Clinical Calculators",
standaloneTitle: "Clinical Calculators",
- subtitle: "Validated psychiatry scores with the indication, items, and next actions in one place.",
+ subtitle:
+ "Psychiatry assessment and monitoring tools with scoring guidance, limitations, safety prompts, and source-linked clinical considerations.",
},
"therapy-compass": {
sharedTitle: "Therapy",
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index f30484ed5e..10a6d87ebe 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -1063,7 +1063,10 @@ async function expectAccountProviderLayout(setup: Locator, layout: "row" | "stac
expect(boxes.every(Boolean)).toBe(true);
const [apple, google, microsoft] = boxes as NonNullable<(typeof boxes)[number]>[];
- expect(boxes.every((box) => box!.height >= 48)).toBe(true);
+ // Chromium can report a CSS-enforced 48px minimum as 47.999… after layout
+ // rounding. Keep the clinical touch-target contract while ignoring that
+ // sub-hundredth-pixel measurement noise.
+ expect(boxes.every((box) => box!.height >= 47.99)).toBe(true);
if (layout === "row") {
expect(Math.max(apple.y, google.y, microsoft.y) - Math.min(apple.y, google.y, microsoft.y)).toBeLessThanOrEqual(1);
expect(apple.x + apple.width).toBeLessThanOrEqual(google.x);
@@ -2033,7 +2036,12 @@ test.describe("PsychSift UI smoke coverage", () => {
await firstRailRow.click();
const sourceDrawer = page.getByTestId("answer-source-drawer");
await expect(sourceDrawer).toBeVisible();
- await expect(sourceDrawer.getByTestId("answer-source-drawer-support")).toContainText("Opened from the source list");
+ // Opened from a rail card rather than a claim, so the drawer makes no
+ // support statement at all — it used to spend its first line saying there
+ // was no claim, above a front-page thumbnail of a page it was not showing.
+ await expect(sourceDrawer.getByTestId("answer-source-drawer-support")).toHaveCount(0);
+ await expect(sourceDrawer.getByTestId("answer-source-drawer-cover")).toHaveCount(0);
+ await expect(sourceDrawer.getByTestId("answer-source-drawer-passage")).toBeVisible();
await expect(page.getByRole("dialog", { name: /PDF|document/i })).toHaveCount(0);
// Paging is the drawer's whole navigation model; at two sources it is numbered.
const pager = sourceDrawer.getByTestId("answer-source-drawer-pager");
@@ -2051,7 +2059,7 @@ test.describe("PsychSift UI smoke coverage", () => {
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,15 +2070,17 @@ 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.
- 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.getByTestId("answer-clinical-notes-trigger")).toHaveCount(0);
- await expect(supportCard.getByTestId("answer-evidence-trigger")).toHaveCount(0);
+ // The support card was removed on 2026-08-31 (owner decision): the status it
+ // carried is now a chip line above the prose, and the safety chip below is
+ // the route it used to own. What this block protected — that the answer's
+ // state is stated on screen, and that the old per-source sheets stay gone —
+ // is asserted against the chip instead.
+ const supportChip = page.getByTestId("answer-card-support");
+ await expect(supportChip).toBeVisible();
+ await expect(supportChip).toContainText(/support/i);
+ await expect(page.getByTestId("answer-support-card")).toHaveCount(0);
+ await expect(page.getByTestId("answer-clinical-notes-trigger")).toHaveCount(0);
+ await expect(page.getByTestId("answer-evidence-trigger")).toHaveCount(0);
await expect(page.getByTestId("safety-findings-panel")).toHaveCount(0);
// Safety findings are MANDATORY for this clozapine fixture — the answer is saturated
@@ -2091,6 +2101,50 @@ test.describe("PsychSift UI smoke coverage", () => {
await expect(safetyFindingsSheet).toHaveCount(0);
await expect(safetyFindingsTrigger).toBeFocused();
+ // The status chips carry a real 48px tap target inside a 24px-tall pill. The
+ // first shape did that with `-my-3`, which keeps `boundingBox()` honest while
+ // moving the hit region outside the element's layout box — so the chip
+ // quietly sat on top of its neighbours. Measured here before the fix: the
+ // safety chip covered a 133x9px band of the support chip and a 133x2px band
+ // of the prose, and a tap in either band opened the chip. Geometry is the
+ // only thing that catches this — every DOM assertion above passes with the
+ // overlap in place — and 320px is the width where the chip row wraps.
+ for (const statusWidth of [390, 320]) {
+ await page.setViewportSize({ width: statusWidth, height: 820 });
+ await expect(safetyFindingsTrigger).toBeVisible();
+ await expectMinTouchTarget(safetyFindingsTrigger);
+ const collisions = await page.evaluate(() => {
+ const box = (selector: string) => {
+ const node = document.querySelector(selector);
+ return node ? { id: selector, ...node.getBoundingClientRect().toJSON() } : null;
+ };
+ const chips = [
+ box('[data-testid="answer-safety-findings-trigger"]'),
+ box('[data-testid="answer-evidence-gaps-trigger"]'),
+ ].filter((entry) => entry !== null);
+ const neighbours = [
+ box('[data-testid="answer-card-support"]'),
+ box('[data-testid="plain-answer-prose"]'),
+ ...chips,
+ ].filter((entry) => entry !== null);
+ const overlaps: string[] = [];
+ for (const chip of chips) {
+ for (const other of neighbours) {
+ if (other.id === chip.id) continue;
+ const width = Math.min(chip.right, other.right) - Math.max(chip.left, other.left);
+ const height = Math.min(chip.bottom, other.bottom) - Math.max(chip.top, other.top);
+ // Sub-pixel rounding, not a real collision.
+ if (width > 0.5 && height > 0.5) {
+ overlaps.push(`${chip.id} over ${other.id} by ${width.toFixed(1)}x${height.toFixed(1)}`);
+ }
+ }
+ }
+ return overlaps;
+ });
+ expect(collisions, `status chip hit regions overlap at ${statusWidth}px`).toEqual([]);
+ }
+ await page.setViewportSize({ width: 390, height: 820 });
+
// Decision 2 (2026-08-24): tables fold into the source drawer, so they are no
// longer on the answer surface at all — reaching one goes through a rail row.
await expect(page.getByLabel("Inline table preview")).toHaveCount(0);
@@ -2152,33 +2206,45 @@ test.describe("PsychSift UI smoke coverage", () => {
await expect(page.getByTestId("mobile-evidence-tabs")).toHaveCount(0);
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.
+ // Document order on the answer surface: question, the status chip line, the
+ // prose, the rail that cites it, the quiet utilities, then the governed
+ // caution. The caution moved below the answer on 2026-08-31 (owner
+ // decision); pinning it here is what stops it drifting back up or, worse,
+ // being dropped when a call site takes placement from AnswerCard.
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 support = document.querySelector('[data-testid="answer-support-card"]');
+ const utilities = document.querySelector('section[aria-label="Answer utilities"]');
+ const support = document.querySelector('[data-testid="answer-card-support"]');
+ const caution = document.querySelector('[data-testid="verification-notice"]');
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,
+ cautionTop: caution?.getBoundingClientRect().top ?? 9999,
+ cautionPresent: Boolean(caution),
};
});
- expect(hierarchy.questionTop).toBeLessThan(hierarchy.plainAnswerTop);
+ expect(hierarchy.cautionPresent).toBe(true);
+ expect(hierarchy.questionTop).toBeLessThan(hierarchy.supportTop);
+ expect(hierarchy.supportTop).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.cautionTop);
- // 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 +2309,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 +2401,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");
@@ -2405,6 +2470,47 @@ test.describe("PsychSift UI smoke coverage", () => {
});
}
+ test("privacy sticky chrome is an opaque bar on phones and glass from sm", async ({ page }) => {
+ // Proven in a browser, not by class presence: tailwind-merge keeps both the
+ // base and the `sm:` utility, so stylesheet order — not the class list —
+ // decides which one wins, and jsdom cannot resolve either.
+ //
+ // The phone case is the one that matters. This band carried translucent
+ // glass at every width, so scrolled content ghosted through the title; it
+ // was visible against the amber obligation band, whose fill read straight
+ // through "Data handling". The repo's own header rule for phones
+ // (`.edge-glass-header` / `.universal-header` in globals.css) is an opaque
+ // bar with no blur, and this page now follows it.
+ const chrome = page.getByTestId("privacy-sticky-chrome");
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/privacy", { waitUntil: "domcontentloaded" });
+ await expect(chrome).toBeVisible();
+
+ const phone = await chrome.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return { background: style.backgroundColor, blur: style.backdropFilter };
+ });
+ // Fully opaque: no alpha channel at all, so nothing can read through.
+ expect(phone.background).not.toMatch(/\/\s*0?\.\d+|rgba?\([^)]*,\s*0?\.\d+\s*\)/);
+ expect(phone.blur).toBe("none");
+
+ // Scrolled, the band must actually hide what passes behind it.
+ await page.evaluate(() => window.scrollTo(0, 600));
+ await expect(chrome).toBeVisible();
+ await expect(page.getByRole("heading", { level: 1, name: /handles your data/i })).not.toBeInViewport();
+
+ // From sm the shared glass treatment is correct again.
+ await page.setViewportSize({ width: 900, height: 900 });
+ await page.goto("/privacy", { waitUntil: "domcontentloaded" });
+ const wide = await chrome.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return { background: style.backgroundColor, blur: style.backdropFilter };
+ });
+ expect(wide.blur).toContain("blur(");
+ expect(wide.background).not.toBe(phone.background);
+ });
+
test("privacy trust brief remains operable with reduced motion and forced colours", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" });
@@ -2718,9 +2824,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");
@@ -2859,7 +2964,16 @@ test.describe("PsychSift UI smoke coverage", () => {
await scrollPrimarySurface(page, liveEndpoint);
await expect(header).not.toHaveAttribute("data-scroll-hidden", "true");
await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true");
- const endpoint = await relatedItems.last().evaluate((item) => {
+ // Measure the LAST thing in the runway, which is the follow-up rows — not
+ // a library listitem. The library line is collapsed by the block above, so
+ // its listitems are `display:none` and expose no element to measure; this
+ // branch only ever ran on a taller answer before the support card was
+ // removed, which is why the mismatch went unseen. What the assertion is
+ // for is unchanged: the last content clears the dock rather than sitting
+ // under it.
+ const runwayEnd = page.getByTestId("answer-follow-up-suggestions").getByRole("button").last();
+ await expect(runwayEnd).toBeVisible();
+ const endpoint = await runwayEnd.evaluate((item) => {
const dockNode = document.querySelector("form.answer-footer-search-dock");
if (!dockNode) throw new Error("Expected phone answer dock");
return {
@@ -3047,9 +3161,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 });
@@ -3231,10 +3345,13 @@ test.describe("PsychSift UI smoke coverage", () => {
// The compact disclosure now deliberately carries the 40px compact-meta
// interaction floor. Its bordered container is 42px high in Chromium, so
// preserve both the usable target and the compact one-row layout.
- expect(disclosureButtonBox!.height).toBeGreaterThanOrEqual(40);
+ // Tolerate sub-pixel rounding (CI saw 39.999969482421875 for a 40px target).
+ expect(disclosureButtonBox!.height).toBeGreaterThanOrEqual(39.5);
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 +3360,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();
@@ -3258,16 +3377,22 @@ test.describe("PsychSift UI smoke coverage", () => {
await expect(sourceOnlyDisclosure).toBeVisible();
const responsiveDisclosureButtonBox = await sourceOnlyButton.boundingBox();
expect(responsiveDisclosureButtonBox).not.toBeNull();
- expect(responsiveDisclosureButtonBox!.height).toBeGreaterThanOrEqual(40);
+ // Same 40px compact-meta floor with sub-pixel tolerance as above.
+ expect(responsiveDisclosureButtonBox!.height).toBeGreaterThanOrEqual(39.5);
await expectNoPageHorizontalOverflow(page);
}
- const supportCard = page.getByTestId("answer-support-card");
- await expect(supportCard).toBeVisible();
- await expect(supportCard).toContainText("Review source match");
- await expect(supportCard).toContainText("Verify cited passages");
- await expect(supportCard.getByTestId("answer-clinical-notes-trigger")).toHaveCount(0);
- await expect(supportCard.getByTestId("answer-evidence-trigger")).toHaveCount(0);
+ // The "Review source match" card is gone with the support card. The caution
+ // it restated is not: the source-only disclosure above already carries the
+ // governed wording ("verify passages", asserted earlier in this test), and
+ // the chip states the degraded support level rather than reading like a
+ // fully supported answer.
+ const sourceOnlySupportChip = page.getByTestId("answer-card-support");
+ await expect(sourceOnlySupportChip).toBeVisible();
+ await expect(sourceOnlySupportChip).toHaveAttribute("data-support", /limited|unassessed/);
+ await expect(page.getByTestId("answer-support-card")).toHaveCount(0);
+ await expect(page.getByTestId("answer-clinical-notes-trigger")).toHaveCount(0);
+ await expect(page.getByTestId("answer-evidence-trigger")).toHaveCount(0);
// A source-only answer still cites real documents, so the rail must list them
// and the drawer must open — the degraded path is exactly where a clinician
@@ -3283,13 +3408,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 +3437,73 @@ 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);
+ // Reading boundingBox() immediately after a resize can race the
+ // reflow — Chromium sometimes reports one sibling's box mid-transition
+ // (seen ~10px short) while the other has already settled. Two rAF
+ // round-trips let layout finish before we measure.
+ await page.evaluate(
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
+ );
+ const statusBox = await statusRow.boundingBox();
+ const sourceOnlyBox = await sourceOnlyDisclosure.boundingBox();
+ const reviewDueBox = await reviewDueTab.boundingBox();
+ expect(statusBox).toBeTruthy();
+ expect(sourceOnlyBox).toBeTruthy();
+ expect(reviewDueBox).toBeTruthy();
+ // The controls have deliberately different touch-target densities, so
+ // their top edges and centres may differ. Both must still be contained
+ // by the single compact status row rather than wrapping onto a second
+ // line.
+ const statusBottom = statusBox!.y + statusBox!.height;
+ expect(sourceOnlyBox!.y).toBeGreaterThanOrEqual(statusBox!.y - 1);
+ expect(sourceOnlyBox!.y + sourceOnlyBox!.height).toBeLessThanOrEqual(statusBottom + 1);
+ expect(reviewDueBox!.y).toBeGreaterThanOrEqual(statusBox!.y - 1);
+ expect(reviewDueBox!.y + reviewDueBox!.height).toBeLessThanOrEqual(statusBottom + 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);
@@ -3349,8 +3527,7 @@ test.describe("PsychSift UI smoke coverage", () => {
const plainAnswer = page.getByTestId("plain-answer-response");
await expect(plainAnswer).toBeVisible();
- const supportCard = page.getByTestId("answer-support-card");
- await expect(supportCard).toBeVisible();
+ await expect(page.getByTestId("answer-card-support")).toBeVisible();
await expectNoPageHorizontalOverflow(page);
// One source surface at every breakpoint: the rail on the page, the drawer
@@ -3367,7 +3544,10 @@ test.describe("PsychSift UI smoke coverage", () => {
await railRow.click();
const drawer = page.getByTestId("answer-source-drawer");
await expect(drawer).toBeVisible();
- await expect(drawer.getByTestId("answer-source-drawer-support")).toBeVisible();
+ // No claim opened this drawer, so it carries no support sentence; the
+ // passage and the route to the PDF are what it must show.
+ await expect(drawer.getByTestId("answer-source-drawer-support")).toHaveCount(0);
+ await expect(drawer.getByTestId("answer-source-drawer-passage")).toBeVisible();
await expectMinTouchTarget(drawer.getByRole("link", { name: "View original PDF" }));
const drawerPager = drawer.getByTestId("answer-source-drawer-pager");
if (await drawerPager.count()) {
diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts
index e45ae7b4cf..f1a1c13075 100644
--- a/tests/ui-specifiers.spec.ts
+++ b/tests/ui-specifiers.spec.ts
@@ -132,6 +132,14 @@ test("searches clinical language without provenance fields and carries a result
await expect(queryRibbon.getByRole("heading", { level: 1, name: "depressed but racing thoughts" })).toBeVisible();
await expect(queryRibbon.getByRole("group", { name: "Filter specifier results" })).toBeVisible();
await expect(page.getByText(/Results ranked by text relevance/i)).toHaveCount(0);
+ // Smart natural-language queries default to the full catalogue lane. This journey
+ // asserts curated guide cards (Top match → wording), so pin Search in → guides first.
+ {
+ const guidesUrl = new URL(page.url());
+ guidesUrl.searchParams.set("scope", "guides");
+ await page.goto(guidesUrl.toString());
+ }
+ await expect(page).toHaveURL(/scope=guides/);
await expect(page.getByText("Top match", { exact: true })).toBeVisible();
const filterTrigger = page.getByTestId("specifier-filter-trigger-desktop");
await filterTrigger.click();
diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts
index a0a6c37c3e..e87977625a 100644
--- a/tests/ui-stress.spec.ts
+++ b/tests/ui-stress.spec.ts
@@ -427,7 +427,10 @@ test.describe("PsychSift long-content stress coverage", () => {
await sourceRail.getByTestId("answer-source-rail-row").first().click();
const sourceDrawer = page.getByTestId("answer-source-drawer");
await expect(sourceDrawer).toBeVisible();
- await expect(sourceDrawer.getByTestId("answer-source-drawer-support")).toBeVisible();
+ // Opened from a rail card, so no support sentence: the passage is the
+ // panel's first content at every one of these widths.
+ await expect(sourceDrawer.getByTestId("answer-source-drawer-support")).toHaveCount(0);
+ await expect(sourceDrawer.getByTestId("answer-source-drawer-passage")).toBeVisible();
await expectNoPageHorizontalOverflow(page);
await page.keyboard.press("Escape");
await expect(sourceDrawer).toHaveCount(0);
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 5b6d5fdcc0..d7623915a2 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -567,7 +567,10 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
await expect(results.getByRole("heading", { level: 1, name: "Compare" })).toBeVisible();
await expect(results.getByText("2 tools", { exact: true })).toBeVisible();
await expect(results.getByRole("heading", { level: 2, name: "Differentials" }).first()).toBeVisible();
- await expect(results.getByRole("link", { name: "Open Differentials" })).toHaveAttribute("href", "/differentials");
+ await expect(results.getByRole("link", { name: "Open Differentials" })).toHaveAttribute(
+ "href",
+ "/?mode=differentials",
+ );
await expect(results.getByRole("heading", { level: 2, name: "Clinical Dictionary" }).first()).toBeVisible();
await expect(results.getByRole("complementary", { name: "Differentials" })).toBeVisible();
@@ -612,7 +615,10 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
await expect(filterSheet.getByTestId("tools-search-filter-sheet-done")).toHaveText(/View 2 tools/);
await filterSheet.getByTestId("tools-search-filter-sheet-done").click();
- await expect(results.getByRole("link", { name: "Open Differentials" })).toHaveAttribute("href", "/differentials");
+ await expect(results.getByRole("link", { name: "Open Differentials" })).toHaveAttribute(
+ "href",
+ "/?mode=differentials",
+ );
const details = results.getByRole("button", { name: "View details for Differentials" });
await details.click();
const detailSheet = page.locator('[data-testid="tools-search-detail-sheet"]:visible');
@@ -621,13 +627,36 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
await expect(detailSheet.getByRole("heading", { name: "Best for" })).toBeVisible();
await expect(detailSheet.getByRole("link", { name: "Compare Differentials" })).toHaveAttribute(
"href",
- "/differentials",
+ "/?mode=differentials",
);
await detailSheet.getByRole("button", { name: "Close Differentials" }).click();
await expect(details).toBeFocused();
await expectNoPageHorizontalOverflow(page);
});
+ test("phone Tools launches every shared-home mode without a redirect hop or stale layout", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const launches = [
+ { link: "Open Differentials", mode: "differentials", heading: "Differential Diagnosis" },
+ { link: "Open Clinical Dictionary", mode: "dictionary", heading: "Clinical Dictionary" },
+ { link: "Open Services", mode: "services", heading: "Clinical Services" },
+ { link: "Open Forms", mode: "forms", heading: "Clinical Forms" },
+ { link: "Open Calculators", mode: "calculators", heading: "Clinical Calculators" },
+ ] as const;
+
+ for (const launch of launches) {
+ await mockAnswerDashboardApi(page);
+ await gotoLauncher(page, "/tools");
+ const link = visibleByTestId(page, "tools-search-results-page").getByRole("link", { name: launch.link });
+ await expect(link).toHaveAttribute("href", `/?mode=${launch.mode}`);
+ await Promise.all([page.waitForURL(`**/?mode=${launch.mode}`), link.click()]);
+
+ const home = visibleByTestId(page, "shared-home-empty-state");
+ await expect(home.getByRole("heading", { level: 2, name: launch.heading })).toBeVisible();
+ await expectIdlePhoneHomeCentered(page, "shared-home-empty-state");
+ }
+ });
+
test("all tools stay visible across supported breakpoints and media preferences", async ({ page }) => {
await gotoLauncher(page, "/tools");
@@ -654,8 +683,8 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
for (const [title, href] of [
["Medication Prescribing", "/medications"],
["Documents", "/documents"],
- ["Services", "/services"],
- ["Forms", "/forms"],
+ ["Services", "/?mode=services"],
+ ["Forms", "/?mode=forms"],
["Saved workflows", "/favourites"],
["PsychSift Search", "/?mode=answer"],
] as const) {
@@ -785,6 +814,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");
@@ -2576,6 +2608,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.
@@ -2583,6 +2650,31 @@ test.describe("PsychSift tools directory and legacy launcher", () => {
await expect(sectionTrigger).toBeVisible();
await expect(sectionTrigger).toContainText("Overview");
await expectMinTouchTarget(sectionTrigger);
+
+ // The page title already has a dedicated back control in the in-page
+ // header, so the old three-part breadcrumb is intentionally absent. Map
+ // and Related are useful enough to remain visible without opening the
+ // section sheet on a phone.
+ await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toHaveCount(0);
+ const discoveryActions = detailPage.getByRole("navigation", { name: "Explore diagnosis" });
+ await expect(discoveryActions).toBeVisible();
+ const mapAction = discoveryActions.getByRole("button", { name: /^Map/ });
+ const relatedAction = discoveryActions.getByRole("button", { name: /^Related/ });
+ await expectMinTouchTarget(mapAction);
+ await expectMinTouchTarget(relatedAction);
+ await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" });
+ await expect(discoveryActions).toBeVisible();
+ await expectNoPageHorizontalOverflow(page);
+ await page.emulateMedia({ reducedMotion: "no-preference", forcedColors: "none" });
+ await mapAction.focus();
+ await expect(mapAction).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(sectionTrigger).toContainText("Map");
+ await expect(page).toHaveURL(/[?&]tab=map/);
+ await relatedAction.click();
+ await expect(sectionTrigger).toContainText("Related");
+ await expect(page).toHaveURL(/[?&]tab=related/);
+
await sectionTrigger.click();
const sectionSheet = page.getByTestId("differential-section-sheet");
await expect(sectionSheet).toBeVisible();
@@ -2624,6 +2716,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 }) => {
diff --git a/tests/ui-v2-answer-safety.dom.test.tsx b/tests/ui-v2-answer-safety.dom.test.tsx
index 927fe1e51a..79ce2bb5a1 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(
+
+