diff --git a/sample-custody-quality-graph-guard/.gitignore b/sample-custody-quality-graph-guard/.gitignore
new file mode 100644
index 00000000..2bf074d6
--- /dev/null
+++ b/sample-custody-quality-graph-guard/.gitignore
@@ -0,0 +1 @@
+reports/frames/
diff --git a/sample-custody-quality-graph-guard/README.md b/sample-custody-quality-graph-guard/README.md
new file mode 100644
index 00000000..e84de0f1
--- /dev/null
+++ b/sample-custody-quality-graph-guard/README.md
@@ -0,0 +1,40 @@
+# Sample Custody Quality Graph Guard
+
+Self-contained reviewer artifact for SCIBASE issue #17, focused on sample custody and cold-chain quality before sample nodes are exposed in scientific knowledge graph entity pages or recommendations.
+
+This slice is intentionally narrow. It does not duplicate broad graph extraction/navigation, geospatial sample provenance, organism/strain boundaries, biological accession crosswalks, measurement harmonization, software dependency provenance, evidence freshness, temporal validity, image metadata provenance, funding provenance, or clinical-trial registry work.
+
+## What It Checks
+
+- Collection provenance has timestamps, site ids, and protocol ids.
+- Preservation protocol and preservation timing are present and inside policy windows.
+- Chain-of-custody handoffs are complete, signed, and contiguous.
+- Cold-chain temperature logs exist and have no policy-breaking excursions.
+- Freeze-thaw cycles and integrity scores meet quality thresholds.
+- Required sample-to-protocol, sample-to-dataset, and sample-to-assay graph edges are present.
+- Unsafe sample graph edges are held with deterministic remediation actions.
+
+## Local Verification
+
+```sh
+npm run check
+npm test
+npm run demo
+npm run verify-video
+```
+
+`npm run demo` writes reviewer artifacts to `reports/`:
+
+- `clean-audit.json`
+- `risky-audit.json`
+- `risky-review.md`
+- `summary.svg`
+- `manifest.json`
+- `demo.mp4`
+
+## Requirement Mapping
+
+- Scientific knowledge graph integration: evaluates sample nodes before entity pages and recommendations publish.
+- Provenance quality: links custody, preservation, temperature, and assay-quality evidence to graph edge publication.
+- Recommendation safety: holds degraded or uncertain sample edges before they can influence discovery paths.
+- Reviewer demonstration: synthetic clean/risky packets produce JSON, Markdown, SVG, and MP4 artifacts without credentials or external services.
diff --git a/sample-custody-quality-graph-guard/demo.js b/sample-custody-quality-graph-guard/demo.js
new file mode 100644
index 00000000..b790d7f3
--- /dev/null
+++ b/sample-custody-quality-graph-guard/demo.js
@@ -0,0 +1,42 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const {
+ evaluateSampleCustodyGraph,
+ renderMarkdownReport,
+ renderSvgSummary
+} = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const clean = evaluateSampleCustodyGraph(cleanPacket, { now: "2026-06-01T10:45:00.000Z" });
+const risky = evaluateSampleCustodyGraph(riskyPacket, { now: "2026-06-01T10:45:00.000Z" });
+const manifest = {
+ module: "sample-custody-quality-graph-guard",
+ issue: 17,
+ generatedAt: "2026-06-01T10:45:00.000Z",
+ scenarios: [
+ { name: "clean", status: clean.status, fingerprint: clean.fingerprint, findings: clean.findings.length },
+ { name: "risky", status: risky.status, fingerprint: risky.fingerprint, findings: risky.findings.length }
+ ],
+ artifacts: [
+ "reports/clean-audit.json",
+ "reports/risky-audit.json",
+ "reports/risky-review.md",
+ "reports/summary.svg",
+ "reports/demo.mp4"
+ ]
+};
+
+fs.writeFileSync(path.join(reportsDir, "clean-audit.json"), `${JSON.stringify(clean, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, "risky-audit.json"), `${JSON.stringify(risky, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(risky, riskyPacket));
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(risky));
+fs.writeFileSync(path.join(reportsDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
+
+console.log(`Clean status: ${clean.status} (${clean.fingerprint})`);
+console.log(`Risky status: ${risky.status} (${risky.fingerprint})`);
+console.log(`Wrote reviewer artifacts to ${reportsDir}`);
diff --git a/sample-custody-quality-graph-guard/index.js b/sample-custody-quality-graph-guard/index.js
new file mode 100644
index 00000000..37faf621
--- /dev/null
+++ b/sample-custody-quality-graph-guard/index.js
@@ -0,0 +1,434 @@
+"use strict";
+
+const crypto = require("node:crypto");
+
+const SEVERITY_ORDER = ["critical", "high", "warning", "info"];
+
+function evaluateSampleCustodyGraph(packet, options = {}) {
+ if (!isPlainObject(packet)) {
+ throw new TypeError("evaluateSampleCustodyGraph expects a packet object");
+ }
+
+ const now = options.now ?? new Date().toISOString();
+ const graph = isPlainObject(packet.graph) ? packet.graph : {};
+ const policy = isPlainObject(packet.policy) ? packet.policy : {};
+ const samples = asArray(packet.samples);
+ const findings = [];
+
+ if (!graph.id || !graph.releaseTarget) {
+ findings.push(
+ finding(
+ "PACKET_SCHEMA_MISSING_GRAPH",
+ "high",
+ "Sample custody packet is missing graph id or release target.",
+ "Entity-page and recommendation decisions need a stable graph release context.",
+ "graph",
+ "Attach graph release metadata before custody validation.",
+ "graph curator"
+ )
+ );
+ }
+
+ if (samples.length === 0) {
+ findings.push(
+ finding(
+ "PACKET_SCHEMA_MISSING_SAMPLES",
+ "high",
+ "Sample custody packet has no samples to inspect.",
+ "The guard cannot prove quality of sample graph edges without sample nodes.",
+ "samples",
+ "Attach sample nodes and custody evidence.",
+ "graph curator"
+ )
+ );
+ }
+
+ samples.forEach((sample, index) => inspectSample(sample, index, policy, findings));
+
+ const sortedFindings = sortFindings(findings);
+ const status = determineStatus(sortedFindings);
+ const summary = summarize(status, sortedFindings, samples.length);
+ const safeEdges = samples
+ .filter((sample) => !sortedFindings.some((item) => item.sampleId === sample.id && ["critical", "high"].includes(item.severity)))
+ .map((sample) => ({
+ sampleId: sample.id,
+ publishableEdges: sample.graphEdges ?? [],
+ qualityTier: "verified-custody"
+ }));
+ const heldEdges = samples
+ .filter((sample) => sortedFindings.some((item) => item.sampleId === sample.id && ["critical", "high"].includes(item.severity)))
+ .map((sample) => ({
+ sampleId: sample.id,
+ heldEdges: sample.graphEdges ?? [],
+ reasonCodes: sortedFindings.filter((item) => item.sampleId === sample.id).map((item) => item.code)
+ }));
+ const remediationActions = sortedFindings.map((item) => ({
+ code: item.code,
+ sampleId: item.sampleId ?? null,
+ owner: item.owner,
+ action: item.remediation
+ }));
+ const fingerprint = crypto
+ .createHash("sha256")
+ .update(
+ JSON.stringify({
+ graph,
+ policy,
+ samples: samples.map((sample) => ({
+ id: sample.id,
+ collection: sample.collection,
+ preservation: sample.preservation,
+ custodyEvents: sample.custodyEvents,
+ quality: sample.quality,
+ graphEdges: sample.graphEdges
+ })),
+ codes: sortedFindings.map((item) => item.code)
+ })
+ )
+ .digest("hex")
+ .slice(0, 16);
+
+ return {
+ generatedAt: now,
+ status,
+ summary,
+ findingCounts: countBySeverity(sortedFindings),
+ findings: sortedFindings,
+ safeEdges,
+ heldEdges,
+ remediationActions,
+ fingerprint
+ };
+}
+
+function renderMarkdownReport(result, packet) {
+ const lines = [
+ "# Sample Custody Quality Graph Guard",
+ "",
+ `Graph: ${packet.graph?.id ?? "unknown"}`,
+ `Status: ${result.status}`,
+ `Fingerprint: ${result.fingerprint}`,
+ "",
+ "## Summary",
+ "",
+ result.summary,
+ "",
+ "## Held Edges",
+ ""
+ ];
+
+ if (result.heldEdges.length === 0) {
+ lines.push("- No graph edges were held.");
+ } else {
+ result.heldEdges.forEach((item) => {
+ lines.push(`- ${item.sampleId}: ${item.reasonCodes.join(", ")}`);
+ });
+ }
+
+ lines.push("", "## Findings", "");
+ if (result.findings.length === 0) {
+ lines.push("- No custody or cold-chain blockers found.");
+ } else {
+ result.findings.forEach((item) => {
+ lines.push(`- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`);
+ lines.push(` - Remediation: ${item.remediation}`);
+ });
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvgSummary(result) {
+ const counts = result.findingCounts;
+ const critical = counts.critical ?? 0;
+ const high = counts.high ?? 0;
+ const warning = counts.warning ?? 0;
+ const ready = result.status === "READY";
+ const statusColor = ready ? "#16794c" : result.status === "REVISE" ? "#a15c00" : "#a11b32";
+ const holdWidth = Math.min(320, (critical + high) * 60);
+ const warningWidth = Math.min(220, warning * 55);
+ const readyWidth = ready ? 280 : Math.max(75, 280 - holdWidth);
+
+ return [
+ ``
+ ].join("\n");
+}
+
+function inspectSample(sample, index, policy, findings) {
+ const sampleId = sample.id ?? `sample-${index}`;
+ const path = `samples[${index}]`;
+ const custodyEvents = asArray(sample.custodyEvents).slice().sort((left, right) => timeValue(left.at) - timeValue(right.at));
+ const preservation = isPlainObject(sample.preservation) ? sample.preservation : {};
+ const collection = isPlainObject(sample.collection) ? sample.collection : {};
+ const quality = isPlainObject(sample.quality) ? sample.quality : {};
+
+ if (!sample.id) {
+ findings.push(
+ finding(
+ "SAMPLE_MISSING_ID",
+ "high",
+ `Sample at index ${index} has no stable id.`,
+ "Knowledge graph sample edges need stable sample identifiers.",
+ `${path}.id`,
+ "Assign a stable sample id before graph publication.",
+ "data curator",
+ sampleId
+ )
+ );
+ }
+
+ if (!collection.collectedAt || !collection.siteId || !collection.protocolId) {
+ findings.push(
+ finding(
+ "COLLECTION_PROVENANCE_INCOMPLETE",
+ "high",
+ `${sampleId} is missing collection time, site id, or protocol id.`,
+ "Custody quality cannot be evaluated without collection provenance.",
+ `${path}.collection`,
+ "Attach collection timestamp, site id, and protocol evidence.",
+ "field lead",
+ sampleId
+ )
+ );
+ }
+
+ if (!preservation.protocolId || !preservation.preservedAt) {
+ findings.push(
+ finding(
+ "PRESERVATION_PROTOCOL_MISSING",
+ "high",
+ `${sampleId} is missing preservation protocol evidence.`,
+ "Samples should not create trusted graph edges without preservation records.",
+ `${path}.preservation`,
+ "Attach preservation protocol id and timestamp.",
+ "biobank curator",
+ sampleId
+ )
+ );
+ } else if (collection.collectedAt) {
+ const delayHours = (timeValue(preservation.preservedAt) - timeValue(collection.collectedAt)) / 3600000;
+ if (delayHours > Number(policy.maxCollectionToPreservationHours ?? 6)) {
+ findings.push(
+ finding(
+ "COLLECTION_TO_PRESERVATION_DELAY",
+ "high",
+ `${sampleId} waited ${delayHours.toFixed(1)} hours before preservation.`,
+ "Long delays can degrade molecular measurements and mislead graph recommendations.",
+ `${path}.preservation.preservedAt`,
+ "Hold sample edges until degradation impact is reviewed or replacement evidence is supplied.",
+ "biobank curator",
+ sampleId
+ )
+ );
+ }
+ }
+
+ if (custodyEvents.length === 0) {
+ findings.push(
+ finding(
+ "CUSTODY_CHAIN_MISSING",
+ "critical",
+ `${sampleId} has no chain-of-custody events.`,
+ "Graph edges should not trust sample identity without custody evidence.",
+ `${path}.custodyEvents`,
+ "Attach signed handoff events from collection through analysis.",
+ "custody officer",
+ sampleId
+ )
+ );
+ } else {
+ inspectCustodyEvents(custodyEvents, path, sampleId, findings);
+ }
+
+ const tempEvents = asArray(sample.temperatureLog);
+ const minTemp = Number(policy.temperatureRangeC?.min ?? -90);
+ const maxTemp = Number(policy.temperatureRangeC?.max ?? -60);
+ const excursions = tempEvents.filter((event) => Number(event.celsius) < minTemp || Number(event.celsius) > maxTemp);
+ if (tempEvents.length === 0) {
+ findings.push(
+ finding(
+ "TEMPERATURE_LOG_MISSING",
+ "high",
+ `${sampleId} has no cold-chain temperature log.`,
+ "Cold-chain evidence protects downstream graph conclusions from degraded samples.",
+ `${path}.temperatureLog`,
+ "Attach temperature logger evidence before publishing sample edges.",
+ "biobank curator",
+ sampleId
+ )
+ );
+ } else if (excursions.length > Number(policy.maxTemperatureExcursions ?? 0)) {
+ findings.push(
+ finding(
+ "COLD_CHAIN_TEMPERATURE_EXCURSION",
+ "critical",
+ `${sampleId} has ${excursions.length} temperature excursion(s) outside policy.`,
+ "Temperature excursions can invalidate assay/sample relationships.",
+ `${path}.temperatureLog`,
+ "Hold graph edges until excursion review and assay impact notes are attached.",
+ "quality reviewer",
+ sampleId
+ )
+ );
+ }
+
+ if (Number(quality.freezeThawCycles ?? 0) > Number(policy.maxFreezeThawCycles ?? 2)) {
+ findings.push(
+ finding(
+ "FREEZE_THAW_LIMIT_EXCEEDED",
+ "high",
+ `${sampleId} exceeds the allowed freeze-thaw cycle limit.`,
+ "Repeated thawing can degrade sample analytes and invalidate reuse recommendations.",
+ `${path}.quality.freezeThawCycles`,
+ "Suppress reuse/recommendation edges or attach validated degradation analysis.",
+ "quality reviewer",
+ sampleId
+ )
+ );
+ }
+
+ if (quality.integrityScore != null && Number(quality.integrityScore) < Number(policy.minIntegrityScore ?? 0.82)) {
+ findings.push(
+ finding(
+ "SAMPLE_INTEGRITY_BELOW_THRESHOLD",
+ "high",
+ `${sampleId} has integrity score ${quality.integrityScore}, below policy threshold.`,
+ "Low-integrity samples should not be recommended as high-confidence evidence.",
+ `${path}.quality.integrityScore`,
+ "Downgrade or hold sample graph edges until a curator reviews quality evidence.",
+ "quality reviewer",
+ sampleId
+ )
+ );
+ }
+
+ const missingRequiredEdges = asArray(policy.requiredGraphEdges).filter((edgeType) => !asArray(sample.graphEdges).some((edge) => edge.type === edgeType));
+ if (missingRequiredEdges.length > 0) {
+ findings.push(
+ finding(
+ "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING",
+ "warning",
+ `${sampleId} is missing required graph edge type(s): ${missingRequiredEdges.join(", ")}.`,
+ "Entity pages need complete sample-to-protocol/dataset/evidence linkage.",
+ `${path}.graphEdges`,
+ "Attach the missing edge evidence or mark the sample as partial provenance.",
+ "graph curator",
+ sampleId
+ )
+ );
+ }
+}
+
+function inspectCustodyEvents(custodyEvents, path, sampleId, findings) {
+ custodyEvents.forEach((event, eventIndex) => {
+ if (!event.at || !event.from || !event.to || !event.signature) {
+ findings.push(
+ finding(
+ "CUSTODY_HANDOFF_INCOMPLETE",
+ "high",
+ `${sampleId} has an incomplete custody handoff at event ${eventIndex}.`,
+ "Each transfer should identify source, recipient, timestamp, and signature.",
+ `${path}.custodyEvents[${eventIndex}]`,
+ "Complete the signed custody handoff record.",
+ "custody officer",
+ sampleId
+ )
+ );
+ }
+ });
+
+ for (let index = 1; index < custodyEvents.length; index += 1) {
+ if (custodyEvents[index - 1].to !== custodyEvents[index].from) {
+ findings.push(
+ finding(
+ "CUSTODY_CHAIN_BREAK",
+ "critical",
+ `${sampleId} custody chain breaks between events ${index - 1} and ${index}.`,
+ "Unexplained custody gaps undermine sample identity and provenance.",
+ `${path}.custodyEvents[${index}]`,
+ "Insert the missing transfer or hold graph publication for the sample.",
+ "custody officer",
+ sampleId
+ )
+ );
+ }
+ }
+}
+
+function determineStatus(findings) {
+ if (findings.some((item) => item.severity === "critical" || item.severity === "high")) {
+ return "HOLD";
+ }
+ if (findings.some((item) => item.severity === "warning")) {
+ return "REVISE";
+ }
+ return "READY";
+}
+
+function summarize(status, findings, sampleCount) {
+ if (status === "READY") {
+ return `All ${sampleCount} sample node(s) have custody, preservation, cold-chain, and quality evidence for graph publication.`;
+ }
+ const counts = countBySeverity(findings);
+ return `Sample graph publication is ${status.toLowerCase()} with ${counts.critical ?? 0} critical, ${counts.high ?? 0} high, and ${counts.warning ?? 0} warning finding(s).`;
+}
+
+function finding(code, severity, message, impact, path, remediation, owner = "graph curator", sampleId = null) {
+ return { code, severity, message, impact, path, remediation, owner, sampleId };
+}
+
+function sortFindings(findings) {
+ return findings.sort((left, right) => {
+ const severityDelta = SEVERITY_ORDER.indexOf(left.severity) - SEVERITY_ORDER.indexOf(right.severity);
+ if (severityDelta !== 0) {
+ return severityDelta;
+ }
+ return left.code.localeCompare(right.code);
+ });
+}
+
+function countBySeverity(findings) {
+ return findings.reduce((counts, item) => {
+ counts[item.severity] = (counts[item.severity] ?? 0) + 1;
+ return counts;
+ }, {});
+}
+
+function timeValue(value) {
+ const parsed = new Date(value).getTime();
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function isPlainObject(value) {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+module.exports = {
+ evaluateSampleCustodyGraph,
+ renderMarkdownReport,
+ renderSvgSummary
+};
diff --git a/sample-custody-quality-graph-guard/make-demo-video.js b/sample-custody-quality-graph-guard/make-demo-video.js
new file mode 100644
index 00000000..ca93e374
--- /dev/null
+++ b/sample-custody-quality-graph-guard/make-demo-video.js
@@ -0,0 +1,127 @@
+"use strict";
+
+const { execFileSync } = require("node:child_process");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const WIDTH = 960;
+const HEIGHT = 540;
+const FONT = {
+ A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
+ C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
+ D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
+ E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
+ G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
+ H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
+ I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
+ L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
+ O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
+ P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
+ R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
+ S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
+ T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
+ U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
+ V: ["10001", "10001", "10001", "10001", "01010", "01010", "00100"],
+ X: ["10001", "01010", "00100", "00100", "00100", "01010", "10001"],
+ Y: ["10001", "01010", "00100", "00100", "00100", "00100", "00100"]
+};
+
+const reportsDir = path.join(__dirname, "reports");
+const framesDir = path.join(reportsDir, "frames");
+fs.mkdirSync(framesDir, { recursive: true });
+
+for (const file of fs.readdirSync(framesDir)) {
+ fs.unlinkSync(path.join(framesDir, file));
+}
+
+const slides = [
+ { label: "CUSTODY", color: [22, 121, 76], fill: 0.88 },
+ { label: "COLD CHAIN", color: [161, 27, 50], fill: 0.38 },
+ { label: "GRAPH EDGES", color: [22, 121, 76], fill: 0.82 }
+];
+
+let frameIndex = 0;
+for (const slide of slides) {
+ for (let i = 0; i < 8; i += 1) {
+ const progress = (i + 1) / 8;
+ const buffer = createFrame(slide, progress);
+ fs.writeFileSync(path.join(framesDir, `frame-${String(frameIndex).padStart(3, "0")}.ppm`), buffer);
+ frameIndex += 1;
+ }
+}
+
+const output = path.join(reportsDir, "demo.mp4");
+execFileSync(
+ "ffmpeg",
+ [
+ "-y",
+ "-framerate",
+ "8",
+ "-i",
+ path.join(framesDir, "frame-%03d.ppm"),
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ output
+ ],
+ { stdio: "ignore" }
+);
+
+const stats = fs.statSync(output);
+console.log(`Wrote ${output} (${stats.size} bytes)`);
+
+function createFrame(slide, progress) {
+ const pixels = Buffer.alloc(WIDTH * HEIGHT * 3);
+ fillRect(pixels, 0, 0, WIDTH, HEIGHT, [17, 24, 39]);
+ fillRect(pixels, 48, 48, 864, 444, [248, 250, 252]);
+ fillRect(pixels, 80, 190, 800, 88, [226, 232, 240]);
+ fillRect(pixels, 80, 190, Math.round(800 * slide.fill * progress), 88, slide.color);
+ fillRect(pixels, 80, 322, 240, 42, [226, 232, 240]);
+ fillRect(pixels, 344, 322, 240, 42, [226, 232, 240]);
+ fillRect(pixels, 608, 322, 240, 42, [226, 232, 240]);
+ fillRect(pixels, 80, 322, 70, 42, [161, 27, 50]);
+ fillRect(pixels, 344, 322, 140, 42, [161, 92, 0]);
+ fillRect(pixels, 608, 322, 210, 42, [22, 121, 76]);
+ drawText(pixels, "SAMPLE GUARD", 82, 104, 5, [17, 24, 39]);
+ drawText(pixels, slide.label, 108, 214, 7, [255, 255, 255]);
+ drawText(pixels, "QUALITY GRAPH", 82, 414, 4, [51, 65, 85]);
+ return Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`, "ascii"), pixels]);
+}
+
+function fillRect(pixels, x, y, width, height, color) {
+ const x2 = Math.min(WIDTH, x + width);
+ const y2 = Math.min(HEIGHT, y + height);
+ for (let row = Math.max(0, y); row < y2; row += 1) {
+ for (let col = Math.max(0, x); col < x2; col += 1) {
+ const offset = (row * WIDTH + col) * 3;
+ pixels[offset] = color[0];
+ pixels[offset + 1] = color[1];
+ pixels[offset + 2] = color[2];
+ }
+ }
+}
+
+function drawText(pixels, text, x, y, scale, color) {
+ let cursor = x;
+ for (const rawChar of text) {
+ const char = rawChar.toUpperCase();
+ if (char === " ") {
+ cursor += 4 * scale;
+ continue;
+ }
+ const glyph = FONT[char];
+ if (!glyph) {
+ cursor += 6 * scale;
+ continue;
+ }
+ glyph.forEach((row, rowIndex) => {
+ for (let colIndex = 0; colIndex < row.length; colIndex += 1) {
+ if (row[colIndex] === "1") {
+ fillRect(pixels, cursor + colIndex * scale, y + rowIndex * scale, scale, scale, color);
+ }
+ }
+ });
+ cursor += 6 * scale;
+ }
+}
diff --git a/sample-custody-quality-graph-guard/package.json b/sample-custody-quality-graph-guard/package.json
new file mode 100644
index 00000000..65c0c60b
--- /dev/null
+++ b/sample-custody-quality-graph-guard/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "sample-custody-quality-graph-guard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Synthetic sample custody and cold-chain quality guard for scientific knowledge graph edges.",
+ "main": "index.js",
+ "scripts": {
+ "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js",
+ "test": "node test.js",
+ "demo": "node demo.js && node make-demo-video.js",
+ "verify-video": "ffprobe -v error -show_entries stream=codec_name,width,height,duration -of default=nokey=1:noprint_wrappers=1 reports/demo.mp4"
+ },
+ "keywords": [
+ "scientific-knowledge-graph",
+ "sample-custody",
+ "cold-chain",
+ "provenance",
+ "synthetic"
+ ],
+ "license": "MIT"
+}
diff --git a/sample-custody-quality-graph-guard/reports/clean-audit.json b/sample-custody-quality-graph-guard/reports/clean-audit.json
new file mode 100644
index 00000000..9d9c11dd
--- /dev/null
+++ b/sample-custody-quality-graph-guard/reports/clean-audit.json
@@ -0,0 +1,30 @@
+{
+ "generatedAt": "2026-06-01T10:45:00.000Z",
+ "status": "READY",
+ "summary": "All 1 sample node(s) have custody, preservation, cold-chain, and quality evidence for graph publication.",
+ "findingCounts": {},
+ "findings": [],
+ "safeEdges": [
+ {
+ "sampleId": "SAMPLE-ALPHA",
+ "publishableEdges": [
+ {
+ "type": "sample-to-protocol",
+ "target": "PROTO-CRYO-1"
+ },
+ {
+ "type": "sample-to-dataset",
+ "target": "DATASET-77"
+ },
+ {
+ "type": "sample-to-assay",
+ "target": "ASSAY-RNA-2"
+ }
+ ],
+ "qualityTier": "verified-custody"
+ }
+ ],
+ "heldEdges": [],
+ "remediationActions": [],
+ "fingerprint": "4fc0bc78f8608fad"
+}
diff --git a/sample-custody-quality-graph-guard/reports/demo.mp4 b/sample-custody-quality-graph-guard/reports/demo.mp4
new file mode 100644
index 00000000..ddef49c6
Binary files /dev/null and b/sample-custody-quality-graph-guard/reports/demo.mp4 differ
diff --git a/sample-custody-quality-graph-guard/reports/manifest.json b/sample-custody-quality-graph-guard/reports/manifest.json
new file mode 100644
index 00000000..48fda537
--- /dev/null
+++ b/sample-custody-quality-graph-guard/reports/manifest.json
@@ -0,0 +1,26 @@
+{
+ "module": "sample-custody-quality-graph-guard",
+ "issue": 17,
+ "generatedAt": "2026-06-01T10:45:00.000Z",
+ "scenarios": [
+ {
+ "name": "clean",
+ "status": "READY",
+ "fingerprint": "4fc0bc78f8608fad",
+ "findings": 0
+ },
+ {
+ "name": "risky",
+ "status": "HOLD",
+ "fingerprint": "92285cf83c973c8e",
+ "findings": 11
+ }
+ ],
+ "artifacts": [
+ "reports/clean-audit.json",
+ "reports/risky-audit.json",
+ "reports/risky-review.md",
+ "reports/summary.svg",
+ "reports/demo.mp4"
+ ]
+}
diff --git a/sample-custody-quality-graph-guard/reports/risky-audit.json b/sample-custody-quality-graph-guard/reports/risky-audit.json
new file mode 100644
index 00000000..5eac61a5
--- /dev/null
+++ b/sample-custody-quality-graph-guard/reports/risky-audit.json
@@ -0,0 +1,231 @@
+{
+ "generatedAt": "2026-06-01T10:45:00.000Z",
+ "status": "HOLD",
+ "summary": "Sample graph publication is hold with 3 critical, 6 high, and 2 warning finding(s).",
+ "findingCounts": {
+ "critical": 3,
+ "high": 6,
+ "warning": 2
+ },
+ "findings": [
+ {
+ "code": "COLD_CHAIN_TEMPERATURE_EXCURSION",
+ "severity": "critical",
+ "message": "SAMPLE-BETA has 1 temperature excursion(s) outside policy.",
+ "impact": "Temperature excursions can invalidate assay/sample relationships.",
+ "path": "samples[0].temperatureLog",
+ "remediation": "Hold graph edges until excursion review and assay impact notes are attached.",
+ "owner": "quality reviewer",
+ "sampleId": "SAMPLE-BETA"
+ },
+ {
+ "code": "CUSTODY_CHAIN_BREAK",
+ "severity": "critical",
+ "message": "SAMPLE-BETA custody chain breaks between events 0 and 1.",
+ "impact": "Unexplained custody gaps undermine sample identity and provenance.",
+ "path": "samples[0].custodyEvents[1]",
+ "remediation": "Insert the missing transfer or hold graph publication for the sample.",
+ "owner": "custody officer",
+ "sampleId": "SAMPLE-BETA"
+ },
+ {
+ "code": "CUSTODY_CHAIN_MISSING",
+ "severity": "critical",
+ "message": "SAMPLE-GAMMA has no chain-of-custody events.",
+ "impact": "Graph edges should not trust sample identity without custody evidence.",
+ "path": "samples[1].custodyEvents",
+ "remediation": "Attach signed handoff events from collection through analysis.",
+ "owner": "custody officer",
+ "sampleId": "SAMPLE-GAMMA"
+ },
+ {
+ "code": "COLLECTION_TO_PRESERVATION_DELAY",
+ "severity": "high",
+ "message": "SAMPLE-BETA waited 9.5 hours before preservation.",
+ "impact": "Long delays can degrade molecular measurements and mislead graph recommendations.",
+ "path": "samples[0].preservation.preservedAt",
+ "remediation": "Hold sample edges until degradation impact is reviewed or replacement evidence is supplied.",
+ "owner": "biobank curator",
+ "sampleId": "SAMPLE-BETA"
+ },
+ {
+ "code": "CUSTODY_HANDOFF_INCOMPLETE",
+ "severity": "high",
+ "message": "SAMPLE-BETA has an incomplete custody handoff at event 1.",
+ "impact": "Each transfer should identify source, recipient, timestamp, and signature.",
+ "path": "samples[0].custodyEvents[1]",
+ "remediation": "Complete the signed custody handoff record.",
+ "owner": "custody officer",
+ "sampleId": "SAMPLE-BETA"
+ },
+ {
+ "code": "FREEZE_THAW_LIMIT_EXCEEDED",
+ "severity": "high",
+ "message": "SAMPLE-BETA exceeds the allowed freeze-thaw cycle limit.",
+ "impact": "Repeated thawing can degrade sample analytes and invalidate reuse recommendations.",
+ "path": "samples[0].quality.freezeThawCycles",
+ "remediation": "Suppress reuse/recommendation edges or attach validated degradation analysis.",
+ "owner": "quality reviewer",
+ "sampleId": "SAMPLE-BETA"
+ },
+ {
+ "code": "PRESERVATION_PROTOCOL_MISSING",
+ "severity": "high",
+ "message": "SAMPLE-GAMMA is missing preservation protocol evidence.",
+ "impact": "Samples should not create trusted graph edges without preservation records.",
+ "path": "samples[1].preservation",
+ "remediation": "Attach preservation protocol id and timestamp.",
+ "owner": "biobank curator",
+ "sampleId": "SAMPLE-GAMMA"
+ },
+ {
+ "code": "SAMPLE_INTEGRITY_BELOW_THRESHOLD",
+ "severity": "high",
+ "message": "SAMPLE-BETA has integrity score 0.69, below policy threshold.",
+ "impact": "Low-integrity samples should not be recommended as high-confidence evidence.",
+ "path": "samples[0].quality.integrityScore",
+ "remediation": "Downgrade or hold sample graph edges until a curator reviews quality evidence.",
+ "owner": "quality reviewer",
+ "sampleId": "SAMPLE-BETA"
+ },
+ {
+ "code": "TEMPERATURE_LOG_MISSING",
+ "severity": "high",
+ "message": "SAMPLE-GAMMA has no cold-chain temperature log.",
+ "impact": "Cold-chain evidence protects downstream graph conclusions from degraded samples.",
+ "path": "samples[1].temperatureLog",
+ "remediation": "Attach temperature logger evidence before publishing sample edges.",
+ "owner": "biobank curator",
+ "sampleId": "SAMPLE-GAMMA"
+ },
+ {
+ "code": "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING",
+ "severity": "warning",
+ "message": "SAMPLE-BETA is missing required graph edge type(s): sample-to-assay.",
+ "impact": "Entity pages need complete sample-to-protocol/dataset/evidence linkage.",
+ "path": "samples[0].graphEdges",
+ "remediation": "Attach the missing edge evidence or mark the sample as partial provenance.",
+ "owner": "graph curator",
+ "sampleId": "SAMPLE-BETA"
+ },
+ {
+ "code": "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING",
+ "severity": "warning",
+ "message": "SAMPLE-GAMMA is missing required graph edge type(s): sample-to-dataset, sample-to-assay.",
+ "impact": "Entity pages need complete sample-to-protocol/dataset/evidence linkage.",
+ "path": "samples[1].graphEdges",
+ "remediation": "Attach the missing edge evidence or mark the sample as partial provenance.",
+ "owner": "graph curator",
+ "sampleId": "SAMPLE-GAMMA"
+ }
+ ],
+ "safeEdges": [],
+ "heldEdges": [
+ {
+ "sampleId": "SAMPLE-BETA",
+ "heldEdges": [
+ {
+ "type": "sample-to-protocol",
+ "target": "PROTO-CRYO-1"
+ },
+ {
+ "type": "sample-to-dataset",
+ "target": "DATASET-88"
+ }
+ ],
+ "reasonCodes": [
+ "COLD_CHAIN_TEMPERATURE_EXCURSION",
+ "CUSTODY_CHAIN_BREAK",
+ "COLLECTION_TO_PRESERVATION_DELAY",
+ "CUSTODY_HANDOFF_INCOMPLETE",
+ "FREEZE_THAW_LIMIT_EXCEEDED",
+ "SAMPLE_INTEGRITY_BELOW_THRESHOLD",
+ "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING"
+ ]
+ },
+ {
+ "sampleId": "SAMPLE-GAMMA",
+ "heldEdges": [
+ {
+ "type": "sample-to-protocol",
+ "target": "PROTO-CRYO-1"
+ }
+ ],
+ "reasonCodes": [
+ "CUSTODY_CHAIN_MISSING",
+ "PRESERVATION_PROTOCOL_MISSING",
+ "TEMPERATURE_LOG_MISSING",
+ "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING"
+ ]
+ }
+ ],
+ "remediationActions": [
+ {
+ "code": "COLD_CHAIN_TEMPERATURE_EXCURSION",
+ "sampleId": "SAMPLE-BETA",
+ "owner": "quality reviewer",
+ "action": "Hold graph edges until excursion review and assay impact notes are attached."
+ },
+ {
+ "code": "CUSTODY_CHAIN_BREAK",
+ "sampleId": "SAMPLE-BETA",
+ "owner": "custody officer",
+ "action": "Insert the missing transfer or hold graph publication for the sample."
+ },
+ {
+ "code": "CUSTODY_CHAIN_MISSING",
+ "sampleId": "SAMPLE-GAMMA",
+ "owner": "custody officer",
+ "action": "Attach signed handoff events from collection through analysis."
+ },
+ {
+ "code": "COLLECTION_TO_PRESERVATION_DELAY",
+ "sampleId": "SAMPLE-BETA",
+ "owner": "biobank curator",
+ "action": "Hold sample edges until degradation impact is reviewed or replacement evidence is supplied."
+ },
+ {
+ "code": "CUSTODY_HANDOFF_INCOMPLETE",
+ "sampleId": "SAMPLE-BETA",
+ "owner": "custody officer",
+ "action": "Complete the signed custody handoff record."
+ },
+ {
+ "code": "FREEZE_THAW_LIMIT_EXCEEDED",
+ "sampleId": "SAMPLE-BETA",
+ "owner": "quality reviewer",
+ "action": "Suppress reuse/recommendation edges or attach validated degradation analysis."
+ },
+ {
+ "code": "PRESERVATION_PROTOCOL_MISSING",
+ "sampleId": "SAMPLE-GAMMA",
+ "owner": "biobank curator",
+ "action": "Attach preservation protocol id and timestamp."
+ },
+ {
+ "code": "SAMPLE_INTEGRITY_BELOW_THRESHOLD",
+ "sampleId": "SAMPLE-BETA",
+ "owner": "quality reviewer",
+ "action": "Downgrade or hold sample graph edges until a curator reviews quality evidence."
+ },
+ {
+ "code": "TEMPERATURE_LOG_MISSING",
+ "sampleId": "SAMPLE-GAMMA",
+ "owner": "biobank curator",
+ "action": "Attach temperature logger evidence before publishing sample edges."
+ },
+ {
+ "code": "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING",
+ "sampleId": "SAMPLE-BETA",
+ "owner": "graph curator",
+ "action": "Attach the missing edge evidence or mark the sample as partial provenance."
+ },
+ {
+ "code": "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING",
+ "sampleId": "SAMPLE-GAMMA",
+ "owner": "graph curator",
+ "action": "Attach the missing edge evidence or mark the sample as partial provenance."
+ }
+ ],
+ "fingerprint": "92285cf83c973c8e"
+}
diff --git a/sample-custody-quality-graph-guard/reports/risky-review.md b/sample-custody-quality-graph-guard/reports/risky-review.md
new file mode 100644
index 00000000..7e8c3b64
--- /dev/null
+++ b/sample-custody-quality-graph-guard/reports/risky-review.md
@@ -0,0 +1,39 @@
+# Sample Custody Quality Graph Guard
+
+Graph: kg-sample-custody-risky
+Status: HOLD
+Fingerprint: 92285cf83c973c8e
+
+## Summary
+
+Sample graph publication is hold with 3 critical, 6 high, and 2 warning finding(s).
+
+## Held Edges
+
+- SAMPLE-BETA: COLD_CHAIN_TEMPERATURE_EXCURSION, CUSTODY_CHAIN_BREAK, COLLECTION_TO_PRESERVATION_DELAY, CUSTODY_HANDOFF_INCOMPLETE, FREEZE_THAW_LIMIT_EXCEEDED, SAMPLE_INTEGRITY_BELOW_THRESHOLD, REQUIRED_SAMPLE_GRAPH_EDGE_MISSING
+- SAMPLE-GAMMA: CUSTODY_CHAIN_MISSING, PRESERVATION_PROTOCOL_MISSING, TEMPERATURE_LOG_MISSING, REQUIRED_SAMPLE_GRAPH_EDGE_MISSING
+
+## Findings
+
+- CRITICAL COLD_CHAIN_TEMPERATURE_EXCURSION: SAMPLE-BETA has 1 temperature excursion(s) outside policy.
+ - Remediation: Hold graph edges until excursion review and assay impact notes are attached.
+- CRITICAL CUSTODY_CHAIN_BREAK: SAMPLE-BETA custody chain breaks between events 0 and 1.
+ - Remediation: Insert the missing transfer or hold graph publication for the sample.
+- CRITICAL CUSTODY_CHAIN_MISSING: SAMPLE-GAMMA has no chain-of-custody events.
+ - Remediation: Attach signed handoff events from collection through analysis.
+- HIGH COLLECTION_TO_PRESERVATION_DELAY: SAMPLE-BETA waited 9.5 hours before preservation.
+ - Remediation: Hold sample edges until degradation impact is reviewed or replacement evidence is supplied.
+- HIGH CUSTODY_HANDOFF_INCOMPLETE: SAMPLE-BETA has an incomplete custody handoff at event 1.
+ - Remediation: Complete the signed custody handoff record.
+- HIGH FREEZE_THAW_LIMIT_EXCEEDED: SAMPLE-BETA exceeds the allowed freeze-thaw cycle limit.
+ - Remediation: Suppress reuse/recommendation edges or attach validated degradation analysis.
+- HIGH PRESERVATION_PROTOCOL_MISSING: SAMPLE-GAMMA is missing preservation protocol evidence.
+ - Remediation: Attach preservation protocol id and timestamp.
+- HIGH SAMPLE_INTEGRITY_BELOW_THRESHOLD: SAMPLE-BETA has integrity score 0.69, below policy threshold.
+ - Remediation: Downgrade or hold sample graph edges until a curator reviews quality evidence.
+- HIGH TEMPERATURE_LOG_MISSING: SAMPLE-GAMMA has no cold-chain temperature log.
+ - Remediation: Attach temperature logger evidence before publishing sample edges.
+- WARNING REQUIRED_SAMPLE_GRAPH_EDGE_MISSING: SAMPLE-BETA is missing required graph edge type(s): sample-to-assay.
+ - Remediation: Attach the missing edge evidence or mark the sample as partial provenance.
+- WARNING REQUIRED_SAMPLE_GRAPH_EDGE_MISSING: SAMPLE-GAMMA is missing required graph edge type(s): sample-to-dataset, sample-to-assay.
+ - Remediation: Attach the missing edge evidence or mark the sample as partial provenance.
diff --git a/sample-custody-quality-graph-guard/reports/summary.svg b/sample-custody-quality-graph-guard/reports/summary.svg
new file mode 100644
index 00000000..41e58793
--- /dev/null
+++ b/sample-custody-quality-graph-guard/reports/summary.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/sample-custody-quality-graph-guard/sample-data.js b/sample-custody-quality-graph-guard/sample-data.js
new file mode 100644
index 00000000..973ce103
--- /dev/null
+++ b/sample-custody-quality-graph-guard/sample-data.js
@@ -0,0 +1,108 @@
+"use strict";
+
+const cleanPacket = {
+ graph: {
+ id: "kg-sample-custody-clean",
+ releaseTarget: "entity-pages-and-recommendations"
+ },
+ policy: {
+ maxCollectionToPreservationHours: 6,
+ temperatureRangeC: { min: -90, max: -60 },
+ maxTemperatureExcursions: 0,
+ maxFreezeThawCycles: 2,
+ minIntegrityScore: 0.82,
+ requiredGraphEdges: ["sample-to-protocol", "sample-to-dataset", "sample-to-assay"]
+ },
+ samples: [
+ {
+ id: "SAMPLE-ALPHA",
+ collection: {
+ collectedAt: "2026-05-18T10:00:00.000Z",
+ siteId: "FIELD-A",
+ protocolId: "PROTO-CRYO-1"
+ },
+ preservation: {
+ protocolId: "PRESERVE-LN2",
+ preservedAt: "2026-05-18T12:00:00.000Z"
+ },
+ custodyEvents: [
+ { at: "2026-05-18T10:05:00.000Z", from: "collector-a", to: "field-freezer", signature: "sig-a1" },
+ { at: "2026-05-18T13:00:00.000Z", from: "field-freezer", to: "biobank-1", signature: "sig-a2" },
+ { at: "2026-05-19T08:00:00.000Z", from: "biobank-1", to: "assay-lab", signature: "sig-a3" }
+ ],
+ temperatureLog: [
+ { at: "2026-05-18T12:00:00.000Z", celsius: -78 },
+ { at: "2026-05-18T18:00:00.000Z", celsius: -75 },
+ { at: "2026-05-19T06:00:00.000Z", celsius: -77 }
+ ],
+ quality: {
+ freezeThawCycles: 1,
+ integrityScore: 0.94
+ },
+ graphEdges: [
+ { type: "sample-to-protocol", target: "PROTO-CRYO-1" },
+ { type: "sample-to-dataset", target: "DATASET-77" },
+ { type: "sample-to-assay", target: "ASSAY-RNA-2" }
+ ]
+ }
+ ]
+};
+
+const riskyPacket = {
+ graph: {
+ id: "kg-sample-custody-risky",
+ releaseTarget: "entity-pages-and-recommendations"
+ },
+ policy: cleanPacket.policy,
+ samples: [
+ {
+ id: "SAMPLE-BETA",
+ collection: {
+ collectedAt: "2026-05-18T10:00:00.000Z",
+ siteId: "FIELD-B",
+ protocolId: "PROTO-CRYO-1"
+ },
+ preservation: {
+ protocolId: "PRESERVE-LN2",
+ preservedAt: "2026-05-18T19:30:00.000Z"
+ },
+ custodyEvents: [
+ { at: "2026-05-18T10:10:00.000Z", from: "collector-b", to: "field-freezer", signature: "sig-b1" },
+ { at: "2026-05-18T16:00:00.000Z", from: "unknown-courier", to: "biobank-1", signature: "" }
+ ],
+ temperatureLog: [
+ { at: "2026-05-18T12:00:00.000Z", celsius: -76 },
+ { at: "2026-05-18T15:30:00.000Z", celsius: -42 }
+ ],
+ quality: {
+ freezeThawCycles: 4,
+ integrityScore: 0.69
+ },
+ graphEdges: [
+ { type: "sample-to-protocol", target: "PROTO-CRYO-1" },
+ { type: "sample-to-dataset", target: "DATASET-88" }
+ ]
+ },
+ {
+ id: "SAMPLE-GAMMA",
+ collection: {
+ collectedAt: "2026-05-19T10:00:00.000Z",
+ siteId: "FIELD-C",
+ protocolId: "PROTO-CRYO-1"
+ },
+ preservation: {},
+ custodyEvents: [],
+ temperatureLog: [],
+ quality: {
+ freezeThawCycles: 0,
+ integrityScore: 0.91
+ },
+ graphEdges: [{ type: "sample-to-protocol", target: "PROTO-CRYO-1" }]
+ }
+ ]
+};
+
+module.exports = {
+ cleanPacket,
+ riskyPacket
+};
diff --git a/sample-custody-quality-graph-guard/test.js b/sample-custody-quality-graph-guard/test.js
new file mode 100644
index 00000000..28b89310
--- /dev/null
+++ b/sample-custody-quality-graph-guard/test.js
@@ -0,0 +1,47 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const {
+ evaluateSampleCustodyGraph,
+ renderMarkdownReport,
+ renderSvgSummary
+} = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const clean = evaluateSampleCustodyGraph(cleanPacket, { now: "2026-06-01T10:45:00.000Z" });
+assert.equal(clean.status, "READY");
+assert.equal(clean.findings.length, 0);
+assert.equal(clean.safeEdges.length, 1);
+assert.equal(clean.heldEdges.length, 0);
+
+const risky = evaluateSampleCustodyGraph(riskyPacket, { now: "2026-06-01T10:45:00.000Z" });
+const codes = new Set(risky.findings.map((finding) => finding.code));
+assert.equal(risky.status, "HOLD");
+assert.ok(codes.has("COLLECTION_TO_PRESERVATION_DELAY"));
+assert.ok(codes.has("CUSTODY_HANDOFF_INCOMPLETE"));
+assert.ok(codes.has("CUSTODY_CHAIN_BREAK"));
+assert.ok(codes.has("COLD_CHAIN_TEMPERATURE_EXCURSION"));
+assert.ok(codes.has("FREEZE_THAW_LIMIT_EXCEEDED"));
+assert.ok(codes.has("SAMPLE_INTEGRITY_BELOW_THRESHOLD"));
+assert.ok(codes.has("PRESERVATION_PROTOCOL_MISSING"));
+assert.ok(codes.has("CUSTODY_CHAIN_MISSING"));
+assert.ok(codes.has("TEMPERATURE_LOG_MISSING"));
+assert.ok(codes.has("REQUIRED_SAMPLE_GRAPH_EDGE_MISSING"));
+assert.equal(risky.safeEdges.length, 0);
+assert.equal(risky.heldEdges.length, 2);
+
+const repeatedRisky = evaluateSampleCustodyGraph(riskyPacket, { now: "2026-06-01T10:50:00.000Z" });
+assert.equal(risky.fingerprint, repeatedRisky.fingerprint);
+
+const markdown = renderMarkdownReport(risky, riskyPacket);
+assert.match(markdown, /Sample Custody Quality Graph Guard/);
+assert.match(markdown, /COLD_CHAIN_TEMPERATURE_EXCURSION/);
+
+const svg = renderSvgSummary(risky);
+assert.match(svg, /