Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 34 additions & 25 deletions docs/filter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,6 @@ component decides the renderer. No call site picks chips, rows or a segmented co
`kind` is optional and defaults to `lens`, because that is what all seven existing call sites
are. Adding the facet kind changed no rendered output.

**`renderAs` is the one exception, and it is a migration seam rather than a layout choice.** A
counted lens should be a segmented bar (section 5), and the component could derive that on its own
from `kind` plus "every option carries a count" — but doing so would restyle all thirteen lens call
sites in a single change, each needing its own browser proof. So `renderAs: "segmented"` is opt-in
per group while modes move over one at a time. It is deliberately not a free choice: it names the
same renderer the rule would derive, the component still refuses it for a group carrying a dead end,
and the end state is that the flag disappears and derivation takes over. Do not add a second value
to it, and do not read it as licence for a call site to pick its own layout.

**Which is which is a question about the data, not the UI.** Differentials'
All / Presentations / Diagnoses is a lens: a result cannot be both. Formulation's twelve domains
are facets: a mechanism routinely carries four. Rendering facets as radios — which formulation
Expand Down Expand Up @@ -125,8 +116,15 @@ cannot state either segment honestly. `data/differentials-snapshot.json` is 1.2
component deliberately never imports it — doing so to get an "all" count would put the whole snapshot
in the bundle — while `useDifferentialSearch` only ever receives query-matched results. So the page
can produce a constant `232`, but not "how many of the 232 survive the current urgency selection",
and section 3 requires both counts to come from the same predicate as the filter. `/api/differentials`
is no help either: its `total` is the _match_ count once `q` is present, not the catalogue's.
and section 3 requires both counts to come from the same predicate as the filter.

`/api/differentials` used to compound this: its `total` measured the records it was returning, which
under a query are the ranked matches, so it reported the caller's own result count rather than the
catalogue. That is fixed — all four branches of the route report the catalogue size, pinned by
`tests/differentials-route.test.ts` — so the honest figure is now available. What is still missing is
the _scoped_ count: "how many of the 232 survive the current urgency selection" needs the catalogue
in memory, which is the megabyte this client must not import. The total alone cannot satisfy
section 3.

Differentials **browse** (`differential-stream-workspace.tsx`) does get scope, because its server
component hands it a model carrying matched and unmatched entries together, distinguished by
Expand Down Expand Up @@ -167,20 +165,31 @@ still the right renderer for short bare labels.
**The same argument applies to a counted `lens`, and the answer there is the segmented bar.** A lens
is an exact partition, so it takes `SegmentedControl` rather than the two-column grid — which is what
`ChoiceChip`'s own contract already says: _"Compact many-of-many selection. Use SegmentedControl for
one-of-many choices."_ Opt in per group with `renderAs: "segmented"` on `resultFilterGroup()`; the
default stays `"chips"` so the twelve lens call sites that predate this render unchanged, and each
can move over with its own browser proof. Differentials is the first adopter — its Show (3) and
Clinical urgency (4) groups were the ragged wrapping row this rule exists to stop.

Two constraints on that renderer, both load-bearing:

- **A group carrying a dead-end option stays on chips.** `SegmentedControl` marks a disabled option
with the native `disabled` attribute, which takes it out of the tab order. A dead end has to stay
focusable and explained — a reader who has just narrowed to nothing needs to reach the option that
did it — so `renderAs` is ignored for such a group rather than silently degrading it.
- **Counts must be unit-free.** `SegmentedControl` uses one field for both the visible count and the
accessible name, so it has no `hintLabel` equivalent (see the rule below). A lens whose counts
carry a unit keeps the chip renderer until that second field exists.
one-of-many choices."_

**It is derived, never declared.** A lens whose options all carry a count renders as a segmented bar
because of what it is, not because a call site asked. There is no renderer flag, and adding one would
break section 1 — a mode declares semantics, and picking a layout is the thing that rule exists to
stop. An earlier revision shipped `renderAs: "segmented"` as a migration seam so modes could move one
at a time; it is gone, and the option list is what decides.

