diff --git a/docs/branch-review-records/60f7559f21cf484e008441a428df84d3a6f127b81692ab314e8bf97a54197ffd.record.md b/docs/branch-review-records/60f7559f21cf484e008441a428df84d3a6f127b81692ab314e8bf97a54197ffd.record.md new file mode 100644 index 0000000000..39644cfec7 --- /dev/null +++ b/docs/branch-review-records/60f7559f21cf484e008441a428df84d3a6f127b81692ab314e8bf97a54197ffd.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/therapy-production-visibility | 0e51be0ebbf22ec67efde77d6ca21b25c5f1fce9 | Therapy production exposure: remove devOnly, switch off the unreviewed-record filter behind a named constant, rewrite three pinned gate tests | Approved for review as PR #2145. Owner-directed exposure of the Therapy catalogue ahead of clinical sign-off, taken with the numbers stated: 205 records, 0 reviewed, 1435 unticked checklist items. No record was relabelled - reviewStatus stays needs_review on all 205, because marking them reviewed would be a false clinical attestation. Gate machinery retained and re-armable via HIDE_UNREVIEWED_IN_PRODUCTION; layout route guard untouched as it self-disarms. Three pinned tests rewritten to the stronger post-gate condition (honesty channels pinned against the real catalogue, including the non-colour-only glyph) rather than deleted. pr-policy returns clinicalRisk false for this diff, which is a classifier gap flagged in the PR body; governance preflight completed voluntarily with the review-status conservatism item deliberately left unticked as it would be untrue. | verify:cheap green - Test Files 673 passed (673), Tests 7276 passed \| 4 skipped (7280), Design-system contract passed (784 production files); verify:ui 447 passed (14.9m) chromium on the Playwright-matched revision; check:production-readiness ran with 2 FAIL, both missing Supabase/OpenAI env in the offline container (documented provider capability gap, unrelated to this diff) | diff --git a/docs/outstanding-issues-inbox/1d0dbb76-a35d-4c8c-8816-3f82f3e1a5f6.json b/docs/outstanding-issues-inbox/1d0dbb76-a35d-4c8c-8816-3f82f3e1a5f6.json new file mode 100644 index 0000000000..12790e3561 --- /dev/null +++ b/docs/outstanding-issues-inbox/1d0dbb76-a35d-4c8c-8816-3f82f3e1a5f6.json @@ -0,0 +1,13 @@ +{ + "version": 2, + "id": "1d0dbb76-a35d-4c8c-8816-3f82f3e1a5f6", + "createdOn": "2026-08-18", + "action": "add", + "payload": { + "pri": "P2", + "type": "task", + "summary": "Therapy sign-off tooling is deferred: no guard stops reviewStatus reviewed being set with an empty checklist, and there is no reviewer attribution", + "detail": "Deferred 2026-08-18 when Therapy was made production-visible ahead of clinical review. Three gaps. (1) reviewStatus is a bare string in src/data/therapies-source.json; a record can be flipped to reviewed with all seven reviewChecklist booleans still false and nothing detects it. Needs a script that refuses the flip unless the checklist is complete, plus a contract test pinning reviewed implies full checklist. (2) No attribution: none of the 44 record fields carries reviewedBy or reviewedAt, so a sign-off cannot record who signed or when - the same defect #318 flags against the medication interaction lexicon. (3) No review workflow: signing off 205 records x 7 checks is 1435 clinical judgements by hand; a CLI that walks records, shows the fields each check covers, and writes the decision with attribution would make it tractable. Current state at deferral: 205 records, all reviewStatus needs_review, all seven checklist booleans false, reviewCompleteness 57-71 with zero records complete. Stop rule: an assistant must never tick clinicalAccuracyReviewed, sourceChecked, evidenceAppraised, safetyCautionsChecked or patientExplanationChecked - those are qualified-clinician attestations. proofread and australianEnglishChecked are non-clinical and may be done with attribution.", + "issueUlid": "01M0ASAS3EJ2Z62XJV5ZQ8A08W" + } +} diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 00193ba8ae..e028a826f6 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -368,10 +368,19 @@ export const appModeDefinitions = [ label: "Therapy", description: "Source-grounded therapy decision support", href: "/therapy-compass", - // Keep Therapy available for local clinical review while its catalogue is - // awaiting qualified-clinician sign-off. Removing this gate requires the - // catalogue review-status contract to prove production-ready records. - devOnly: true, + // Therapy was production-gated while its catalogue awaited qualified-clinician + // sign-off. The owner lifted that gate on 2026-08-18 with the catalogue still + // unreviewed — a deliberate, informed decision for a private clinical + // reference tool, taken knowing all 205 records are `needs_review`. + // + // The records were NOT relabelled to buy this: `reviewStatus` still reads + // `needs_review`, and the per-record signals that say so — the "Needs source + // review" search badge, the "(Awaiting source review.)" metadata suffix, and + // `StatusBadge` on the record — are now the only thing standing between an + // unreviewed record and a clinical decision. `tests/therapy-ranking.test.ts` + // pins them, and that pin replaced the old "hidden in production" assertion + // rather than deleting it. See `HIDE_UNREVIEWED_IN_PRODUCTION` in + // `src/lib/therapies.ts` for the matching record-level switch. search: { kind: "therapies", // The longer phrase became the late portal's LCP element on Therapy Home. diff --git a/src/lib/therapies.ts b/src/lib/therapies.ts index 3cfb5eb0ce..fb4b9ad7d3 100644 --- a/src/lib/therapies.ts +++ b/src/lib/therapies.ts @@ -32,9 +32,34 @@ function defaultTherapyEnvironment(): string | undefined { return process.env.PLAYWRIGHT_OFFLINE_MODE === "true" ? "development" : process.env.NODE_ENV; } +/** + * Whether production withholds therapy records that have not completed clinical + * review. + * + * Was implicitly `true`: production filtered every record whose `reviewStatus` + * was not `reviewed`, which — with the whole catalogue unreviewed — meant an + * empty library, and `devOnly` on the mode hid the entrance entirely. The owner + * lifted both on 2026-08-18, deliberately and with the numbers in hand: 205 + * records, 0 reviewed, 1435 unticked checklist items. + * + * Written as a named constant rather than deleting the filter, for three + * reasons. Re-arming the gate is a one-word edit if that decision is revisited; + * the condition stays greppable, so "why is unreviewed content live?" has an + * answer at the point of the decision; and `therapyNeedsReview` keeps its + * meaning for every caller that still surfaces review state to the reader — + * which, now that this filter is off, is the only remaining protection. + * + * Nothing here changes a record's data. `reviewStatus` is still `needs_review`, + * because the alternative — relabelling records as reviewed — would have been a + * false clinical attestation rather than a product decision. + */ +const HIDE_UNREVIEWED_IN_PRODUCTION = false; + /** Production may expose only records that have completed clinical review. */ export function therapyRecordsForEnvironment(environment = defaultTherapyEnvironment()): TherapyIndexRecord[] { - return environment === "production" ? therapyRecords.filter((record) => !therapyNeedsReview(record)) : therapyRecords; + return HIDE_UNREVIEWED_IN_PRODUCTION && environment === "production" + ? therapyRecords.filter((record) => !therapyNeedsReview(record)) + : therapyRecords; } export function findTherapyRecord( @@ -42,7 +67,10 @@ export function findTherapyRecord( environment = defaultTherapyEnvironment(), ): TherapyIndexRecord | undefined { const record = bySlug.get(slug); - return record && (environment !== "production" || !therapyNeedsReview(record)) ? record : undefined; + if (!record) return undefined; + return !HIDE_UNREVIEWED_IN_PRODUCTION || environment !== "production" || !therapyNeedsReview(record) + ? record + : undefined; } export function therapyRecordExists(slug: string, environment = defaultTherapyEnvironment()): boolean { diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts index a4ae603758..1f95f840ea 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -316,7 +316,10 @@ describe("app mode search contract", () => { expect(isAppModeVisible("prescribing", "production")).toBe(true); expect(isAppModeVisible("tools", "production")).toBe(true); expect(isAppModeVisible("calculators", "production")).toBe(true); - expect(isAppModeVisible("therapy-compass", "production")).toBe(false); + // Therapy joined the production modes on 2026-08-18 when the owner lifted its + // clinical-review gate. Asserted `true` rather than dropped, so a silent + // re-hiding still fails a test. + expect(isAppModeVisible("therapy-compass", "production")).toBe(true); expect(isAppModeVisible("factsheets", "production")).toBe(true); expect(productionModes).not.toContain("evidence"); expect(productionModes).toContain("services"); @@ -329,7 +332,7 @@ describe("app mode search contract", () => { expect(productionModes).toContain("prescribing"); expect(productionModes).toContain("tools"); expect(productionModes).toContain("calculators"); - expect(productionModes).not.toContain("therapy-compass"); + expect(productionModes).toContain("therapy-compass"); expect(productionModes).toContain("factsheets"); expect(developmentModes).toEqual( expect.arrayContaining([ @@ -352,12 +355,19 @@ describe("app mode search contract", () => { expect(developmentModes).not.toContain("evidence"); }); - it("keeps Therapy Compass behind clinical review in production", () => { + // Was "keeps Therapy Compass behind clinical review in production". The owner + // lifted that gate on 2026-08-18 with the catalogue still unreviewed, so the + // old assertion is untrue rather than merely unenforced. Inverted rather than + // removed: Therapy is now an ordinary production mode in both environments, + // and an accidental return of `devOnly` fails here. The clinical-review + // property it used to protect did not vanish — it moved to the per-record + // signals pinned in `tests/therapy-ranking.test.ts`. + it("exposes Therapy Compass as an ordinary production mode", () => { expect(isAppModeId("therapy-compass")).toBe(true); expect(isAppModeVisible("therapy-compass", "development")).toBe(true); - expect(isAppModeVisible("therapy-compass", "production")).toBe(false); + expect(isAppModeVisible("therapy-compass", "production")).toBe(true); expect(visibleAppModeDefinitions("development").map((mode) => mode.id)).toContain("therapy-compass"); - expect(visibleAppModeDefinitions("production").map((mode) => mode.id)).not.toContain("therapy-compass"); + expect(visibleAppModeDefinitions("production").map((mode) => mode.id)).toContain("therapy-compass"); }); it("gates Favourites mode to authenticated or demo sessions", () => { diff --git a/tests/therapy-ranking.test.ts b/tests/therapy-ranking.test.ts index 5b7d0ff215..5bb68e73b2 100644 --- a/tests/therapy-ranking.test.ts +++ b/tests/therapy-ranking.test.ts @@ -1,13 +1,14 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { EMPTY_SEARCH, searchTherapies } from "@/components/therapy-compass/data/select"; +import { EMPTY_SEARCH, reviewStatusMeta, searchTherapies } from "@/components/therapy-compass/data/select"; import { THERAPY_CATALOGUE_ASSETS } from "@/components/therapy-compass/data/generated-assets"; import type { Therapy } from "@/components/therapy-compass/data/types"; import { rankTherapyCandidates, scoreTherapyCandidate } from "@/lib/therapy-ranking"; import { findTherapyRecord, searchTherapyRecords, + therapyNeedsReview, therapyRecords, therapyRecordsForEnvironment, therapySlugs, @@ -50,12 +51,51 @@ describe("shared Therapy ranker", () => { expect(rankTherapyCandidates(records, "CBT")[0]?.record.name).toBe("Cognitive behavioural therapy"); }); - it("excludes unreviewed Therapy content from production discovery and routes", () => { + /** + * Was "excludes unreviewed Therapy content from production discovery and + * routes". That assertion is now false by decision, not by accident: the owner + * lifted the production review gate on 2026-08-18 with all 205 records still + * `needs_review`, so production serves them. + * + * Replaced rather than deleted, and with the stronger requirement. While the + * gate stood, hiding the content WAS the protection and nothing needed to say + * "unreviewed" out loud. Now that the content ships, the only thing between an + * unreviewed record and a clinical decision is that every surface still says + * so — so that is what gets pinned, on the real catalogue rather than fixtures. + * + * If someone re-arms `HIDE_UNREVIEWED_IN_PRODUCTION`, the first three + * assertions fail loudly and this comment is where they should look. + */ + it("serves the catalogue in production and never drops the unreviewed marking", () => { expect(therapyRecords.length).toBeGreaterThan(0); - expect(therapyRecordsForEnvironment("production")).toEqual([]); - expect(searchTherapyRecords("CBT", "production")).toEqual([]); - expect(therapySlugs("production")).toEqual([]); - expect(findTherapyRecord(therapyRecords[0].slug, "production")).toBeUndefined(); + expect(therapyRecordsForEnvironment("production")).toHaveLength(therapyRecords.length); + expect(therapySlugs("production")).toHaveLength(therapyRecords.length); + expect(findTherapyRecord(therapyRecords[0].slug, "production")).toBeDefined(); + expect(searchTherapyRecords("CBT", "production").length).toBeGreaterThan(0); + + // The marking itself. `reviewStatus` must stay honest — relabelling records + // as reviewed would be a false clinical attestation, not a product change. + const unreviewed = therapyRecords.filter((record) => record.reviewStatus !== "reviewed"); + expect(unreviewed.length).toBeGreaterThan(0); + expect(therapyNeedsReview(unreviewed[0])).toBe(true); + + // Every reader-facing channel that carries it, so none can be dropped + // silently: the record badge, the universal-search badge, and the route + // metadata description. + expect(reviewStatusMeta(unreviewed[0].reviewStatus)).toEqual({ + label: "Needs source review", + tone: "warning", + }); + expect(readFileSync("src/lib/universal-search.ts", "utf8")).toContain( + 'badge: therapyNeedsReview(record) ? "Needs source review"', + ); + expect(readFileSync("src/app/(search-app)/therapy-compass/[slug]/page.tsx", "utf8")).toContain( + "Awaiting source review.", + ); + // Tone alone is not a channel — `StatusBadge` pairs it with a glyph. + expect(readFileSync("src/components/therapy-compass/ui.tsx", "utf8")).toContain( + 'const Icon = meta.tone === "success" ? ShieldCheck : TriangleAlert;', + ); }); it.each([ diff --git a/tests/therapy-review-regressions.test.ts b/tests/therapy-review-regressions.test.ts index d0c1fdc1b0..f9b07792bd 100644 --- a/tests/therapy-review-regressions.test.ts +++ b/tests/therapy-review-regressions.test.ts @@ -29,14 +29,35 @@ describe("Therapy review regression contracts", () => { ); }); - it("keeps Therapy unavailable in production until clinical review is complete", () => { + /** + * Was "keeps Therapy unavailable in production until clinical review is + * complete". The owner lifted that gate on 2026-08-18 with the catalogue still + * unreviewed, so the old name now describes a state the product deliberately + * does not hold. + * + * The machinery is asserted to still EXIST rather than being torn out, which + * is the point of keeping this test. `devOnly` was removed from the mode and + * `HIDE_UNREVIEWED_IN_PRODUCTION` is `false`, but the generic route guard, + * the record filter and `therapyNeedsReview` all remain wired — so re-arming + * the gate after sign-off is a two-line edit, not a rebuild. A future change + * that deletes the mechanism outright fails here. + */ + it("keeps the Therapy review gate re-armable after its deliberate lifting", () => { const layout = source("src/app/(search-app)/therapy-compass/layout.tsx"); const modes = source("src/lib/app-modes.ts"); const therapies = source("src/lib/therapies.ts"); + // The route guard is generic over `devOnly`, so it self-disarmed when the + // flag came off and did not need editing. It must stay for the next mode. expect(layout).toContain('isAppModeVisible("therapy-compass", "production")'); expect(layout).toContain("notFound()"); - expect(modes).toMatch(/id: "therapy-compass"[\s\S]*?devOnly: true/); + + // Therapy is no longer dev-only, and the reason is recorded at the site. + expect(modes).not.toMatch(/id: "therapy-compass"[\s\S]{0,600}?devOnly: true/); + expect(modes).toContain("HIDE_UNREVIEWED_IN_PRODUCTION"); + + // The record-level filter survives, switched off by one named constant. + expect(therapies).toContain("const HIDE_UNREVIEWED_IN_PRODUCTION = false;"); expect(therapies).toContain('environment === "production"'); expect(therapies).toContain("therapyNeedsReview(record)"); });