@@ -254,6 +322,16 @@ export function QcDashboard({
{!c.reviewed && c.effectiveVerdict !== "PASS" && (
)}
+ {c.gate.state !== "RELEASE" && (
+
+ {c.gate.state}
+
+ )}
{c.specimenId}
@@ -305,6 +383,64 @@ export function QcDashboard({
);
}
+function GateSummary({ detail }: { detail: Detail }) {
+ const tiles = [
+ {
+ state: "RELEASE" as const,
+ label: "Release-ready",
+ value: detail.summary.gates.RELEASE,
+ icon:
,
+ className: "text-pass",
+ },
+ {
+ state: "RETRY" as const,
+ label: "Bounded retry",
+ value: detail.summary.gates.RETRY,
+ icon:
,
+ className: "text-review",
+ },
+ {
+ state: "HOLD" as const,
+ label: "Expert hold",
+ value: detail.summary.gates.HOLD,
+ icon:
,
+ className: "text-fail",
+ },
+ ];
+ return (
+
+
+
+
+ Autonomous release gate
+
+ Versioned policy combines sample identity, hash-chained provenance, and vision metrics.
+
+
+
spatial-qc-v1
+
+
+
+
+ {tiles.map((tile) => (
+
+
+ {tile.icon}
+ {tile.label}
+
+
{tile.value}
+
+ ))}
+
+
+ Identity and provenance failures always fail closed. Only metric exceptions can be
+ accepted by a domain expert, and a written rationale is required.
+
+
+
+ );
+}
+
function SummaryTile({
icon,
label,
@@ -343,21 +479,24 @@ function CoreDetail({
onSaved: () => void;
}) {
const [note, setNote] = useState(core.note ?? "");
+ const [saveMessage, setSaveMessage] = useState
(null);
const [pending, startTransition] = useTransition();
function save(verdict: "PASS" | "FAIL") {
startTransition(async () => {
- await overrideCore(core.qcId, verdict, note);
- onSaved();
+ const result = await overrideCore(core.qcId, verdict, note);
+ setSaveMessage(result.message);
+ if (result.ok) onSaved();
});
}
const metrics = [
- { label: "Focus score", value: core.focusScore, kind: "unit" as const, good: core.focusScore >= 0.8 },
- { label: "Marker completeness", value: core.markerCompleteness, kind: "unit" as const, good: core.markerCompleteness >= 0.9 },
- { label: "Segmentation sanity", value: core.segSanity, kind: "unit" as const, good: core.segSanity >= 0.8 },
- { label: "Saturation", value: core.saturationPct, kind: "pct" as const, good: core.saturationPct < 5 },
- { label: "Tissue loss", value: core.tissueLossPct, kind: "pct" as const, good: core.tissueLossPct < 10 },
+ { label: "Focus score", value: core.focusScore, kind: "unit" as const, good: core.focusScore >= VISION_POLICY.thresholds.focusScoreMin },
+ { label: "Marker completeness", value: core.markerCompleteness, kind: "unit" as const, good: core.markerCompleteness >= VISION_POLICY.thresholds.markerCompletenessMin },
+ { label: "Segmentation sanity", value: core.segSanity, kind: "unit" as const, good: core.segSanity >= VISION_POLICY.thresholds.segSanityMin },
+ { label: "Saturation", value: core.saturationPct, kind: "pct" as const, good: core.saturationPct <= VISION_POLICY.thresholds.saturationPctMax },
+ { label: "Tissue loss", value: core.tissueLossPct, kind: "pct" as const, good: core.tissueLossPct <= VISION_POLICY.thresholds.tissueLossPctMax },
+ { label: "Fold probability", value: core.foldScore, kind: "unit" as const, good: core.foldScore <= VISION_POLICY.thresholds.foldScoreMax },
];
return (
@@ -379,6 +518,75 @@ function CoreDetail({
)}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index 45ea2e2..f989817 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -41,6 +41,13 @@ export function StatusBadge({ status }: { status: string }) {
SCANNED: { variant: "pass", label: "Scanned" },
SCANNING: { variant: "review", label: "Scanning" },
PENDING: { variant: "outline", label: "Pending" },
+ RELEASE: { variant: "pass", label: "Release" },
+ RETRY: { variant: "review", label: "Retry" },
+ HOLD: { variant: "fail", label: "Hold" },
+ MATCH: { variant: "pass", label: "Match" },
+ MISMATCH: { variant: "fail", label: "Mismatch" },
+ LOW_CONFIDENCE: { variant: "review", label: "Low confidence" },
+ MISSING: { variant: "outline", label: "Missing" },
};
const cfg = map[status] ?? { variant: "default" as const, label: status };
return
{cfg.label};
diff --git a/src/lib/provenance.test.ts b/src/lib/provenance.test.ts
new file mode 100644
index 0000000..6a81025
--- /dev/null
+++ b/src/lib/provenance.test.ts
@@ -0,0 +1,172 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ appendProvenanceEvent,
+ imageManifestSha256,
+ verifyAcquisitionEvidence,
+ verifyProvenanceChain,
+} from "./provenance";
+
+function chain() {
+ const base = {
+ specimenId: "SPC-0001",
+ coreId: "SPC-0001-C01",
+ slideId: "TMA-A-mIF",
+ assayRunId: "AR-mIF-014",
+ occurredAt: "2026-07-01T12:00:00.000Z",
+ };
+ const first = appendProvenanceEvent(
+ {
+ ...base,
+ id: "event-1",
+ sequence: 1,
+ eventType: "SPECIMEN_ID_BOUND",
+ actor: "accession.station",
+ payload: { expectedCoreId: base.coreId },
+ },
+ null
+ );
+ const second = appendProvenanceEvent(
+ {
+ ...base,
+ id: "event-2",
+ sequence: 2,
+ eventType: "IMAGE_ACQUIRED",
+ actor: "scanner.synthetic",
+ payload: { acquisitionId: "ACQ-001" },
+ },
+ first.eventHash
+ );
+ return [first, second];
+}
+
+test("verifies an intact acquisition chain", () => {
+ const result = verifyProvenanceChain(chain());
+ assert.equal(result.valid, true);
+ assert.equal(result.eventCount, 2);
+ assert.equal(result.head, chain()[1].eventHash);
+});
+
+test("detects payload tampering", () => {
+ const events = chain();
+ events[1] = { ...events[1], payloadJson: '{"acquisitionId":"other"}' };
+ const result = verifyProvenanceChain(events);
+ assert.equal(result.valid, false);
+ assert.match(result.errors.join(" "), /hash does not match/);
+});
+
+test("image manifest hashes are deterministic and bind acquisition metadata", () => {
+ const input = {
+ acquisitionId: "ACQ-001",
+ channel: "DAPI",
+ uri: "synthetic://image/DAPI.ome.tiff",
+ capturedAt: "2026-07-01T12:00:00.000Z",
+ };
+ assert.equal(imageManifestSha256(input), imageManifestSha256({ ...input }));
+ assert.notEqual(
+ imageManifestSha256(input),
+ imageManifestSha256({ ...input, channel: "CD8" })
+ );
+});
+
+test("verifies that chained payloads match image and QC records", () => {
+ const capturedAt = "2026-07-01T12:00:00.000Z";
+ const image = {
+ acquisitionId: "ACQ-001",
+ channel: "DAPI",
+ uri: "synthetic://image/DAPI.ome.tiff",
+ capturedAt,
+ manifestSha256: "",
+ };
+ image.manifestSha256 = imageManifestSha256({
+ acquisitionId: image.acquisitionId,
+ channel: image.channel,
+ uri: image.uri,
+ capturedAt: image.capturedAt,
+ });
+ const expected = {
+ specimenId: "SPC-0001",
+ coreId: "SPC-0001-C01",
+ slideId: "TMA-A-mIF",
+ assayRunId: "AR-mIF-014",
+ acquisitionId: "ACQ-001",
+ observedCoreId: "SPC-0001-C01",
+ observedSlideId: "TMA-A-mIF",
+ identityConfidence: 0.995,
+ metrics: {
+ focusScore: 0.95,
+ markerCompleteness: 0.98,
+ saturationPct: 1.2,
+ tissueLossPct: 2,
+ segSanity: 0.93,
+ foldScore: 0.04,
+ },
+ verdict: "PASS",
+ policyVersion: "spatial-qc-v1",
+ };
+ const definitions = [
+ {
+ eventType: "SPECIMEN_ID_BOUND",
+ payload: {
+ specimenId: expected.specimenId,
+ coreId: expected.coreId,
+ evidenceKind: "SYNTHETIC_DEMO",
+ },
+ },
+ {
+ eventType: "TMA_POSITION_BOUND",
+ payload: { tmaId: "TMA-A", row: 1, col: 2, evidenceKind: "SYNTHETIC_DEMO" },
+ },
+ {
+ eventType: "IMAGE_ACQUIRED",
+ payload: {
+ acquisitionId: expected.acquisitionId,
+ observedCoreId: expected.observedCoreId,
+ observedSlideId: expected.observedSlideId,
+ identityConfidence: expected.identityConfidence,
+ manifestHashes: [image.manifestSha256],
+ evidenceKind: "SYNTHETIC_DEMO",
+ },
+ },
+ {
+ eventType: "VISION_QC_EVALUATED",
+ payload: {
+ metrics: expected.metrics,
+ verdict: expected.verdict,
+ policyVersion: expected.policyVersion,
+ evidenceKind: "SYNTHETIC_DEMO",
+ },
+ },
+ ];
+ let previousHash: string | null = null;
+ const events = definitions.map((definition, index) => {
+ const event = appendProvenanceEvent(
+ {
+ id: `event-${index + 1}`,
+ specimenId: expected.specimenId,
+ coreId: expected.coreId,
+ slideId: expected.slideId,
+ assayRunId: expected.assayRunId,
+ sequence: index + 1,
+ eventType: definition.eventType,
+ actor: "synthetic.fixture",
+ occurredAt: capturedAt,
+ payload: definition.payload,
+ },
+ previousHash
+ );
+ previousHash = event.eventHash;
+ return event;
+ });
+
+ const valid = verifyAcquisitionEvidence({ events, images: [image], expected });
+ assert.equal(valid.valid, true);
+
+ const changedQc = verifyAcquisitionEvidence({
+ events,
+ images: [image],
+ expected: { ...expected, metrics: { ...expected.metrics, focusScore: 0.2 } },
+ });
+ assert.equal(changedQc.valid, false);
+ assert.match(changedQc.errors.join(" "), /payload does not match/);
+});
diff --git a/src/lib/provenance.ts b/src/lib/provenance.ts
new file mode 100644
index 0000000..422aa4e
--- /dev/null
+++ b/src/lib/provenance.ts
@@ -0,0 +1,296 @@
+import { createHash } from "node:crypto";
+
+export type ProvenanceEventRecord = {
+ id: string;
+ specimenId: string;
+ coreId: string;
+ slideId: string;
+ assayRunId: string;
+ sequence: number;
+ eventType: string;
+ actor: string;
+ occurredAt: Date | string;
+ payloadJson: string;
+ previousHash: string | null;
+ eventHash: string;
+};
+
+type NewProvenanceEvent = Omit<
+ ProvenanceEventRecord,
+ "payloadJson" | "previousHash" | "eventHash"
+> & {
+ payload: unknown;
+};
+
+function normalize(value: unknown): unknown {
+ if (value instanceof Date) return value.toISOString();
+ if (Array.isArray(value)) return value.map(normalize);
+ if (value !== null && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value as Record
)
+ .filter(([, item]) => item !== undefined)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, item]) => [key, normalize(item)])
+ );
+ }
+ return value;
+}
+
+export function canonicalJson(value: unknown): string {
+ return JSON.stringify(normalize(value));
+}
+
+export function sha256Hex(value: string): string {
+ return createHash("sha256").update(value).digest("hex");
+}
+
+function eventHashInput(event: Omit) {
+ return canonicalJson({
+ id: event.id,
+ specimenId: event.specimenId,
+ coreId: event.coreId,
+ slideId: event.slideId,
+ assayRunId: event.assayRunId,
+ sequence: event.sequence,
+ eventType: event.eventType,
+ actor: event.actor,
+ occurredAt:
+ event.occurredAt instanceof Date
+ ? event.occurredAt.toISOString()
+ : event.occurredAt,
+ payloadJson: event.payloadJson,
+ previousHash: event.previousHash,
+ });
+}
+
+export function appendProvenanceEvent(
+ event: NewProvenanceEvent,
+ previousHash: string | null
+): ProvenanceEventRecord {
+ const stored: Omit = {
+ id: event.id,
+ specimenId: event.specimenId,
+ coreId: event.coreId,
+ slideId: event.slideId,
+ assayRunId: event.assayRunId,
+ sequence: event.sequence,
+ eventType: event.eventType,
+ actor: event.actor,
+ occurredAt: event.occurredAt,
+ payloadJson: canonicalJson(event.payload),
+ previousHash,
+ };
+ return { ...stored, eventHash: sha256Hex(eventHashInput(stored)) };
+}
+
+export function verifyProvenanceChain(events: ProvenanceEventRecord[]) {
+ const ordered = [...events].sort((left, right) => left.sequence - right.sequence);
+ const errors: string[] = [];
+ let previousHash: string | null = null;
+ const identity = ordered[0]
+ ? {
+ specimenId: ordered[0].specimenId,
+ coreId: ordered[0].coreId,
+ slideId: ordered[0].slideId,
+ assayRunId: ordered[0].assayRunId,
+ }
+ : null;
+
+ if (ordered.length === 0) errors.push("chain is empty");
+
+ for (const [index, event] of ordered.entries()) {
+ if (event.sequence !== index + 1) {
+ errors.push(`expected sequence ${index + 1}, received ${event.sequence}`);
+ }
+ if (event.previousHash !== previousHash) {
+ errors.push(`event ${event.sequence} does not link to the previous hash`);
+ }
+ if (
+ identity &&
+ (event.specimenId !== identity.specimenId ||
+ event.coreId !== identity.coreId ||
+ event.slideId !== identity.slideId ||
+ event.assayRunId !== identity.assayRunId)
+ ) {
+ errors.push(`event ${event.sequence} changes acquisition identity`);
+ }
+ try {
+ JSON.parse(event.payloadJson);
+ } catch {
+ errors.push(`event ${event.sequence} payload is not valid JSON`);
+ }
+ const expected = sha256Hex(
+ eventHashInput({
+ id: event.id,
+ specimenId: event.specimenId,
+ coreId: event.coreId,
+ slideId: event.slideId,
+ assayRunId: event.assayRunId,
+ sequence: event.sequence,
+ eventType: event.eventType,
+ actor: event.actor,
+ occurredAt: event.occurredAt,
+ payloadJson: event.payloadJson,
+ previousHash: event.previousHash,
+ })
+ );
+ if (event.eventHash !== expected) {
+ errors.push(`event ${event.sequence} hash does not match its contents`);
+ }
+ previousHash = event.eventHash;
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ eventCount: ordered.length,
+ head: ordered.at(-1)?.eventHash ?? null,
+ };
+}
+
+export function imageManifestSha256(input: {
+ acquisitionId: string;
+ channel: string;
+ uri: string;
+ capturedAt: Date | string;
+}): string {
+ return sha256Hex(
+ canonicalJson({
+ acquisitionId: input.acquisitionId,
+ channel: input.channel,
+ uri: input.uri,
+ capturedAt: input.capturedAt,
+ })
+ );
+}
+
+type AcquisitionImageRecord = {
+ acquisitionId: string | null;
+ channel: string;
+ uri: string;
+ capturedAt: Date | string | null;
+ manifestSha256: string | null;
+};
+
+type AcquisitionEvidence = {
+ specimenId: string;
+ coreId: string;
+ slideId: string;
+ assayRunId: string;
+ acquisitionId: string | null;
+ observedCoreId: string | null;
+ observedSlideId: string | null;
+ identityConfidence: number | null;
+ metrics: {
+ focusScore: number;
+ markerCompleteness: number;
+ saturationPct: number;
+ tissueLossPct: number;
+ segSanity: number;
+ foldScore: number;
+ };
+ verdict: string;
+ policyVersion: string;
+};
+
+function payloadRecord(event: ProvenanceEventRecord | undefined) {
+ if (!event) return null;
+ try {
+ const payload: unknown = JSON.parse(event.payloadJson);
+ return payload !== null && typeof payload === "object"
+ ? (payload as Record)
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function verifyAcquisitionEvidence(input: {
+ events: ProvenanceEventRecord[];
+ images: AcquisitionImageRecord[];
+ expected: AcquisitionEvidence;
+}) {
+ const chain = verifyProvenanceChain(input.events);
+ const ordered = [...input.events].sort((left, right) => left.sequence - right.sequence);
+ const expectedTypes = [
+ "SPECIMEN_ID_BOUND",
+ "TMA_POSITION_BOUND",
+ "IMAGE_ACQUIRED",
+ "VISION_QC_EVALUATED",
+ ];
+ const eventTypesValid =
+ ordered.length === expectedTypes.length &&
+ ordered.every((event, index) => event.eventType === expectedTypes[index]);
+ const topLevelIdentityValid = ordered.every(
+ (event) =>
+ event.specimenId === input.expected.specimenId &&
+ event.coreId === input.expected.coreId &&
+ event.slideId === input.expected.slideId &&
+ event.assayRunId === input.expected.assayRunId
+ );
+
+ const storedManifestHashes = input.images
+ .map((image) => image.manifestSha256)
+ .filter((value): value is string => value !== null)
+ .sort();
+ const manifestsValid =
+ input.images.length > 0 &&
+ input.images.every(
+ (image) =>
+ image.acquisitionId !== null &&
+ image.capturedAt !== null &&
+ image.manifestSha256 !== null &&
+ image.acquisitionId === input.expected.acquisitionId &&
+ image.manifestSha256 ===
+ imageManifestSha256({
+ acquisitionId: image.acquisitionId,
+ channel: image.channel,
+ uri: image.uri,
+ capturedAt: image.capturedAt,
+ })
+ );
+
+ const specimenPayload = payloadRecord(
+ ordered.find((event) => event.eventType === "SPECIMEN_ID_BOUND")
+ );
+ const imagePayload = payloadRecord(
+ ordered.find((event) => event.eventType === "IMAGE_ACQUIRED")
+ );
+ const qcPayload = payloadRecord(
+ ordered.find((event) => event.eventType === "VISION_QC_EVALUATED")
+ );
+ const payloads = ordered.map((event) => payloadRecord(event));
+ const bindingsValid =
+ specimenPayload?.specimenId === input.expected.specimenId &&
+ specimenPayload?.coreId === input.expected.coreId &&
+ imagePayload?.acquisitionId === input.expected.acquisitionId &&
+ imagePayload?.observedCoreId === input.expected.observedCoreId &&
+ imagePayload?.observedSlideId === input.expected.observedSlideId &&
+ imagePayload?.identityConfidence === input.expected.identityConfidence &&
+ canonicalJson(imagePayload?.manifestHashes) === canonicalJson(storedManifestHashes) &&
+ canonicalJson(qcPayload?.metrics) === canonicalJson(input.expected.metrics) &&
+ qcPayload?.verdict === input.expected.verdict &&
+ qcPayload?.policyVersion === input.expected.policyVersion;
+ const syntheticEvidenceLabeled = payloads.every(
+ (payload) => payload?.evidenceKind === "SYNTHETIC_DEMO"
+ );
+
+ const errors = [
+ ...chain.errors,
+ ...(eventTypesValid ? [] : ["acquisition event sequence is incomplete"]),
+ ...(topLevelIdentityValid ? [] : ["provenance identity does not match the acquisition"]),
+ ...(manifestsValid ? [] : ["image manifest verification failed"]),
+ ...(bindingsValid ? [] : ["provenance payload does not match acquisition evidence"]),
+ ...(syntheticEvidenceLabeled ? [] : ["synthetic evidence label is missing"]),
+ ];
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ eventCount: chain.eventCount,
+ head: chain.head,
+ chainValid: chain.valid,
+ manifestsValid,
+ bindingsValid,
+ };
+}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index a55b55f..11096c8 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -1,5 +1,12 @@
import { prisma } from "@/lib/prisma";
import { STAGES, type Stage } from "@/lib/constants";
+import { verifyAcquisitionEvidence } from "@/lib/provenance";
+import {
+ evaluateVisionGate,
+ GATE_REASON_LABELS,
+ VISION_POLICY,
+ type GateState,
+} from "@/lib/vision-gate";
const DAY = 24 * 60 * 60 * 1000;
@@ -228,6 +235,8 @@ export async function getRunDetail(runId: string) {
slides: {
include: {
tma: true,
+ images: { orderBy: [{ coreId: "asc" }, { channel: "asc" }] },
+ provenanceEvents: { orderBy: { sequence: "asc" } },
qcResults: {
include: { core: { include: { specimen: true } } },
orderBy: { coreId: "asc" },
@@ -240,35 +249,136 @@ export async function getRunDetail(runId: string) {
const slide = run.slides[0];
const qc = run.slides.flatMap((s) => s.qcResults);
+ const images = run.slides.flatMap((s) => s.images);
+ const events = run.slides.flatMap((s) => s.provenanceEvents);
- const cores = qc.map((q) => ({
- qcId: q.id,
- coreId: q.coreId ?? "",
- coreLabel: q.core?.label ?? "",
- specimenId: q.core?.specimenId ?? "",
- patientCode: q.core?.specimen.patientCode ?? "",
- indication: q.core?.specimen.indication ?? "",
- focusScore: q.focusScore,
- markerCompleteness: q.markerCompleteness,
- saturationPct: q.saturationPct,
- tissueLossPct: q.tissueLossPct,
- segSanity: q.segSanity,
- verdict: q.verdict,
- overrideVerdict: q.overrideVerdict,
- effectiveVerdict: effectiveVerdict(q),
- reviewed: q.reviewed,
- failureReason: q.failureReason,
- note: q.note,
- }));
+ const cores = qc.map((q) => {
+ const coreId = q.coreId ?? "";
+ const acquisitionImages = images.filter(
+ (image) => image.coreId === coreId && image.slideId === q.slideId
+ );
+ const acquisitionEvents = events.filter(
+ (event) => event.coreId === coreId && event.slideId === q.slideId
+ );
+ const evidence = verifyAcquisitionEvidence({
+ events: acquisitionEvents,
+ images: acquisitionImages,
+ expected: {
+ specimenId: q.core?.specimenId ?? "",
+ coreId,
+ slideId: q.slideId,
+ assayRunId: run.id,
+ acquisitionId: q.acquisitionId,
+ observedCoreId: q.observedCoreId,
+ observedSlideId: q.observedSlideId,
+ identityConfidence: q.identityConfidence,
+ metrics: {
+ focusScore: q.focusScore,
+ markerCompleteness: q.markerCompleteness,
+ saturationPct: q.saturationPct,
+ tissueLossPct: q.tissueLossPct,
+ segSanity: q.segSanity,
+ foldScore: q.foldScore,
+ },
+ verdict: q.verdict,
+ policyVersion: q.policyVersion,
+ },
+ });
+ const identityStatus: "MISSING" | "MISMATCH" | "LOW_CONFIDENCE" | "MATCH" =
+ !q.observedCoreId || !q.observedSlideId || q.identityConfidence === null
+ ? "MISSING"
+ : q.observedCoreId !== coreId || q.observedSlideId !== q.slideId
+ ? "MISMATCH"
+ : q.identityConfidence < VISION_POLICY.thresholds.identityConfidenceMin
+ ? "LOW_CONFIDENCE"
+ : "MATCH";
+ const gate = evaluateVisionGate({
+ policyVersion: q.policyVersion,
+ expectedCoreId: coreId,
+ expectedSlideId: q.slideId,
+ observedCoreId: q.observedCoreId,
+ observedSlideId: q.observedSlideId,
+ identityConfidence: q.identityConfidence,
+ provenanceValid: evidence.valid,
+ attempt: q.attempt,
+ metrics: {
+ focusScore: q.focusScore,
+ markerCompleteness: q.markerCompleteness,
+ saturationPct: q.saturationPct,
+ tissueLossPct: q.tissueLossPct,
+ segSanity: q.segSanity,
+ foldScore: q.foldScore,
+ },
+ expertOverride:
+ q.overrideVerdict === "PASS" || q.overrideVerdict === "FAIL"
+ ? q.overrideVerdict
+ : null,
+ expertNote: q.note,
+ });
+
+ return {
+ qcId: q.id,
+ coreId,
+ coreLabel: q.core?.label ?? "",
+ specimenId: q.core?.specimenId ?? "",
+ patientCode: q.core?.specimen.patientCode ?? "",
+ indication: q.core?.specimen.indication ?? "",
+ focusScore: q.focusScore,
+ markerCompleteness: q.markerCompleteness,
+ saturationPct: q.saturationPct,
+ tissueLossPct: q.tissueLossPct,
+ segSanity: q.segSanity,
+ foldScore: q.foldScore,
+ verdict: q.verdict,
+ overrideVerdict: q.overrideVerdict,
+ effectiveVerdict: effectiveVerdict(q),
+ reviewed: q.reviewed,
+ failureReason: q.failureReason,
+ note: q.note,
+ identity: {
+ status: identityStatus,
+ expectedCoreId: coreId,
+ observedCoreId: q.observedCoreId,
+ expectedSlideId: q.slideId,
+ observedSlideId: q.observedSlideId,
+ confidence: q.identityConfidence,
+ },
+ provenance: {
+ valid: evidence.valid,
+ chainValid: evidence.chainValid,
+ manifestsValid: evidence.manifestsValid,
+ eventCount: evidence.eventCount,
+ head: evidence.head,
+ errors: evidence.errors,
+ acquisitionId: q.acquisitionId,
+ capturedAt: acquisitionImages[0]?.capturedAt?.toISOString() ?? null,
+ },
+ gate: {
+ ...gate,
+ reasonLabels: gate.reasons.map((reason) => GATE_REASON_LABELS[reason]),
+ },
+ };
+ });
- const summary = { PASS: 0, FLAG: 0, FAIL: 0, needsReview: 0 };
+ const summary = {
+ PASS: 0,
+ FLAG: 0,
+ FAIL: 0,
+ needsReview: 0,
+ gates: { RELEASE: 0, RETRY: 0, HOLD: 0 } as Record,
+ releaseBlocked: 0,
+ };
const reasons = new Map();
for (const c of cores) {
summary[c.effectiveVerdict as "PASS" | "FLAG" | "FAIL"]++;
- if (!c.reviewed && c.effectiveVerdict !== "PASS") summary.needsReview++;
+ if (c.gate.state !== "RELEASE" || (!c.reviewed && c.effectiveVerdict !== "PASS")) {
+ summary.needsReview++;
+ }
if (c.failureReason && c.effectiveVerdict !== "PASS") {
reasons.set(c.failureReason, (reasons.get(c.failureReason) ?? 0) + 1);
}
+ summary.gates[c.gate.state]++;
+ if (c.gate.state !== "RELEASE") summary.releaseBlocked++;
}
return {
diff --git a/src/lib/vision-gate.test.ts b/src/lib/vision-gate.test.ts
new file mode 100644
index 0000000..505fa14
--- /dev/null
+++ b/src/lib/vision-gate.test.ts
@@ -0,0 +1,95 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ evaluateVisionGate,
+ VISION_POLICY,
+ type VisionGateInput,
+} from "./vision-gate";
+
+const passingInput: VisionGateInput = {
+ policyVersion: VISION_POLICY.version,
+ expectedCoreId: "SPC-0001-C01",
+ expectedSlideId: "TMA-A-mIF",
+ observedCoreId: "SPC-0001-C01",
+ observedSlideId: "TMA-A-mIF",
+ identityConfidence: 0.997,
+ provenanceValid: true,
+ attempt: 1,
+ metrics: {
+ focusScore: 0.95,
+ markerCompleteness: 0.98,
+ saturationPct: 1.2,
+ tissueLossPct: 2,
+ segSanity: 0.93,
+ foldScore: 0.04,
+ },
+};
+
+test("releases only when identity, provenance, and metrics pass", () => {
+ const result = evaluateVisionGate(passingInput);
+ assert.equal(result.state, "RELEASE");
+ assert.equal(result.recovery.action, "NONE");
+});
+
+test("identity mismatch is non-overridable and quarantines evidence", () => {
+ const result = evaluateVisionGate({
+ ...passingInput,
+ observedCoreId: "SPC-0002-C01",
+ expertOverride: "PASS",
+ expertNote: "Morphology confirms the expected specimen.",
+ });
+ assert.equal(result.state, "HOLD");
+ assert.equal(result.recovery.action, "QUARANTINE");
+ assert.equal(result.recovery.automatic, false);
+ assert.ok(result.reasons.includes("IDENTITY_MISMATCH"));
+});
+
+test("a recoverable focus failure gets one bounded rescan", () => {
+ const result = evaluateVisionGate({
+ ...passingInput,
+ metrics: { ...passingInput.metrics, focusScore: 0.62 },
+ });
+ assert.equal(result.state, "RETRY");
+ assert.equal(result.recovery.action, "RESCAN");
+ assert.equal(result.recovery.automatic, true);
+});
+
+test("retry exhaustion stops and escalates to an expert", () => {
+ const result = evaluateVisionGate({
+ ...passingInput,
+ attempt: 2,
+ metrics: { ...passingInput.metrics, focusScore: 0.62 },
+ });
+ assert.equal(result.state, "HOLD");
+ assert.equal(result.recovery.action, "EXPERT_REVIEW");
+ assert.ok(result.reasons.includes("RETRY_BUDGET_EXHAUSTED"));
+});
+
+test("irreversible tissue loss requires expert-authorized recutting", () => {
+ const result = evaluateVisionGate({
+ ...passingInput,
+ metrics: { ...passingInput.metrics, tissueLossPct: 18 },
+ });
+ assert.equal(result.state, "HOLD");
+ assert.equal(result.recovery.action, "RECUT");
+ assert.equal(result.recovery.requiresExpert, true);
+});
+
+test("metric overrides require rationale but cannot bypass safety gates", () => {
+ const noRationale = evaluateVisionGate({
+ ...passingInput,
+ metrics: { ...passingInput.metrics, segSanity: 0.6 },
+ expertOverride: "PASS",
+ });
+ assert.equal(noRationale.state, "HOLD");
+ assert.ok(noRationale.reasons.includes("EXPERT_RATIONALE_REQUIRED"));
+
+ const accepted = evaluateVisionGate({
+ ...passingInput,
+ metrics: { ...passingInput.metrics, segSanity: 0.6 },
+ expertOverride: "PASS",
+ expertNote: "Artifact is outside the tissue region used downstream.",
+ });
+ assert.equal(accepted.state, "RELEASE");
+ assert.ok(accepted.reasons.includes("EXPERT_OVERRIDE_RECORDED"));
+});
diff --git a/src/lib/vision-gate.ts b/src/lib/vision-gate.ts
new file mode 100644
index 0000000..9bcdf8f
--- /dev/null
+++ b/src/lib/vision-gate.ts
@@ -0,0 +1,285 @@
+export const VISION_POLICY = {
+ version: "spatial-qc-v1",
+ thresholds: {
+ focusScoreMin: 0.8,
+ markerCompletenessMin: 0.9,
+ saturationPctMax: 5,
+ tissueLossPctMax: 10,
+ segSanityMin: 0.8,
+ foldScoreMax: 0.25,
+ identityConfidenceMin: 0.98,
+ },
+ maxAttempts: 2,
+} as const;
+
+export type GateState = "RELEASE" | "RETRY" | "HOLD";
+
+export type GateReason =
+ | "POLICY_VERSION_MISMATCH"
+ | "IDENTITY_MISSING"
+ | "IDENTITY_MISMATCH"
+ | "IDENTITY_CONFIDENCE_LOW"
+ | "PROVENANCE_INVALID"
+ | "EXPERT_REJECTED"
+ | "EXPERT_RATIONALE_REQUIRED"
+ | "EXPERT_OVERRIDE_RECORDED"
+ | "FOCUS_LOW"
+ | "MARKER_DROPOUT"
+ | "SATURATION_HIGH"
+ | "TISSUE_LOSS_HIGH"
+ | "SEGMENTATION_UNSTABLE"
+ | "FOLD_DETECTED"
+ | "RETRY_BUDGET_EXHAUSTED";
+
+export type RecoveryAction =
+ | "NONE"
+ | "RESCAN"
+ | "REPROCESS"
+ | "RESTAIN"
+ | "RECUT"
+ | "QUARANTINE"
+ | "EXPERT_REVIEW";
+
+export type VisionGateInput = {
+ policyVersion: string;
+ expectedCoreId: string;
+ expectedSlideId: string;
+ observedCoreId: string | null;
+ observedSlideId: string | null;
+ identityConfidence: number | null;
+ provenanceValid: boolean;
+ attempt: number;
+ metrics: {
+ focusScore: number;
+ markerCompleteness: number;
+ saturationPct: number;
+ tissueLossPct: number;
+ segSanity: number;
+ foldScore: number;
+ };
+ expertOverride?: "PASS" | "FAIL" | null;
+ expertNote?: string | null;
+};
+
+export type VisionGateDecision = {
+ state: GateState;
+ reasons: GateReason[];
+ policyVersion: string;
+ recovery: {
+ action: RecoveryAction;
+ automatic: boolean;
+ requiresExpert: boolean;
+ attempt: number;
+ maxAttempts: number;
+ instruction: string;
+ };
+};
+
+export const GATE_REASON_LABELS: Record = {
+ POLICY_VERSION_MISMATCH: "QC policy version mismatch",
+ IDENTITY_MISSING: "Identity evidence missing",
+ IDENTITY_MISMATCH: "Observed label does not match expected sample",
+ IDENTITY_CONFIDENCE_LOW: "Identity confidence below release threshold",
+ PROVENANCE_INVALID: "Provenance chain or image manifest is invalid",
+ EXPERT_REJECTED: "Domain expert rejected this acquisition",
+ EXPERT_RATIONALE_REQUIRED: "A pass override requires an expert rationale",
+ EXPERT_OVERRIDE_RECORDED: "Domain expert accepted the metric exception",
+ FOCUS_LOW: "Focus below policy threshold",
+ MARKER_DROPOUT: "Marker completeness below policy threshold",
+ SATURATION_HIGH: "Saturation above policy threshold",
+ TISSUE_LOSS_HIGH: "Tissue loss above policy threshold",
+ SEGMENTATION_UNSTABLE: "Segmentation sanity below policy threshold",
+ FOLD_DETECTED: "Fold probability above policy threshold",
+ RETRY_BUDGET_EXHAUSTED: "Automatic retry budget exhausted",
+};
+
+function recovery(
+ state: GateState,
+ reasons: GateReason[],
+ action: RecoveryAction,
+ automatic: boolean,
+ requiresExpert: boolean,
+ attempt: number,
+ instruction: string
+): VisionGateDecision {
+ return {
+ state,
+ reasons,
+ policyVersion: VISION_POLICY.version,
+ recovery: {
+ action,
+ automatic,
+ requiresExpert,
+ attempt,
+ maxAttempts: VISION_POLICY.maxAttempts,
+ instruction,
+ },
+ };
+}
+
+export function evaluateVisionGate(input: VisionGateInput): VisionGateDecision {
+ const attempt = Math.max(1, input.attempt);
+ const safetyReasons: GateReason[] = [];
+
+ if (input.policyVersion !== VISION_POLICY.version) {
+ safetyReasons.push("POLICY_VERSION_MISMATCH");
+ }
+ if (
+ !input.observedCoreId ||
+ !input.observedSlideId ||
+ input.identityConfidence === null
+ ) {
+ safetyReasons.push("IDENTITY_MISSING");
+ } else {
+ if (
+ input.observedCoreId !== input.expectedCoreId ||
+ input.observedSlideId !== input.expectedSlideId
+ ) {
+ safetyReasons.push("IDENTITY_MISMATCH");
+ }
+ if (
+ input.identityConfidence <
+ VISION_POLICY.thresholds.identityConfidenceMin
+ ) {
+ safetyReasons.push("IDENTITY_CONFIDENCE_LOW");
+ }
+ }
+ if (!input.provenanceValid) safetyReasons.push("PROVENANCE_INVALID");
+
+ if (safetyReasons.length > 0) {
+ const quarantine = safetyReasons.some((reason) =>
+ ["IDENTITY_MISMATCH", "PROVENANCE_INVALID"].includes(reason)
+ );
+ return recovery(
+ "HOLD",
+ safetyReasons,
+ quarantine ? "QUARANTINE" : "EXPERT_REVIEW",
+ false,
+ true,
+ attempt,
+ quarantine
+ ? "Quarantine the acquisition. Reconcile physical labels and the hash chain; never relabel evidence automatically."
+ : "A domain expert must reconcile identity evidence and the active policy before release."
+ );
+ }
+
+ if (input.expertOverride === "FAIL") {
+ return recovery(
+ "HOLD",
+ ["EXPERT_REJECTED"],
+ "EXPERT_REVIEW",
+ false,
+ true,
+ attempt,
+ "Keep the acquisition on hold and follow the expert's recorded disposition."
+ );
+ }
+ if (input.expertOverride === "PASS") {
+ if ((input.expertNote?.trim().length ?? 0) < 8) {
+ return recovery(
+ "HOLD",
+ ["EXPERT_RATIONALE_REQUIRED"],
+ "EXPERT_REVIEW",
+ false,
+ true,
+ attempt,
+ "Record a concise scientific rationale before accepting a metric exception."
+ );
+ }
+ return recovery(
+ "RELEASE",
+ ["EXPERT_OVERRIDE_RECORDED"],
+ "NONE",
+ false,
+ false,
+ attempt,
+ "Release under the recorded expert rationale; identity and provenance gates remain non-overridable."
+ );
+ }
+
+ const metricReasons: GateReason[] = [];
+ const { metrics } = input;
+ if (metrics.focusScore < VISION_POLICY.thresholds.focusScoreMin) {
+ metricReasons.push("FOCUS_LOW");
+ }
+ if (
+ metrics.markerCompleteness <
+ VISION_POLICY.thresholds.markerCompletenessMin
+ ) {
+ metricReasons.push("MARKER_DROPOUT");
+ }
+ if (metrics.saturationPct > VISION_POLICY.thresholds.saturationPctMax) {
+ metricReasons.push("SATURATION_HIGH");
+ }
+ if (metrics.tissueLossPct > VISION_POLICY.thresholds.tissueLossPctMax) {
+ metricReasons.push("TISSUE_LOSS_HIGH");
+ }
+ if (metrics.segSanity < VISION_POLICY.thresholds.segSanityMin) {
+ metricReasons.push("SEGMENTATION_UNSTABLE");
+ }
+ if (metrics.foldScore > VISION_POLICY.thresholds.foldScoreMax) {
+ metricReasons.push("FOLD_DETECTED");
+ }
+
+ if (metricReasons.length === 0) {
+ return recovery(
+ "RELEASE",
+ [],
+ "NONE",
+ false,
+ false,
+ attempt,
+ "All identity, provenance, and vision policy gates passed."
+ );
+ }
+
+ if (metricReasons.includes("TISSUE_LOSS_HIGH") || metricReasons.includes("FOLD_DETECTED")) {
+ return recovery(
+ "HOLD",
+ metricReasons,
+ "RECUT",
+ false,
+ true,
+ attempt,
+ "A domain expert must assess remaining material before authorizing a new section."
+ );
+ }
+ if (metricReasons.includes("MARKER_DROPOUT")) {
+ return recovery(
+ "HOLD",
+ metricReasons,
+ "RESTAIN",
+ false,
+ true,
+ attempt,
+ "Review controls and tissue availability before authorizing a restain."
+ );
+ }
+
+ if (attempt >= VISION_POLICY.maxAttempts) {
+ return recovery(
+ "HOLD",
+ [...metricReasons, "RETRY_BUDGET_EXHAUSTED"],
+ "EXPERT_REVIEW",
+ false,
+ true,
+ attempt,
+ "Stop automatic recovery. A domain expert must choose the next disposition."
+ );
+ }
+
+ const reprocessOnly = metricReasons.every(
+ (reason) => reason === "SEGMENTATION_UNSTABLE"
+ );
+ return recovery(
+ "RETRY",
+ metricReasons,
+ reprocessOnly ? "REPROCESS" : "RESCAN",
+ true,
+ false,
+ attempt,
+ reprocessOnly
+ ? "Re-run segmentation once with the validated fallback profile, then re-evaluate every gate."
+ : "Reacquire once with validated focus/exposure adjustments, then re-evaluate every gate."
+ );
+}