Two conditions bound it, both load-bearing:

- **At most five options.** That is where the chip tier above ends. A segmented bar is one control
read left to right; past five it wraps into rows and stops reading as one, which is the ragged
shape this rule exists to remove. A longer lens keeps the chip row.
A dead end does **not** send the group back to chips, and an earlier revision that made it do so was
wrong: documents' Source locality marks an option dead the moment its count reaches zero, so a
state-dependent renderer made the control morph from a segmented bar into a chip row while the reader
was using it. The shape of the option list decides the renderer; nothing about the current selection
can change it. `SegmentedControl` carries the dead end itself, on a `deadEnd` field kept deliberately
separate from `disabled` — `disabled` means "not on offer" and leaves the arrow path, `deadEnd` means
"your own narrowing emptied this" and stays on it with `aria-disabled` and a stated reason, exactly as
section 3 requires.

Counts may carry units. `SegmentedControl` takes the same `hint`/`hintLabel` split as an option (see
the rule below), so `"1 loaded source"` is announced while `1` is displayed. Before that split a
counted lens with a unit had to stay on chips — which is what kept documents' Source locality there.

**`hint` is announced, `hintLabel` is displayed.** `hint` carries the unit (`"1 loaded source"`) and
is what the option's accessible name is built from; `hintLabel` is the short visible form (`"1"`).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 2,
"id": "2ab09b4d-a5b8-4854-b04e-c18942cbc33b",
"createdOn": "2026-09-07",
"action": "update",
"payload": {
"id": "#ZWJ71W",
"detail": "Found 2026-09-02 while correcting #YTR84P, and recorded separately because it is a different defect from the pinned-clock one and would be missed inside that row's prose. On branch claude/ward-flow-phases-6-7-design at 1888ad1: tests/ui-ward-morning.spec.ts clicks ward-morning-view-fixed and ward-morning-view-live (lines 51-52 and 195-206). Those test ids live in ViewControl (morning-page.tsx around lines 481-487), but owner decision WB-DB-11 ('ONE VIEW, ALWAYS LIVE') left MorningPage rendering MorningBody and nothing else, so ViewControl is exported and rendered by nothing. The spec therefore cannot pass as written. Same root cause as the D5 problem recorded in #YTR84P - the fixed/live split was removed while the code and tests that depend on it were left behind - so both should be settled by the same decision: either restore the fixed view, or retire it and remove the orphaned code and assertions together. SCOPE AND URGENCY: low. That branch's PR #2466 was closed unmerged on 2026-08-31 (299 commits ahead of main, mergeable_state dirty, titled 'DRAFT, not for merge'), and all of it is synthetic design scratch under src/app/mockups/ward-flow/**, which 404s in production. Nothing on main is affected. Recorded so it is not rediscovered from scratch by whoever takes the D5 decision. Offline only - the branch fetches normally from a cloud container with git fetch --depth=50 origin claude/ward-flow-phases-6-7-design. UPDATE 2026-09-06 (cross-reference only; this row's own finding is unchanged): the SAME two tests are now test.skip ON MAIN as well, not just unpassable on the Phase 6 branch. Commit 18a42d2 marked \"the morning page renders its headline, and the rail navigates away and back\" and \"print states when the sheet was printed, and the real PDF is exactly one A4 page\" as test.skip with a stated reason, because on main /mockups/ward-flow/morning is now only a redirect to /capacity and MorningPage is mounted by no route at all - so there is nothing honest to retarget them to either. That commit deliberately skipped rather than deleted or rewrote them, pending an owner ruling on spec D9. WHY THIS MATTERS FOR THIS ROW: the non-required Advisory UI job is consequently GREEN again, so the decision behind these skips is now less visible than it was while they were failing, not more. Component-level coverage continues in tests/ward-morning-page.dom.test.tsx and tests/ward-morning-print.test.ts. The ruling itself is unchanged and still owner-owned: restore the fixed/live morning view, or retire it and remove the orphaned ViewControl code and both specs together. Settling it should close this row and the D5 half of #YTR84P at the same time.",
"baseRowFingerprint": "565ee27887d9f0607ebd62021df38cd59d495a251f856431f23ef6b374007136"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 2,
"id": "2e5e2bc1-25b0-43af-805c-e3f7d8c63a13",
"createdOn": "2026-09-07",
"action": "done",
"payload": {
"id": "#QSHHGK",
"outcome": "CLOSED 2026-09-07 by owner decision: the owner is the named refresh owner, and the weekly report-only run is sufficient. That resolves point (1), the last of the three reasons this row stayed open. Points (2) and (3) were resolved on 2026-09-06 in PR #2665: the baseline was refreshed from main at fd8156109c54ed078895610406f629f4baf282b7, a commit that resolves and is an ancestor of main, so the provenance warning this row was opened around no longer fires. Production moved 1656.0 -> 1808.2 KiB gzip, mockups 613.1 -> 746.9, route / 278.5 -> 246.2, route /documents/search 281.6 -> 249.0, with tolerances untouched at 10/10/25 percent. THE ROW'S CAUTION IS NOT UN-RECORDED BY CLOSING IT: that refresh absorbed roughly two weeks of accumulated growth that was never attributed to a reviewed change set, and the owner took that decision knowingly and explicitly. The standing mechanism is .github/workflows/bundle-budget-refresh.yml, weekly on Wednesday 04:40 UTC plus workflow_dispatch, report-only, publishing a rolling issue and a refreshed bundle-budget.json artifact; the owner applies it at their discretion. REOPEN IF: the weekly run stops producing a resolvable baselineSource, or accumulated drift again fails an unrelated PR before anyone has applied an artifact.",
"baseRowFingerprint": "b31c2800eec69dfc49e478f141fed32c8b6ac287f84983b5e349ea16b993fcdf"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"version": 2,
"id": "5057727d-f394-47ee-b8cf-e61328d93e6d",
"createdOn": "2026-09-07",
"action": "cancel",
"payload": {
"requestId": "2ab09b4d-a5b8-4854-b04e-c18942cbc33b",
"reason": "Superseded verbatim by ce667f09-3a39-4d1d-bc8f-969bc0b5393d, which contains this request's full text plus the 2026-09-07 correction that spec D9 is the shift handover page, not the fixed/live morning view. Cancelled so the row carries exactly one pending mutation, per the reconciler's one-mutation-per-row rule."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "6582c265-084e-4c0d-8de8-4f5511df7b35",
"createdOn": "2026-09-07",
"action": "add",
"payload": {
"pri": "P1",
"type": "task",
"summary": "Ten locally authored differential records are live and carry no clinician sign-off",
"detail": "Merged and live in PR #2665 (main e45bd02, which auto-deploys to psychiatry.tools), so this content is in front of clinicians now. TEN SEEDED SLUGS in src/lib/differential-curated.ts: delirium, hypoactive-delirium, neuroleptic-malignant-syndrome, serotonin-toxicity, catatonia-in-mood-disorder, akathisia, alcohol-withdrawal, clozapine-specific-adverse-effects-toxicity, postpartum-psychosis, lithium-physiological-withdrawal-tremor. Each entry may carry atAGlance safety facts, doNow first moves, discriminators for the map comparison table, and a contentNote. WHAT IS ALREADY IN PLACE, so this row is not overstated: every authored block renders the provenance line \"Locally authored - verify before use\" (curatedProvenanceLabel in differential-detail.ts); the Source tab states when a record carries authored content (hasCuratedContent, rendered as testid differential-authored-content-note); authored steps stop at assessment and escalation rather than dosing, which docs/clinical-governance.md \"Clinical Use Rules\" reserves for validated tooling; and two unit tests in tests/differential-detail.test.ts enforce both boundaries - no discriminator may name a diagnosis its own record does not list in related, and no authored doNow step may match a dose pattern. The entry is resolved server-side in getDifferentialDetailContext and travels on DifferentialDetailContext.curated, so only the record on screen crosses the client boundary. WHAT IS MISSING: clinician sign-off. The content was authored by an assistant, not by a clinician, and no qualified reviewer has read it. Treat this exactly as #SBKXZ7 treats therapy sign-off - the tooling and labelling are done, the attestation is not, and assistants must never tick the clinical checks on their own behalf. NEXT: the owner reads the ten entries and either signs them off, edits them, or removes the ones they do not want to stand behind. There is currently no review-status field on a curated entry, so consider whether sign-off should be recorded in the module (a reviewedBy/reviewedAt pair per slug, mirroring the therapy generator's contract) or tracked only here. STOP: do not add further seeded records until these ten are ruled on. SEPARATE BUT ADJACENT: lithium-physiological-withdrawal-tremor is seeded largely to CORRECT the generated export, whose clinicalHinge is the definition of akathisia and whose immediateActions are four statements about akathisia, parkinsonism and tardive syndromes; its contentNote says so on the page. The underlying export defect is its own row.",
"source": "Claude Code session 2026-09-06 (session_01GMHX5TDG34BBn2yDdi81s5), PR #2665 merged as e45bd02",
"issueUlid": "01M1XMVB7K87GR248JR8CYQ151"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 2,
"id": "6d9ced9d-0f97-4519-b7d0-8a5bffc596d6",
"createdOn": "2026-09-07",
"action": "add",
"payload": {
"pri": "P2",
"type": "issue",
"summary": "The differentials export leaves 194 of 201 records with an empty section summary, and at least one carries another diagnosis's content",
"detail": "Measured over all 201 records in data/differentials-snapshot.json during the 2026-09-06 session: 150 carry NO investigations, 110 carry NO immediate actions, 194 have at least one EMPTY section summary, and 55 of the 91 records that do have \"immediate actions\" carry statements rather than actions. Worse than sparse in at least one case: lithium-physiological-withdrawal-tremor is titled \"Lithium\" with a tremor subtitle, but its clinicalHinge is the definition of AKATHISIA, its four immediateActions are statements about akathisia, drug-induced parkinsonism and tardive syndromes rather than actions, and its related nodes are the EPS family rather than tremor differentials. That is another diagnosis's content presented under this one. WHY THIS IS P2 AND NOT P1: the detail page now degrades honestly around it. Every panel added in PR #2665 renders only when it has content, so a sparse record produces a sparse page rather than a confident-looking empty one; buildDiscriminators derives a row only from data the catalogue actually carries and leaves the cell blank rather than echoing the other column; and the one known-mismatched record shows a content note saying its generated sections mix material from other diagnoses. Nothing fabricates. WHAT REMAINS: the overlay in src/lib/differential-curated.ts treats symptoms one record at a time and does not scale - repairing the generator that produced 194 records with an empty section summary is a data-pipeline job, and no row owned it before this one. NEXT: find the export/generation step behind data/differentials-snapshot.json, establish whether the empty sections are a source-content gap or a mapping bug in the exporter, and fix at that layer. The per-record counts above are the measurement to re-run afterwards - a fix should move them materially, and the lithium record specifically should stop carrying akathisia content. STOP: do not paper over further records by seeding differential-curated.ts; that overlay exists for correction and high-yield enrichment, not as a substitute for a working export, and every seeded record adds clinical content that needs its own sign-off (see the authored-records row).",
"source": "Claude Code session 2026-09-06 (session_01GMHX5TDG34BBn2yDdi81s5), measured across data/differentials-snapshot.json while building PR #2665",
"issueUlid": "01M1XMVVBHVMG7D8T4KN1HCQ0R"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"version": 2,
"id": "7c9df6d8-6113-407a-842e-0899aa7799c2",
"createdOn": "2026-09-07",
"action": "cancel",
"payload": {
"requestId": "2e5e2bc1-25b0-43af-805c-e3f7d8c63a13",
"reason": "Ordering, not a reversal: the owner's decision to close #QSHHGK stands. Its pending update a4593f03 corrects a statement in the row that is now false (that the baseline was deliberately not refreshed), and a done cannot carry a detail change, so the update must land first. Re-queue this closure after the next reconcile."
}
}
Loading
Loading