From 0fa624e64303e119c6ae1afcc46acc3792e5b7c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:51:24 +0000 Subject: [PATCH 1/7] fix(site-content): refuse first-publication sign-offs whose content pin is stale The first-publication plan checked only that reviewedContentSha256 looked like a SHA-256. It now recomputes the pin through the clinical:review contract (forms against data/forms-catalog.json, services against the curated records) and refuses any record edited since its sign-off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016imb8DCuk85z3FWRDDyZx5 --- .../publish-site-content-first-catalogue.ts | 63 +++++++++++++++++-- ...site-content-first-publication-cli.test.ts | 61 +++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/scripts/publish-site-content-first-catalogue.ts b/scripts/publish-site-content-first-catalogue.ts index 0080d13a7d..f0691750eb 100644 --- a/scripts/publish-site-content-first-catalogue.ts +++ b/scripts/publish-site-content-first-catalogue.ts @@ -14,7 +14,10 @@ * 1. Fail-closed clinical sign-off check: every record included in the plan must carry * `status === "reviewed"`, a named human reviewer (`isNamedPerson(reviewedBy)` — rejecting * `"PsychSift"`, `"System"`, bare titles, etc.), and a valid UTC `reviewedAt` timestamp. - * 2. Offline by default: `--dry-run` and `--out ` never import or call a live Supabase + * 2. Content pin check: each sign-off's `reviewedContentSha256` must still match the content it + * signed, worked out the same way npm run clinical:review wrote it. A record edited after its + * sign-off is refused, so a stale approval can never publish changed text. + * 3. Offline by default: `--dry-run` and `--out ` never import or call a live Supabase * client. Live execution requires BOTH `--execute` and `ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT=true`. * * USAGE @@ -44,6 +47,9 @@ import { import { compareCanonicalSiteContentIdentifiers, siteContentValueHash } from "@/lib/site-content/site-content-manifest"; import { reconciliationHash } from "@/lib/site-content/site-content-reconciliation"; +import { recordPinState } from "./lib/clinical-record-review-contract.mjs"; +import { loadContext as loadServiceSignOffContext } from "./lib/signoff-kinds/services.mjs"; + export type FirstPublicationKind = "service" | "form" | "all"; export type ClinicalSignOffEntry = { @@ -56,6 +62,14 @@ export type ClinicalSignOffEntry = { reviewedContentSha256?: string | null; }; +/** + * Whether a sign-off's content pin still matches what it signed. `unverifiable` means the + * content the pin covers could not be loaded, which is refused like a stale pin. + */ +export type SignOffPinState = "current" | "stale" | "unsigned" | "unverifiable"; + +export type SignOffPinCheck = (kind: "service" | "form", review: ClinicalSignOffEntry) => SignOffPinState; + export type FirstPublicationRejectedRecord = { kind: "service" | "form"; recordId: string; @@ -94,6 +108,10 @@ export type BuildFirstPublicationOptions = { serviceReviews?: readonly ClinicalSignOffEntry[]; formReviews?: readonly ClinicalSignOffEntry[]; onlySlugs?: readonly string[]; + /** Checks each content pin. Defaults to the clinical:review contract over the repository data. */ + pinState?: SignOffPinCheck; + /** The curated service records a service pin covers (from the services sign-off kind). */ + serviceSignOffContext?: object; }; export type FirstPublicationCliOptions = { @@ -118,11 +136,27 @@ function isValidReviewTimestamp(value: string | null | undefined): boolean { return normalized.slice(0, 19) === trimmed.slice(0, 19); } +/** The default pin check: the same digest npm run clinical:review wrote at sign-off. */ +export function createSignOffPinCheck( + options: { serviceContext?: object; formsCatalogPath?: string } = {}, +): SignOffPinCheck { + let formsCatalog: unknown; + return (kind, review) => { + if (kind === "service") { + if (!options.serviceContext) return "unverifiable"; + return recordPinState(review, "service", options.serviceContext); + } + formsCatalog ??= JSON.parse(readFileSync(resolve(options.formsCatalogPath ?? "data/forms-catalog.json"), "utf8")); + return recordPinState(review, "form", { catalog: formsCatalog }); + }; +} + function validateSignOffEntry( kind: "service" | "form", recordId: string, slug: string, review: ClinicalSignOffEntry | undefined, + pinState: SignOffPinCheck, ): FirstPublicationRejectedRecord | null { if (!review) { return { @@ -164,6 +198,24 @@ function validateSignOffEntry( reason: `reviewedContentSha256 ${JSON.stringify(review.reviewedContentSha256)} is missing or not a valid 64-character hex SHA-256 digest`, }; } + const pin = pinState(kind, review); + if (pin === "unverifiable") { + return { + kind, + recordId, + slug, + reason: "the content its sign-off covers could not be loaded, so the content pin could not be checked", + }; + } + if (pin !== "current") { + return { + kind, + recordId, + slug, + reason: + "content changed since sign-off (reviewedContentSha256 no longer matches); re-review it with npm run clinical:review", + }; + } return null; } @@ -209,6 +261,7 @@ export function buildFirstPublicationCataloguePlan( const serviceReviews = options.serviceReviews ?? loadDefaultServiceReviews(); const formReviews = options.formReviews ?? loadDefaultFormReviews(); + const pinState = options.pinState ?? createSignOffPinCheck({ serviceContext: options.serviceSignOffContext }); const serviceReviewByKey = new Map(); for (const entry of serviceReviews) { @@ -238,7 +291,7 @@ export function buildFirstPublicationCataloguePlan( serviceReviewByKey.get(stableId.toLowerCase()) ?? serviceReviewByKey.get(service.slug.trim().toLowerCase()) ?? (serviceWithId.id ? serviceReviewByKey.get(serviceWithId.id.trim().toLowerCase()) : undefined); - const problem = validateSignOffEntry("service", stableId, service.slug, review); + const problem = validateSignOffEntry("service", stableId, service.slug, review, pinState); if (problem) { rejected.push(problem); continue; @@ -263,7 +316,7 @@ export function buildFirstPublicationCataloguePlan( formReviewByKey.get(formCode.toLowerCase()) ?? formReviewByKey.get(form.slug.trim().toLowerCase()) ?? formReviewByKey.get(formRecordId.toLowerCase()); - const problem = validateSignOffEntry("form", formCode, form.slug, review); + const problem = validateSignOffEntry("form", formCode, form.slug, review, pinState); if (problem) { rejected.push(problem); continue; @@ -278,7 +331,7 @@ export function buildFirstPublicationCataloguePlan( .join("\n"); const remainder = rejected.length > 10 ? `\n ...and ${rejected.length - 10} more record(s).` : ""; throw new Error( - `First-publication plan refused: ${rejected.length} record(s) lack clinical sign-off by a named person:\n${preview}${remainder}`, + `First-publication plan refused: ${rejected.length} record(s) lack a current clinical sign-off by a named person:\n${preview}${remainder}`, ); } @@ -440,6 +493,8 @@ export async function runFirstPublicationCli( batchSize: options.batchSize, serviceReviews: loadDefaultServiceReviews(options.servicesReviewPath), formReviews: loadDefaultFormReviews(options.formsReviewPath), + serviceSignOffContext: + options.kind === "form" ? undefined : await loadServiceSignOffContext("service", process.cwd()), }); if (options.out) { diff --git a/tests/site-content-first-publication-cli.test.ts b/tests/site-content-first-publication-cli.test.ts index 57d91d3d1a..3aef1f7f34 100644 --- a/tests/site-content-first-publication-cli.test.ts +++ b/tests/site-content-first-publication-cli.test.ts @@ -1,3 +1,6 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + import { describe, expect, it } from "vitest"; import { formRecords } from "@/lib/forms"; @@ -7,12 +10,35 @@ import { parseFirstPublicationCliArgs, runFirstPublicationCli, type ClinicalSignOffEntry, + type SignOffPinCheck, } from "../scripts/publish-site-content-first-catalogue"; const NAMED_REVIEWER = "Dr Jane Citizen"; const REVIEWED_AT = "2026-10-16T01:00:00.000Z"; const REVIEWED_SHA256 = "eb950d3c48dba095ef61d7b0645709d90316ed475b5eb6602b670e62e6e07ad1"; +// A distinct digest per record, so the plan check below proves each record is matched to +// its own sign-off, not to one shared pin. +function fixtureDigest(kind: string, key: string): string { + return createHash("sha256").update(`${kind}:${key}`).digest("hex"); +} + +const fixturePinCheck: SignOffPinCheck = (kind, review) => + review.reviewedContentSha256 === fixtureDigest(kind, String(kind === "form" ? review.code : review.id)) + ? "current" + : "stale"; + +type RepositoryFormReview = ClinicalSignOffEntry & { code: string }; + +function repositoryFormReview(code: string): RepositoryFormReview { + const document = JSON.parse(readFileSync("data/forms-content-review.json", "utf8")) as { + forms: RepositoryFormReview[]; + }; + const review = document.forms.find((entry) => entry.code === code); + if (!review) throw new Error(`No repository review for Form ${code}.`); + return review; +} + function formCodeForRecord(form: (typeof formRecords)[number]): string { const payload = (form.catalogPayload ?? {}) as { form?: string }; return String(payload.form ?? form.catalogueLabel ?? form.slug) @@ -27,7 +53,7 @@ function signedServiceReviews(): ClinicalSignOffEntry[] { status: "reviewed", reviewedBy: NAMED_REVIEWER, reviewedAt: REVIEWED_AT, - reviewedContentSha256: REVIEWED_SHA256, + reviewedContentSha256: fixtureDigest("service", String(service.catalogPayload?.stableId ?? service.slug)), })); } @@ -38,7 +64,7 @@ function signedFormReviews(): ClinicalSignOffEntry[] { status: "reviewed", reviewedBy: NAMED_REVIEWER, reviewedAt: REVIEWED_AT, - reviewedContentSha256: REVIEWED_SHA256, + reviewedContentSha256: fixtureDigest("form", formCodeForRecord(form)), })); } @@ -47,7 +73,7 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { const formPlan = buildFirstPublicationCataloguePlan({ kind: "form" }); expect(formPlan.counts.forms).toBe(54); expect(() => buildFirstPublicationCataloguePlan({ kind: "service" })).toThrow( - /First-publication plan refused: \d+ record\(s\) lack clinical sign-off by a named person/, + /First-publication plan refused: \d+ record\(s\) lack a current clinical sign-off by a named person/, ); }); @@ -107,6 +133,33 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { ).toThrow(/First-publication plan refused/); }); + it("refuses a form whose content changed after its sign-off, and accepts it while the pin is current", () => { + const sampleForm = formRecords.find((form) => formCodeForRecord(form) === "1A")!; + const signed = repositoryFormReview("1A"); + expect(signed.status).toBe("reviewed"); + + const current = buildFirstPublicationCataloguePlan({ kind: "form", forms: [sampleForm], formReviews: [signed] }); + expect(current.counts.forms).toBe(1); + + // A well-formed digest that is not the one the signed content produces: the content no longer matches. + const stalePin = createHash("sha256").update("edited after sign-off").digest("hex"); + expect(() => + buildFirstPublicationCataloguePlan({ + kind: "form", + forms: [sampleForm], + formReviews: [{ ...signed, reviewedContentSha256: stalePin }], + }), + ).toThrow(/content changed since sign-off/); + }); + + it("refuses a service sign-off when the curated records it pins were not loaded", () => { + const sampleService = defaultServiceRecords()[0]!; + const [review] = signedServiceReviews(); + expect(() => + buildFirstPublicationCataloguePlan({ kind: "service", services: [sampleService], serviceReviews: [review!] }), + ).toThrow(/content pin could not be checked/); + }); + it("builds a deterministic batched first-publication plan once all 222 services and 54 forms have named clinician sign-off", () => { const services222 = defaultServiceRecords().slice(0, 222); const planA = buildFirstPublicationCataloguePlan({ @@ -115,6 +168,7 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { services: services222, serviceReviews: signedServiceReviews(), formReviews: signedFormReviews(), + pinState: fixturePinCheck, }); const planB = buildFirstPublicationCataloguePlan({ kind: "all", @@ -122,6 +176,7 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { services: services222, serviceReviews: signedServiceReviews(), formReviews: signedFormReviews(), + pinState: fixturePinCheck, }); expect(planA.version).toBe("site-content-first-publication-plan-v1"); From fe0f2591ff5a3bc4a9fc49c6cd18c28ce0b17937 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:53:57 +0000 Subject: [PATCH 2/7] fix(calculators): keep bedside calculator entries across the desktop breakpoint The calculator page mounts ClinicalMathPanel in the sidebar at 1280px and wider, and in the mobile column below that. Each mount had its own state, so resizing across the breakpoint wiped everything entered. The page now holds one in-memory state and passes it to whichever panel is mounted. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016imb8DCuk85z3FWRDDyZx5 --- .../calculators/clinical-math-panel.tsx | 84 ++++++++++++++++++- src/components/calculators/search-page.tsx | 7 +- ...linical-math-panel-breakpoint.dom.test.tsx | 64 ++++++++++++++ 3 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 tests/clinical-math-panel-breakpoint.dom.test.tsx diff --git a/src/components/calculators/clinical-math-panel.tsx b/src/components/calculators/clinical-math-panel.tsx index 76c55a5635..fe5733e793 100644 --- a/src/components/calculators/clinical-math-panel.tsx +++ b/src/components/calculators/clinical-math-panel.tsx @@ -30,8 +30,12 @@ function parseStrictNumber(value: string): number { return trimmed === "" ? Number.NaN : Number(trimmed); } -export function ClinicalMathPanel({ defaultOpen = false, defaultTab = "renal" }: ClinicalMathPanelProps) { - const panelId = useId(); +/** + * Everything the clinician has entered. The calculator page keeps one copy and hands it to + * whichever panel is mounted, so crossing the desktop breakpoint (the panel moves between the + * sidebar and the mobile column) does not wipe the entries. It lives in memory only. + */ +export function useClinicalMathPanelState({ defaultOpen = false, defaultTab = "renal" }: ClinicalMathPanelProps = {}) { const [open, setOpen] = useState(defaultOpen); const [activeTab, setActiveTab] = useState(defaultTab); @@ -54,6 +58,82 @@ export function ClinicalMathPanel({ defaultOpen = false, defaultTab = "renal" }: const [targetLevel, setTargetLevel] = useState(""); const [tabletIncrementMg, setTabletIncrementMg] = useState<125 | 225 | 250 | 450>(125); + return { + open, + setOpen, + activeTab, + setActiveTab, + age, + setAge, + sex, + setSex, + weightKg, + setWeightKg, + heightCm, + setHeightCm, + creatinine, + setCreatinine, + creatinineUnit, + setCreatinineUnit, + qtMs, + setQtMs, + hrBpm, + setHrBpm, + lithiumLevel, + setLithiumLevel, + lithiumIndication, + setLithiumIndication, + currentDoseMg, + setCurrentDoseMg, + targetLevel, + setTargetLevel, + tabletIncrementMg, + setTabletIncrementMg, + }; +} + +export type ClinicalMathPanelState = ReturnType; + +export function ClinicalMathPanel({ + defaultOpen = false, + defaultTab = "renal", + state, +}: ClinicalMathPanelProps & { state?: ClinicalMathPanelState }) { + const panelId = useId(); + const ownState = useClinicalMathPanelState({ defaultOpen, defaultTab }); + const { + open, + setOpen, + activeTab, + setActiveTab, + age, + setAge, + sex, + setSex, + weightKg, + setWeightKg, + heightCm, + setHeightCm, + creatinine, + setCreatinine, + creatinineUnit, + setCreatinineUnit, + qtMs, + setQtMs, + hrBpm, + setHrBpm, + lithiumLevel, + setLithiumLevel, + lithiumIndication, + setLithiumIndication, + currentDoseMg, + setCurrentDoseMg, + targetLevel, + setTargetLevel, + tabletIncrementMg, + setTabletIncrementMg, + } = state ?? ownState; + const parsedAge = parseStrictNumber(age); const parsedWeight = parseStrictNumber(weightKg); const parsedHeight = heightCm.trim() === "" ? undefined : parseStrictNumber(heightCm); diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 38b02e48d0..16587ff2cb 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -45,7 +45,7 @@ import { type CalculatorFixture, } from "./calculator-fixtures"; import { CalculatorSheet } from "./calculator-sheet"; -import { ClinicalMathPanel } from "./clinical-math-panel"; +import { ClinicalMathPanel, useClinicalMathPanelState } from "./clinical-math-panel"; import { calculatorRecordById } from "./calculator-routes"; import { MetaPill, @@ -345,6 +345,7 @@ export function CalculatorsSearchPage({ () => false, ); const isDesktopSidebar = useSyncExternalStore(subscribeDesktopSidebar, getDesktopSidebarSnapshot, () => false); + const clinicalMath = useClinicalMathPanelState(); const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery; const normalizedQuery = normalizeCalculatorQuery(query); const smartExpansions = useMemo(() => smartSearchExpansions("calculators", query), [query]); @@ -605,14 +606,14 @@ export function CalculatorsSearchPage({ sidebar={ <> - {isDesktopSidebar ? : null} + {isDesktopSidebar ? : null} } sidebarMobile={
- {!isDesktopSidebar ? : null} + {!isDesktopSidebar ? : null}
} diff --git a/tests/clinical-math-panel-breakpoint.dom.test.tsx b/tests/clinical-math-panel-breakpoint.dom.test.tsx new file mode 100644 index 0000000000..424ed93c65 --- /dev/null +++ b/tests/clinical-math-panel-breakpoint.dom.test.tsx @@ -0,0 +1,64 @@ +/** @vitest-environment jsdom */ + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +import { CalculatorsSearchPage } from "@/components/calculators/search-page"; + +// A controllable (min-width: 1280px) query, so the test can cross the desktop breakpoint. +function installDesktopQuery(initial: boolean) { + let matches = initial; + const listeners = new Set<() => void>(); + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + media: query, + get matches() { + return query === "(min-width: 1280px)" ? matches : false; + }, + addEventListener: (_type: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener), + addListener: (listener: () => void) => listeners.add(listener), + removeListener: (listener: () => void) => listeners.delete(listener), + onchange: null, + dispatchEvent: () => true, + })), + ); + return (next: boolean) => { + matches = next; + act(() => { + for (const listener of listeners) listener(); + }); + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("bedside calculator panel across the desktop breakpoint", () => { + it("keeps the clinician's entries when the panel moves between the sidebar and the mobile column", () => { + const setDesktop = installDesktopQuery(false); + render(); + + fireEvent.click(screen.getByTestId("clinical-math-panel-toggle")); + fireEvent.change(screen.getByLabelText("Age (years)"), { target: { value: "72" } }); + fireEvent.change(screen.getByLabelText("Weight (kg)"), { target: { value: "64" } }); + + setDesktop(true); + expect(screen.getAllByTestId("clinical-math-panel-toggle")).toHaveLength(1); + expect(screen.getByLabelText("Age (years)")).toHaveValue(72); + expect(screen.getByLabelText("Weight (kg)")).toHaveValue(64); + + setDesktop(false); + expect(screen.getByLabelText("Age (years)")).toHaveValue(72); + }); +}); From a7e6bc1f5563f4a27b1511c4f31f16cf76858de1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:03:56 +0000 Subject: [PATCH 3/7] fix(site-content): a sign-off must name the record it publishes A review matched by slug alias could carry a current pin for a different record. The plan now requires the review's own id (service) or code (form) to be the record being published. The curated service records are loaded from the repository root rather than the working directory. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016imb8DCuk85z3FWRDDyZx5 --- .../publish-site-content-first-catalogue.ts | 19 ++++++++++++++++--- ...site-content-first-publication-cli.test.ts | 12 ++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/scripts/publish-site-content-first-catalogue.ts b/scripts/publish-site-content-first-catalogue.ts index f0691750eb..808d8a0a89 100644 --- a/scripts/publish-site-content-first-catalogue.ts +++ b/scripts/publish-site-content-first-catalogue.ts @@ -27,8 +27,8 @@ */ import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { isNamedPerson } from "@/lib/admin/rule-sign-off"; import { formRecords, type FormRecord } from "@/lib/forms"; @@ -124,6 +124,8 @@ export type FirstPublicationCliOptions = { formsReviewPath: string; }; +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; function isValidReviewTimestamp(value: string | null | undefined): boolean { @@ -198,6 +200,17 @@ function validateSignOffEntry( reason: `reviewedContentSha256 ${JSON.stringify(review.reviewedContentSha256)} is missing or not a valid 64-character hex SHA-256 digest`, }; } + // The pin covers the record its own id (service) or code (form) names, so that must be the + // record being published, not one the review was only matched to through a slug alias. + const reviewIdentity = kind === "service" ? review.id : review.code; + if (typeof reviewIdentity !== "string" || reviewIdentity.trim().toLowerCase() !== recordId.trim().toLowerCase()) { + return { + kind, + recordId, + slug, + reason: `its review names ${JSON.stringify(reviewIdentity ?? null)}, not this record, so its sign-off does not cover it`, + }; + } const pin = pinState(kind, review); if (pin === "unverifiable") { return { @@ -494,7 +507,7 @@ export async function runFirstPublicationCli( serviceReviews: loadDefaultServiceReviews(options.servicesReviewPath), formReviews: loadDefaultFormReviews(options.formsReviewPath), serviceSignOffContext: - options.kind === "form" ? undefined : await loadServiceSignOffContext("service", process.cwd()), + options.kind === "form" ? undefined : await loadServiceSignOffContext("service", REPOSITORY_ROOT), }); if (options.out) { diff --git a/tests/site-content-first-publication-cli.test.ts b/tests/site-content-first-publication-cli.test.ts index 3aef1f7f34..903a471a20 100644 --- a/tests/site-content-first-publication-cli.test.ts +++ b/tests/site-content-first-publication-cli.test.ts @@ -152,6 +152,18 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { ).toThrow(/content changed since sign-off/); }); + it("refuses a current sign-off that reaches a different form only through a slug alias", () => { + const otherForm = formRecords.find((form) => formCodeForRecord(form) !== "1A")!; + const signed = repositoryFormReview("1A"); + expect(() => + buildFirstPublicationCataloguePlan({ + kind: "form", + forms: [otherForm], + formReviews: [{ ...signed, slug: otherForm.slug }], + }), + ).toThrow(/its review names "1A", not this record/); + }); + it("refuses a service sign-off when the curated records it pins were not loaded", () => { const sampleService = defaultServiceRecords()[0]!; const [review] = signedServiceReviews(); From 5ebcb550ed05732e4a891611922bf486042d09cd Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:12:03 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix:=204=20findings=20=E2=80=94=20Reject=20?= =?UTF-8?q?unsigned=20merged=20service=20details;=20Require=20matchi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject unsigned merged service details - Require matching reviewed record identities - Resolve curated data from project root - Separate missing-content errors from real drift --- .../publish-site-content-first-catalogue.ts | 76 ++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/scripts/publish-site-content-first-catalogue.ts b/scripts/publish-site-content-first-catalogue.ts index 808d8a0a89..b17e8df707 100644 --- a/scripts/publish-site-content-first-catalogue.ts +++ b/scripts/publish-site-content-first-catalogue.ts @@ -27,7 +27,7 @@ */ import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isNamedPerson } from "@/lib/admin/rule-sign-off"; @@ -47,7 +47,7 @@ import { import { compareCanonicalSiteContentIdentifiers, siteContentValueHash } from "@/lib/site-content/site-content-manifest"; import { reconciliationHash } from "@/lib/site-content/site-content-reconciliation"; -import { recordPinState } from "./lib/clinical-record-review-contract.mjs"; +import { recordContentSha256 } from "./lib/clinical-record-review-contract.mjs"; import { loadContext as loadServiceSignOffContext } from "./lib/signoff-kinds/services.mjs"; export type FirstPublicationKind = "service" | "form" | "all"; @@ -68,7 +68,11 @@ export type ClinicalSignOffEntry = { */ export type SignOffPinState = "current" | "stale" | "unsigned" | "unverifiable"; -export type SignOffPinCheck = (kind: "service" | "form", review: ClinicalSignOffEntry) => SignOffPinState; +export type SignOffPinCheck = ( + kind: "service" | "form", + review: ClinicalSignOffEntry, + record?: ServiceRecord | FormRecord, +) => SignOffPinState | { state: SignOffPinState; reason?: string }; export type FirstPublicationRejectedRecord = { kind: "service" | "form"; @@ -124,8 +128,6 @@ export type FirstPublicationCliOptions = { formsReviewPath: string; }; -const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; function isValidReviewTimestamp(value: string | null | undefined): boolean { @@ -143,13 +145,36 @@ export function createSignOffPinCheck( options: { serviceContext?: object; formsCatalogPath?: string } = {}, ): SignOffPinCheck { let formsCatalog: unknown; - return (kind, review) => { - if (kind === "service") { - if (!options.serviceContext) return "unverifiable"; - return recordPinState(review, "service", options.serviceContext); + return (kind, review, record) => { + try { + if (kind === "service") { + if (!options.serviceContext || !record) return { state: "unverifiable", reason: "service record is unavailable" }; + const context = options.serviceContext as { services?: Record }; + const stableId = String((record.catalogPayload as { stableId?: string } | undefined)?.stableId ?? "").trim(); + if (!stableId || !context.services?.[stableId]) { + return { state: "unverifiable", reason: `no curated service record exists for ${stableId || "the selected record"}` }; + } + // The curated service pin does not attest legacy catalogue fields (notably cost). + // Refuse merged rows rather than allowing those fields to reach publication. + if (typeof record.cost === "string" && record.cost.trim()) { + return { state: "unverifiable", reason: "selected service contains legacy cost content outside the curated sign-off" }; + } + return { + state: recordContentSha256({ ...review, id: stableId }, "service", options.serviceContext) === review.reviewedContentSha256 + ? "current" + : "stale", + }; + } + formsCatalog ??= JSON.parse(readFileSync(resolve(options.formsCatalogPath ?? "data/forms-catalog.json"), "utf8")); + if (!record) return { state: "unverifiable", reason: "form record is unavailable" }; + return { + state: recordContentSha256(review, "form", { catalog: formsCatalog }) === review.reviewedContentSha256 + ? "current" + : "stale", + }; + } catch (error) { + return { state: "unverifiable", reason: error instanceof Error ? error.message : String(error) }; } - formsCatalog ??= JSON.parse(readFileSync(resolve(options.formsCatalogPath ?? "data/forms-catalog.json"), "utf8")); - return recordPinState(review, "form", { catalog: formsCatalog }); }; } @@ -159,6 +184,7 @@ function validateSignOffEntry( slug: string, review: ClinicalSignOffEntry | undefined, pinState: SignOffPinCheck, + record: ServiceRecord | FormRecord, ): FirstPublicationRejectedRecord | null { if (!review) { return { @@ -200,24 +226,20 @@ function validateSignOffEntry( reason: `reviewedContentSha256 ${JSON.stringify(review.reviewedContentSha256)} is missing or not a valid 64-character hex SHA-256 digest`, }; } - // The pin covers the record its own id (service) or code (form) names, so that must be the - // record being published, not one the review was only matched to through a slug alias. - const reviewIdentity = kind === "service" ? review.id : review.code; - if (typeof reviewIdentity !== "string" || reviewIdentity.trim().toLowerCase() !== recordId.trim().toLowerCase()) { - return { - kind, - recordId, - slug, - reason: `its review names ${JSON.stringify(reviewIdentity ?? null)}, not this record, so its sign-off does not cover it`, - }; + const reviewedIdentity = kind === "service" ? review.id : review.code ?? review.id; + if (typeof reviewedIdentity !== "string" || reviewedIdentity.trim().toLowerCase() !== recordId.trim().toLowerCase()) { + return { kind, recordId, slug, reason: "clinical review identifies a different publication record" }; } - const pin = pinState(kind, review); + const pinResult = pinState(kind, review, record); + const pin = typeof pinResult === "string" ? pinResult : pinResult.state; if (pin === "unverifiable") { return { kind, recordId, slug, - reason: "the content its sign-off covers could not be loaded, so the content pin could not be checked", + reason: typeof pinResult === "string" + ? "the content its sign-off covers could not be loaded, so the content pin could not be checked" + : `the signed content could not be loaded: ${pinResult.reason ?? "unknown error"}`, }; } if (pin !== "current") { @@ -304,7 +326,7 @@ export function buildFirstPublicationCataloguePlan( serviceReviewByKey.get(stableId.toLowerCase()) ?? serviceReviewByKey.get(service.slug.trim().toLowerCase()) ?? (serviceWithId.id ? serviceReviewByKey.get(serviceWithId.id.trim().toLowerCase()) : undefined); - const problem = validateSignOffEntry("service", stableId, service.slug, review, pinState); + const problem = validateSignOffEntry("service", stableId, service.slug, review, pinState, service); if (problem) { rejected.push(problem); continue; @@ -329,7 +351,7 @@ export function buildFirstPublicationCataloguePlan( formReviewByKey.get(formCode.toLowerCase()) ?? formReviewByKey.get(form.slug.trim().toLowerCase()) ?? formReviewByKey.get(formRecordId.toLowerCase()); - const problem = validateSignOffEntry("form", formCode, form.slug, review, pinState); + const problem = validateSignOffEntry("form", formCode, form.slug, review, pinState, form); if (problem) { rejected.push(problem); continue; @@ -507,7 +529,9 @@ export async function runFirstPublicationCli( serviceReviews: loadDefaultServiceReviews(options.servicesReviewPath), formReviews: loadDefaultFormReviews(options.formsReviewPath), serviceSignOffContext: - options.kind === "form" ? undefined : await loadServiceSignOffContext("service", REPOSITORY_ROOT), + options.kind === "form" + ? undefined + : await loadServiceSignOffContext("service", resolve(fileURLToPath(new URL("..", import.meta.url)))), }); if (options.out) { From 11f59614e18732fa7830d7757a35c1021d408f57 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:12:06 +0000 Subject: [PATCH 5/7] fix: Make calculator panel props mutually exclusive --- .../calculators/clinical-math-panel.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/calculators/clinical-math-panel.tsx b/src/components/calculators/clinical-math-panel.tsx index fe5733e793..0e327fceb2 100644 --- a/src/components/calculators/clinical-math-panel.tsx +++ b/src/components/calculators/clinical-math-panel.tsx @@ -94,13 +94,25 @@ export function useClinicalMathPanelState({ defaultOpen = false, defaultTab = "r export type ClinicalMathPanelState = ReturnType; -export function ClinicalMathPanel({ - defaultOpen = false, - defaultTab = "renal", - state, -}: ClinicalMathPanelProps & { state?: ClinicalMathPanelState }) { - const panelId = useId(); +type ClinicalMathPanelControlledProps = { state: ClinicalMathPanelState; defaultOpen?: never; defaultTab?: never }; +type ClinicalMathPanelUncontrolledProps = ClinicalMathPanelProps & { state?: never }; + +export function ClinicalMathPanel(props: ClinicalMathPanelControlledProps | ClinicalMathPanelUncontrolledProps) { + if ("state" in props) return ; + return ; +} + +function UncontrolledClinicalMathPanel({ defaultOpen = false, defaultTab = "renal" }: ClinicalMathPanelProps) { const ownState = useClinicalMathPanelState({ defaultOpen, defaultTab }); + return ; +} + +function ControlledClinicalMathPanel({ state }: ClinicalMathPanelControlledProps) { + return ; +} + +function ClinicalMathPanelContent({ state }: { state: ClinicalMathPanelState }) { + const panelId = useId(); const { open, setOpen, @@ -132,7 +144,7 @@ export function ClinicalMathPanel({ setTargetLevel, tabletIncrementMg, setTabletIncrementMg, - } = state ?? ownState; + } = state; const parsedAge = parseStrictNumber(age); const parsedWeight = parseStrictNumber(weightKg); From 11d42e2296f4040ee829a0c982c815cc5f7a9b06 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:12:10 +0000 Subject: [PATCH 6/7] fix: Isolate calculator state updates --- src/components/calculators/search-page.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 16587ff2cb..c051759088 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -329,6 +329,16 @@ function useOptionalSearchParams() { } } +function ClinicalMathPanels({ isDesktopSidebar }: { isDesktopSidebar: boolean }) { + const state = useClinicalMathPanelState(); + return ( + <> + {isDesktopSidebar ? : null} + {!isDesktopSidebar ? : null} + + ); +} + export function CalculatorsSearchPage({ initialQuery = "", initialCalculatorId, @@ -345,7 +355,6 @@ export function CalculatorsSearchPage({ () => false, ); const isDesktopSidebar = useSyncExternalStore(subscribeDesktopSidebar, getDesktopSidebarSnapshot, () => false); - const clinicalMath = useClinicalMathPanelState(); const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery; const normalizedQuery = normalizeCalculatorQuery(query); const smartExpansions = useMemo(() => smartSearchExpansions("calculators", query), [query]); @@ -606,14 +615,14 @@ export function CalculatorsSearchPage({ sidebar={ <> - {isDesktopSidebar ? : null} + {isDesktopSidebar ? : null} } sidebarMobile={
- {!isDesktopSidebar ? : null} + {!isDesktopSidebar ? : null}
} From cfcffe1ae6be7b83d3966bab0623a1084e0b9bed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:13:56 +0000 Subject: [PATCH 7/7] Revert unreviewed Qodo Fixer commits on the sign-off and calculator follow-ups The bot's search-page change gave each layout its own calculator state again, so entries were lost across the 1280px resize (the fix this PR exists for), and its publish-script rewrite changed refusal behaviour (a new legacy-cost refusal) without review. Restores the reviewed a7e6bc1f5 code. Reverts 5ebcb550e, 11f59614e and 11d42e229. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016imb8DCuk85z3FWRDDyZx5 --- .../publish-site-content-first-catalogue.ts | 76 +++++++------------ .../calculators/clinical-math-panel.tsx | 26 ++----- src/components/calculators/search-page.tsx | 15 +--- 3 files changed, 36 insertions(+), 81 deletions(-) diff --git a/scripts/publish-site-content-first-catalogue.ts b/scripts/publish-site-content-first-catalogue.ts index b17e8df707..808d8a0a89 100644 --- a/scripts/publish-site-content-first-catalogue.ts +++ b/scripts/publish-site-content-first-catalogue.ts @@ -27,7 +27,7 @@ */ import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isNamedPerson } from "@/lib/admin/rule-sign-off"; @@ -47,7 +47,7 @@ import { import { compareCanonicalSiteContentIdentifiers, siteContentValueHash } from "@/lib/site-content/site-content-manifest"; import { reconciliationHash } from "@/lib/site-content/site-content-reconciliation"; -import { recordContentSha256 } from "./lib/clinical-record-review-contract.mjs"; +import { recordPinState } from "./lib/clinical-record-review-contract.mjs"; import { loadContext as loadServiceSignOffContext } from "./lib/signoff-kinds/services.mjs"; export type FirstPublicationKind = "service" | "form" | "all"; @@ -68,11 +68,7 @@ export type ClinicalSignOffEntry = { */ export type SignOffPinState = "current" | "stale" | "unsigned" | "unverifiable"; -export type SignOffPinCheck = ( - kind: "service" | "form", - review: ClinicalSignOffEntry, - record?: ServiceRecord | FormRecord, -) => SignOffPinState | { state: SignOffPinState; reason?: string }; +export type SignOffPinCheck = (kind: "service" | "form", review: ClinicalSignOffEntry) => SignOffPinState; export type FirstPublicationRejectedRecord = { kind: "service" | "form"; @@ -128,6 +124,8 @@ export type FirstPublicationCliOptions = { formsReviewPath: string; }; +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; function isValidReviewTimestamp(value: string | null | undefined): boolean { @@ -145,36 +143,13 @@ export function createSignOffPinCheck( options: { serviceContext?: object; formsCatalogPath?: string } = {}, ): SignOffPinCheck { let formsCatalog: unknown; - return (kind, review, record) => { - try { - if (kind === "service") { - if (!options.serviceContext || !record) return { state: "unverifiable", reason: "service record is unavailable" }; - const context = options.serviceContext as { services?: Record }; - const stableId = String((record.catalogPayload as { stableId?: string } | undefined)?.stableId ?? "").trim(); - if (!stableId || !context.services?.[stableId]) { - return { state: "unverifiable", reason: `no curated service record exists for ${stableId || "the selected record"}` }; - } - // The curated service pin does not attest legacy catalogue fields (notably cost). - // Refuse merged rows rather than allowing those fields to reach publication. - if (typeof record.cost === "string" && record.cost.trim()) { - return { state: "unverifiable", reason: "selected service contains legacy cost content outside the curated sign-off" }; - } - return { - state: recordContentSha256({ ...review, id: stableId }, "service", options.serviceContext) === review.reviewedContentSha256 - ? "current" - : "stale", - }; - } - formsCatalog ??= JSON.parse(readFileSync(resolve(options.formsCatalogPath ?? "data/forms-catalog.json"), "utf8")); - if (!record) return { state: "unverifiable", reason: "form record is unavailable" }; - return { - state: recordContentSha256(review, "form", { catalog: formsCatalog }) === review.reviewedContentSha256 - ? "current" - : "stale", - }; - } catch (error) { - return { state: "unverifiable", reason: error instanceof Error ? error.message : String(error) }; + return (kind, review) => { + if (kind === "service") { + if (!options.serviceContext) return "unverifiable"; + return recordPinState(review, "service", options.serviceContext); } + formsCatalog ??= JSON.parse(readFileSync(resolve(options.formsCatalogPath ?? "data/forms-catalog.json"), "utf8")); + return recordPinState(review, "form", { catalog: formsCatalog }); }; } @@ -184,7 +159,6 @@ function validateSignOffEntry( slug: string, review: ClinicalSignOffEntry | undefined, pinState: SignOffPinCheck, - record: ServiceRecord | FormRecord, ): FirstPublicationRejectedRecord | null { if (!review) { return { @@ -226,20 +200,24 @@ function validateSignOffEntry( reason: `reviewedContentSha256 ${JSON.stringify(review.reviewedContentSha256)} is missing or not a valid 64-character hex SHA-256 digest`, }; } - const reviewedIdentity = kind === "service" ? review.id : review.code ?? review.id; - if (typeof reviewedIdentity !== "string" || reviewedIdentity.trim().toLowerCase() !== recordId.trim().toLowerCase()) { - return { kind, recordId, slug, reason: "clinical review identifies a different publication record" }; + // The pin covers the record its own id (service) or code (form) names, so that must be the + // record being published, not one the review was only matched to through a slug alias. + const reviewIdentity = kind === "service" ? review.id : review.code; + if (typeof reviewIdentity !== "string" || reviewIdentity.trim().toLowerCase() !== recordId.trim().toLowerCase()) { + return { + kind, + recordId, + slug, + reason: `its review names ${JSON.stringify(reviewIdentity ?? null)}, not this record, so its sign-off does not cover it`, + }; } - const pinResult = pinState(kind, review, record); - const pin = typeof pinResult === "string" ? pinResult : pinResult.state; + const pin = pinState(kind, review); if (pin === "unverifiable") { return { kind, recordId, slug, - reason: typeof pinResult === "string" - ? "the content its sign-off covers could not be loaded, so the content pin could not be checked" - : `the signed content could not be loaded: ${pinResult.reason ?? "unknown error"}`, + reason: "the content its sign-off covers could not be loaded, so the content pin could not be checked", }; } if (pin !== "current") { @@ -326,7 +304,7 @@ export function buildFirstPublicationCataloguePlan( serviceReviewByKey.get(stableId.toLowerCase()) ?? serviceReviewByKey.get(service.slug.trim().toLowerCase()) ?? (serviceWithId.id ? serviceReviewByKey.get(serviceWithId.id.trim().toLowerCase()) : undefined); - const problem = validateSignOffEntry("service", stableId, service.slug, review, pinState, service); + const problem = validateSignOffEntry("service", stableId, service.slug, review, pinState); if (problem) { rejected.push(problem); continue; @@ -351,7 +329,7 @@ export function buildFirstPublicationCataloguePlan( formReviewByKey.get(formCode.toLowerCase()) ?? formReviewByKey.get(form.slug.trim().toLowerCase()) ?? formReviewByKey.get(formRecordId.toLowerCase()); - const problem = validateSignOffEntry("form", formCode, form.slug, review, pinState, form); + const problem = validateSignOffEntry("form", formCode, form.slug, review, pinState); if (problem) { rejected.push(problem); continue; @@ -529,9 +507,7 @@ export async function runFirstPublicationCli( serviceReviews: loadDefaultServiceReviews(options.servicesReviewPath), formReviews: loadDefaultFormReviews(options.formsReviewPath), serviceSignOffContext: - options.kind === "form" - ? undefined - : await loadServiceSignOffContext("service", resolve(fileURLToPath(new URL("..", import.meta.url)))), + options.kind === "form" ? undefined : await loadServiceSignOffContext("service", REPOSITORY_ROOT), }); if (options.out) { diff --git a/src/components/calculators/clinical-math-panel.tsx b/src/components/calculators/clinical-math-panel.tsx index 0e327fceb2..fe5733e793 100644 --- a/src/components/calculators/clinical-math-panel.tsx +++ b/src/components/calculators/clinical-math-panel.tsx @@ -94,25 +94,13 @@ export function useClinicalMathPanelState({ defaultOpen = false, defaultTab = "r export type ClinicalMathPanelState = ReturnType; -type ClinicalMathPanelControlledProps = { state: ClinicalMathPanelState; defaultOpen?: never; defaultTab?: never }; -type ClinicalMathPanelUncontrolledProps = ClinicalMathPanelProps & { state?: never }; - -export function ClinicalMathPanel(props: ClinicalMathPanelControlledProps | ClinicalMathPanelUncontrolledProps) { - if ("state" in props) return ; - return ; -} - -function UncontrolledClinicalMathPanel({ defaultOpen = false, defaultTab = "renal" }: ClinicalMathPanelProps) { - const ownState = useClinicalMathPanelState({ defaultOpen, defaultTab }); - return ; -} - -function ControlledClinicalMathPanel({ state }: ClinicalMathPanelControlledProps) { - return ; -} - -function ClinicalMathPanelContent({ state }: { state: ClinicalMathPanelState }) { +export function ClinicalMathPanel({ + defaultOpen = false, + defaultTab = "renal", + state, +}: ClinicalMathPanelProps & { state?: ClinicalMathPanelState }) { const panelId = useId(); + const ownState = useClinicalMathPanelState({ defaultOpen, defaultTab }); const { open, setOpen, @@ -144,7 +132,7 @@ function ClinicalMathPanelContent({ state }: { state: ClinicalMathPanelState }) setTargetLevel, tabletIncrementMg, setTabletIncrementMg, - } = state; + } = state ?? ownState; const parsedAge = parseStrictNumber(age); const parsedWeight = parseStrictNumber(weightKg); diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index c051759088..16587ff2cb 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -329,16 +329,6 @@ function useOptionalSearchParams() { } } -function ClinicalMathPanels({ isDesktopSidebar }: { isDesktopSidebar: boolean }) { - const state = useClinicalMathPanelState(); - return ( - <> - {isDesktopSidebar ? : null} - {!isDesktopSidebar ? : null} - - ); -} - export function CalculatorsSearchPage({ initialQuery = "", initialCalculatorId, @@ -355,6 +345,7 @@ export function CalculatorsSearchPage({ () => false, ); const isDesktopSidebar = useSyncExternalStore(subscribeDesktopSidebar, getDesktopSidebarSnapshot, () => false); + const clinicalMath = useClinicalMathPanelState(); const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery; const normalizedQuery = normalizeCalculatorQuery(query); const smartExpansions = useMemo(() => smartSearchExpansions("calculators", query), [query]); @@ -615,14 +606,14 @@ export function CalculatorsSearchPage({ sidebar={ <> - {isDesktopSidebar ? : null} + {isDesktopSidebar ? : null} } sidebarMobile={
- {!isDesktopSidebar ? : null} + {!isDesktopSidebar ? : null}
}