diff --git a/sample-chain-of-custody-assistant/README.md b/sample-chain-of-custody-assistant/README.md
new file mode 100644
index 00000000..533c4e7a
--- /dev/null
+++ b/sample-chain-of-custody-assistant/README.md
@@ -0,0 +1,77 @@
+# Sample Chain-of-Custody Review Assistant
+
+This module adds a deterministic assistant for reviewing wet-lab or clinical sample
+chain-of-custody packets before AI-generated research conclusions are released.
+
+It is scoped to synthetic/sample metadata only. It does not call external APIs,
+does not require credentials, and does not process real participant records.
+
+## Why This Belongs In The AI Research Assistant Suite
+
+AI review and reproducibility tools can miss a basic scientific failure mode:
+the sample used to support a claim may have incomplete custody evidence. This
+assistant checks whether every sample has enough documented handoffs, consent
+links, storage controls, assay-run alignment, and reviewer signoff before the
+project relies on the result.
+
+The assistant emits a deterministic `release`, `revise`, or `hold` decision with
+evidence-linked findings and remediation steps.
+
+## Checks
+
+- Missing consent or participant-link evidence.
+- Custody events with missing actor or signature fields.
+- Time gaps beyond the configured maximum transit window.
+- Storage temperature excursions against the sample's storage spec.
+- Blinding leaks in reviewer-visible metadata.
+- Assay runs that use quarantined samples, mismatched instruments, or missing QC.
+- Conclusions that rely on samples with unresolved high-risk custody findings.
+- Required reviewer signoff before release.
+
+## Usage
+
+```bash
+node sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs \
+ sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json \
+ --format markdown
+```
+
+JSON output:
+
+```bash
+node sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs \
+ sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json \
+ --format json
+```
+
+## Validation
+
+```bash
+node sample-chain-of-custody-assistant/test/run-tests.mjs
+```
+
+Expected result:
+
+```text
+sample chain-of-custody assistant tests passed
+```
+
+## Demo Artifacts
+
+- `demo/sample-report.md`
+- `demo/sample-report.json`
+- `demo/decision-flow.svg`
+- `demo/decision-flow.png`
+- `demo/sample-chain-of-custody-demo.mp4`
+
+## Scope Boundaries
+
+This assistant intentionally avoids:
+
+- live LIMS, EHR, cloud storage, or instrument connections;
+- real participant or patient data;
+- credential handling;
+- legal, medical, or regulatory advice;
+- replacing required human review.
+
+It is a pre-release guardrail that highlights evidence gaps for a reviewer.
diff --git a/sample-chain-of-custody-assistant/demo/decision-flow.png b/sample-chain-of-custody-assistant/demo/decision-flow.png
new file mode 100644
index 00000000..dee9a71d
Binary files /dev/null and b/sample-chain-of-custody-assistant/demo/decision-flow.png differ
diff --git a/sample-chain-of-custody-assistant/demo/decision-flow.svg b/sample-chain-of-custody-assistant/demo/decision-flow.svg
new file mode 100644
index 00000000..8d89e09c
--- /dev/null
+++ b/sample-chain-of-custody-assistant/demo/decision-flow.svg
@@ -0,0 +1,30 @@
+
diff --git a/sample-chain-of-custody-assistant/demo/sample-chain-of-custody-demo.mp4 b/sample-chain-of-custody-assistant/demo/sample-chain-of-custody-demo.mp4
new file mode 100644
index 00000000..5ad35845
Binary files /dev/null and b/sample-chain-of-custody-assistant/demo/sample-chain-of-custody-demo.mp4 differ
diff --git a/sample-chain-of-custody-assistant/demo/sample-report.json b/sample-chain-of-custody-assistant/demo/sample-report.json
new file mode 100644
index 00000000..af33f98e
--- /dev/null
+++ b/sample-chain-of-custody-assistant/demo/sample-report.json
@@ -0,0 +1,119 @@
+{
+ "projectId": "SCI-CHAIN-001",
+ "title": "Cytokine drift after cold-chain interruption",
+ "generatedAt": "2026-06-01T12:00:00Z",
+ "assistant": "sample-chain-of-custody-review",
+ "decision": "hold",
+ "severityCounts": {
+ "critical": 4,
+ "high": 6,
+ "medium": 4,
+ "low": 0
+ },
+ "findings": [
+ {
+ "severity": "critical",
+ "sampleId": "S-1001,S-1002",
+ "check": "conclusion-release-gate",
+ "evidence": "Conclusion C-1 relies on sample(s) with critical custody findings.",
+ "remediation": "Hold this conclusion until critical sample custody issues are resolved."
+ },
+ {
+ "severity": "critical",
+ "sampleId": "S-1002",
+ "check": "assay-qc",
+ "evidence": "assay-run@2026-05-22T18:20:00Z has qcStatus=fail.",
+ "remediation": "Do not release conclusions using this assay until QC failure is resolved or rerun."
+ },
+ {
+ "severity": "critical",
+ "sampleId": "S-1002",
+ "check": "consent-link",
+ "evidence": "No consentId is attached to the sample metadata.",
+ "remediation": "Attach a verified consent record or remove this sample from AI-supported conclusions."
+ },
+ {
+ "severity": "critical",
+ "sampleId": "S-1002",
+ "check": "open-quarantine",
+ "evidence": "Open quarantine: temperature excursion pending QA disposition.",
+ "remediation": "Resolve QA quarantine or remove this sample from conclusions."
+ },
+ {
+ "severity": "high",
+ "sampleId": "S-1001",
+ "check": "blinding-leak",
+ "evidence": "Reviewer-visible fields include group/arm metadata while blindingGroup=exposed.",
+ "remediation": "Mask treatment-arm fields before AI review or document that the review is intentionally unblinded."
+ },
+ {
+ "severity": "high",
+ "sampleId": "S-1002",
+ "check": "blinding-leak",
+ "evidence": "Reviewer-visible fields include group/arm metadata while blindingGroup=exposed.",
+ "remediation": "Mask treatment-arm fields before AI review or document that the review is intentionally unblinded."
+ },
+ {
+ "severity": "high",
+ "sampleId": "S-1002",
+ "check": "reviewer-signoff",
+ "evidence": "No reviewerSignoff record is present.",
+ "remediation": "Obtain accountable QA/reviewer signoff before release."
+ },
+ {
+ "severity": "high",
+ "sampleId": "S-1002",
+ "check": "temperature-excursion",
+ "evidence": "storage@2026-05-22T16:45:00Z recorded 11.7C outside 2C-8C.",
+ "remediation": "Open a QA disposition, document stability impact, and quarantine the sample until resolved."
+ },
+ {
+ "severity": "high",
+ "sampleId": "S-1003",
+ "check": "conclusion-release-gate",
+ "evidence": "Conclusion C-2 relies on sample(s) with high-risk custody findings.",
+ "remediation": "Revise confidence language and require reviewer acceptance before release."
+ },
+ {
+ "severity": "high",
+ "sampleId": "S-1003",
+ "check": "required-custody-event",
+ "evidence": "Missing required custody event: assay-run.",
+ "remediation": "Add a signed assay-run custody event or exclude this sample from release."
+ },
+ {
+ "severity": "medium",
+ "sampleId": "S-1002",
+ "check": "custody-time-gap",
+ "evidence": "collect to accession gap is 7.8 hours.",
+ "remediation": "Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample."
+ },
+ {
+ "severity": "medium",
+ "sampleId": "S-1002",
+ "check": "handoff-signature",
+ "evidence": "accession@2026-05-22T16:30:00Z is missing actor or signature evidence.",
+ "remediation": "Add a signed handoff record with accountable actor and timestamp."
+ },
+ {
+ "severity": "medium",
+ "sampleId": "S-1002",
+ "check": "required-custody-event",
+ "evidence": "Missing required custody event: archive.",
+ "remediation": "Add a signed archive custody event or exclude this sample from release."
+ },
+ {
+ "severity": "medium",
+ "sampleId": "S-1003",
+ "check": "custody-time-gap",
+ "evidence": "storage to archive gap is 6.5 hours.",
+ "remediation": "Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample."
+ }
+ ],
+ "researchGapPrompts": [
+ "Which biomarkers remain stable after the observed cold-chain excursion window?",
+ "Would blinded AI review change the severity or interpretation of the reported effect?",
+ "What courier or intermediate storage metadata should be captured to close shipment evidence gaps?",
+ "Can the failed assay be rerun from archived aliquots, or should the conclusion be limited to unaffected samples?"
+ ]
+}
diff --git a/sample-chain-of-custody-assistant/demo/sample-report.md b/sample-chain-of-custody-assistant/demo/sample-report.md
new file mode 100644
index 00000000..0869a202
--- /dev/null
+++ b/sample-chain-of-custody-assistant/demo/sample-report.md
@@ -0,0 +1,105 @@
+# Sample Chain-of-Custody Review: SCI-CHAIN-001
+
+Title: Cytokine drift after cold-chain interruption
+Decision: HOLD
+
+## Severity Counts
+
+- Critical: 4
+- High: 6
+- Medium: 4
+- Low: 0
+
+## Findings
+
+### [CRITICAL] conclusion-release-gate
+
+- Sample/conclusion: S-1001,S-1002
+- Evidence: Conclusion C-1 relies on sample(s) with critical custody findings.
+- Remediation: Hold this conclusion until critical sample custody issues are resolved.
+
+### [CRITICAL] assay-qc
+
+- Sample/conclusion: S-1002
+- Evidence: assay-run@2026-05-22T18:20:00Z has qcStatus=fail.
+- Remediation: Do not release conclusions using this assay until QC failure is resolved or rerun.
+
+### [CRITICAL] consent-link
+
+- Sample/conclusion: S-1002
+- Evidence: No consentId is attached to the sample metadata.
+- Remediation: Attach a verified consent record or remove this sample from AI-supported conclusions.
+
+### [CRITICAL] open-quarantine
+
+- Sample/conclusion: S-1002
+- Evidence: Open quarantine: temperature excursion pending QA disposition.
+- Remediation: Resolve QA quarantine or remove this sample from conclusions.
+
+### [HIGH] blinding-leak
+
+- Sample/conclusion: S-1001
+- Evidence: Reviewer-visible fields include group/arm metadata while blindingGroup=exposed.
+- Remediation: Mask treatment-arm fields before AI review or document that the review is intentionally unblinded.
+
+### [HIGH] blinding-leak
+
+- Sample/conclusion: S-1002
+- Evidence: Reviewer-visible fields include group/arm metadata while blindingGroup=exposed.
+- Remediation: Mask treatment-arm fields before AI review or document that the review is intentionally unblinded.
+
+### [HIGH] reviewer-signoff
+
+- Sample/conclusion: S-1002
+- Evidence: No reviewerSignoff record is present.
+- Remediation: Obtain accountable QA/reviewer signoff before release.
+
+### [HIGH] temperature-excursion
+
+- Sample/conclusion: S-1002
+- Evidence: storage@2026-05-22T16:45:00Z recorded 11.7C outside 2C-8C.
+- Remediation: Open a QA disposition, document stability impact, and quarantine the sample until resolved.
+
+### [HIGH] conclusion-release-gate
+
+- Sample/conclusion: S-1003
+- Evidence: Conclusion C-2 relies on sample(s) with high-risk custody findings.
+- Remediation: Revise confidence language and require reviewer acceptance before release.
+
+### [HIGH] required-custody-event
+
+- Sample/conclusion: S-1003
+- Evidence: Missing required custody event: assay-run.
+- Remediation: Add a signed assay-run custody event or exclude this sample from release.
+
+### [MEDIUM] custody-time-gap
+
+- Sample/conclusion: S-1002
+- Evidence: collect to accession gap is 7.8 hours.
+- Remediation: Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample.
+
+### [MEDIUM] handoff-signature
+
+- Sample/conclusion: S-1002
+- Evidence: accession@2026-05-22T16:30:00Z is missing actor or signature evidence.
+- Remediation: Add a signed handoff record with accountable actor and timestamp.
+
+### [MEDIUM] required-custody-event
+
+- Sample/conclusion: S-1002
+- Evidence: Missing required custody event: archive.
+- Remediation: Add a signed archive custody event or exclude this sample from release.
+
+### [MEDIUM] custody-time-gap
+
+- Sample/conclusion: S-1003
+- Evidence: storage to archive gap is 6.5 hours.
+- Remediation: Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample.
+
+## Research Gap Prompts
+
+- Which biomarkers remain stable after the observed cold-chain excursion window?
+- Would blinded AI review change the severity or interpretation of the reported effect?
+- What courier or intermediate storage metadata should be captured to close shipment evidence gaps?
+- Can the failed assay be rerun from archived aliquots, or should the conclusion be limited to unaffected samples?
+
diff --git a/sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json b/sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json
new file mode 100644
index 00000000..625d32ba
--- /dev/null
+++ b/sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json
@@ -0,0 +1,176 @@
+{
+ "projectId": "SCI-CHAIN-001",
+ "title": "Cytokine drift after cold-chain interruption",
+ "generatedAt": "2026-06-01T12:00:00Z",
+ "policy": {
+ "maxTransitGapHours": 4,
+ "requiredEvents": ["collect", "accession", "storage", "assay-run", "archive"],
+ "releaseRequiresReviewerSignoff": true
+ },
+ "conclusions": [
+ {
+ "id": "C-1",
+ "text": "IL-6 increased in the exposed cohort.",
+ "sampleIds": ["S-1001", "S-1002"]
+ },
+ {
+ "id": "C-2",
+ "text": "Control samples remained stable across the shipment window.",
+ "sampleIds": ["S-1003"]
+ }
+ ],
+ "samples": [
+ {
+ "id": "S-1001",
+ "participantId": "P-101",
+ "consentId": "CONS-778",
+ "visibleFields": ["sample_id", "collection_day", "blinded_arm"],
+ "blindingGroup": "exposed",
+ "storageSpec": {
+ "minCelsius": 2,
+ "maxCelsius": 8
+ },
+ "custodyEvents": [
+ {
+ "type": "collect",
+ "timestamp": "2026-05-22T09:10:00Z",
+ "location": "Clinic A",
+ "actor": "phlebotomist-12",
+ "signature": "sig-collect-1001"
+ },
+ {
+ "type": "accession",
+ "timestamp": "2026-05-22T10:20:00Z",
+ "location": "Lab intake",
+ "actor": "tech-07",
+ "signature": "sig-accession-1001"
+ },
+ {
+ "type": "storage",
+ "timestamp": "2026-05-22T10:40:00Z",
+ "location": "Cold room 2",
+ "actor": "tech-07",
+ "signature": "sig-storage-1001",
+ "temperatureCelsius": 4.2
+ },
+ {
+ "type": "assay-run",
+ "timestamp": "2026-05-22T13:35:00Z",
+ "location": "ELISA bay",
+ "actor": "assay-operator-2",
+ "signature": "sig-assay-1001",
+ "instrumentId": "ELISA-04",
+ "qcStatus": "pass"
+ },
+ {
+ "type": "archive",
+ "timestamp": "2026-05-22T16:00:00Z",
+ "location": "Freezer B",
+ "actor": "tech-14",
+ "signature": "sig-archive-1001",
+ "temperatureCelsius": -80
+ }
+ ],
+ "reviewerSignoff": {
+ "reviewer": "qa-reviewer-1",
+ "timestamp": "2026-05-23T09:00:00Z"
+ }
+ },
+ {
+ "id": "S-1002",
+ "participantId": "P-102",
+ "consentId": "",
+ "visibleFields": ["sample_id", "collection_day", "treatment_arm"],
+ "blindingGroup": "exposed",
+ "storageSpec": {
+ "minCelsius": 2,
+ "maxCelsius": 8
+ },
+ "custodyEvents": [
+ {
+ "type": "collect",
+ "timestamp": "2026-05-22T08:45:00Z",
+ "location": "Clinic B",
+ "actor": "phlebotomist-09",
+ "signature": "sig-collect-1002"
+ },
+ {
+ "type": "accession",
+ "timestamp": "2026-05-22T16:30:00Z",
+ "location": "Lab intake",
+ "actor": "tech-11",
+ "signature": ""
+ },
+ {
+ "type": "storage",
+ "timestamp": "2026-05-22T16:45:00Z",
+ "location": "Cold room 2",
+ "actor": "tech-11",
+ "signature": "sig-storage-1002",
+ "temperatureCelsius": 11.7
+ },
+ {
+ "type": "assay-run",
+ "timestamp": "2026-05-22T18:20:00Z",
+ "location": "ELISA bay",
+ "actor": "assay-operator-2",
+ "signature": "sig-assay-1002",
+ "instrumentId": "ELISA-04",
+ "qcStatus": "fail"
+ }
+ ],
+ "quarantine": {
+ "status": "open",
+ "reason": "temperature excursion pending QA disposition"
+ },
+ "reviewerSignoff": null
+ },
+ {
+ "id": "S-1003",
+ "participantId": "P-103",
+ "consentId": "CONS-889",
+ "visibleFields": ["sample_id", "collection_day"],
+ "blindingGroup": "control",
+ "storageSpec": {
+ "minCelsius": -90,
+ "maxCelsius": -60
+ },
+ "custodyEvents": [
+ {
+ "type": "collect",
+ "timestamp": "2026-05-22T09:30:00Z",
+ "location": "Clinic A",
+ "actor": "phlebotomist-12",
+ "signature": "sig-collect-1003"
+ },
+ {
+ "type": "accession",
+ "timestamp": "2026-05-22T10:15:00Z",
+ "location": "Lab intake",
+ "actor": "tech-07",
+ "signature": "sig-accession-1003"
+ },
+ {
+ "type": "storage",
+ "timestamp": "2026-05-22T10:30:00Z",
+ "location": "Freezer A",
+ "actor": "tech-07",
+ "signature": "sig-storage-1003",
+ "temperatureCelsius": -78
+ },
+ {
+ "type": "archive",
+ "timestamp": "2026-05-22T17:00:00Z",
+ "location": "Freezer B",
+ "actor": "tech-14",
+ "signature": "sig-archive-1003",
+ "temperatureCelsius": -75
+ }
+ ],
+ "reviewerSignoff": {
+ "reviewer": "qa-reviewer-1",
+ "timestamp": "2026-05-23T09:05:00Z"
+ }
+ }
+ ]
+}
diff --git a/sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs b/sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs
new file mode 100644
index 00000000..9616d08a
--- /dev/null
+++ b/sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs
@@ -0,0 +1,322 @@
+#!/usr/bin/env node
+import fs from "node:fs";
+import path from "node:path";
+
+const SEVERITY_SCORE = {
+ critical: 4,
+ high: 3,
+ medium: 2,
+ low: 1
+};
+
+function hoursBetween(startIso, endIso) {
+ return (new Date(endIso).getTime() - new Date(startIso).getTime()) / 36e5;
+}
+
+function finding(severity, sampleId, check, evidence, remediation) {
+ return { severity, sampleId, check, evidence, remediation };
+}
+
+function eventLabel(event) {
+ return `${event.type}@${event.timestamp || "missing-time"}`;
+}
+
+function sortEvents(events) {
+ return [...(events || [])].sort((a, b) => {
+ return new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime();
+ });
+}
+
+function analyzeSample(sample, packet) {
+ const findings = [];
+ const policy = packet.policy || {};
+ const requiredEvents = policy.requiredEvents || [];
+ const events = sortEvents(sample.custodyEvents);
+ const eventTypes = new Set(events.map((event) => event.type));
+
+ if (!sample.consentId) {
+ findings.push(finding(
+ "critical",
+ sample.id,
+ "consent-link",
+ "No consentId is attached to the sample metadata.",
+ "Attach a verified consent record or remove this sample from AI-supported conclusions."
+ ));
+ }
+
+ for (const required of requiredEvents) {
+ if (!eventTypes.has(required)) {
+ findings.push(finding(
+ required === "assay-run" ? "high" : "medium",
+ sample.id,
+ "required-custody-event",
+ `Missing required custody event: ${required}.`,
+ `Add a signed ${required} custody event or exclude this sample from release.`
+ ));
+ }
+ }
+
+ for (const event of events) {
+ if (!event.actor || !event.signature) {
+ findings.push(finding(
+ "medium",
+ sample.id,
+ "handoff-signature",
+ `${eventLabel(event)} is missing actor or signature evidence.`,
+ "Add a signed handoff record with accountable actor and timestamp."
+ ));
+ }
+
+ if (typeof event.temperatureCelsius === "number" && sample.storageSpec) {
+ const { minCelsius, maxCelsius } = sample.storageSpec;
+ const isArchiveFreezer = event.type === "archive" && event.temperatureCelsius < minCelsius;
+ if (!isArchiveFreezer && (event.temperatureCelsius < minCelsius || event.temperatureCelsius > maxCelsius)) {
+ findings.push(finding(
+ "high",
+ sample.id,
+ "temperature-excursion",
+ `${eventLabel(event)} recorded ${event.temperatureCelsius}C outside ${minCelsius}C-${maxCelsius}C.`,
+ "Open a QA disposition, document stability impact, and quarantine the sample until resolved."
+ ));
+ }
+ }
+ }
+
+ for (let index = 1; index < events.length; index += 1) {
+ const previous = events[index - 1];
+ const current = events[index];
+ const gap = hoursBetween(previous.timestamp, current.timestamp);
+ if (gap > (policy.maxTransitGapHours || 4)) {
+ findings.push(finding(
+ "medium",
+ sample.id,
+ "custody-time-gap",
+ `${previous.type} to ${current.type} gap is ${gap.toFixed(1)} hours.`,
+ "Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample."
+ ));
+ }
+ }
+
+ if (sample.blindingGroup && (sample.visibleFields || []).some((field) => /arm|group|treatment/i.test(field))) {
+ findings.push(finding(
+ "high",
+ sample.id,
+ "blinding-leak",
+ `Reviewer-visible fields include group/arm metadata while blindingGroup=${sample.blindingGroup}.`,
+ "Mask treatment-arm fields before AI review or document that the review is intentionally unblinded."
+ ));
+ }
+
+ const assayEvents = events.filter((event) => event.type === "assay-run");
+ for (const assay of assayEvents) {
+ if (!assay.instrumentId) {
+ findings.push(finding(
+ "medium",
+ sample.id,
+ "instrument-link",
+ `${eventLabel(assay)} has no instrumentId.`,
+ "Link the assay event to the instrument run and calibration record."
+ ));
+ }
+ if (assay.qcStatus && assay.qcStatus !== "pass") {
+ findings.push(finding(
+ "critical",
+ sample.id,
+ "assay-qc",
+ `${eventLabel(assay)} has qcStatus=${assay.qcStatus}.`,
+ "Do not release conclusions using this assay until QC failure is resolved or rerun."
+ ));
+ }
+ }
+
+ if (sample.quarantine?.status === "open") {
+ findings.push(finding(
+ "critical",
+ sample.id,
+ "open-quarantine",
+ `Open quarantine: ${sample.quarantine.reason || "reason not provided"}.`,
+ "Resolve QA quarantine or remove this sample from conclusions."
+ ));
+ }
+
+ if (policy.releaseRequiresReviewerSignoff && !sample.reviewerSignoff) {
+ findings.push(finding(
+ "high",
+ sample.id,
+ "reviewer-signoff",
+ "No reviewerSignoff record is present.",
+ "Obtain accountable QA/reviewer signoff before release."
+ ));
+ }
+
+ return findings;
+}
+
+function conclusionFindings(packet, sampleRisk) {
+ const findings = [];
+ for (const conclusion of packet.conclusions || []) {
+ const linkedFindings = (conclusion.sampleIds || []).flatMap((sampleId) => sampleRisk.get(sampleId) || []);
+ const maxSeverity = linkedFindings.reduce((max, item) => {
+ return Math.max(max, SEVERITY_SCORE[item.severity] || 0);
+ }, 0);
+
+ if (maxSeverity >= SEVERITY_SCORE.critical) {
+ findings.push(finding(
+ "critical",
+ conclusion.sampleIds.join(","),
+ "conclusion-release-gate",
+ `Conclusion ${conclusion.id} relies on sample(s) with critical custody findings.`,
+ "Hold this conclusion until critical sample custody issues are resolved."
+ ));
+ } else if (maxSeverity >= SEVERITY_SCORE.high) {
+ findings.push(finding(
+ "high",
+ conclusion.sampleIds.join(","),
+ "conclusion-release-gate",
+ `Conclusion ${conclusion.id} relies on sample(s) with high-risk custody findings.`,
+ "Revise confidence language and require reviewer acceptance before release."
+ ));
+ }
+ }
+ return findings;
+}
+
+function summarizeDecision(findings) {
+ const counts = findings.reduce((acc, item) => {
+ acc[item.severity] = (acc[item.severity] || 0) + 1;
+ return acc;
+ }, {});
+
+ let decision = "release";
+ if ((counts.critical || 0) > 0) {
+ decision = "hold";
+ } else if ((counts.high || 0) > 0 || (counts.medium || 0) >= 3) {
+ decision = "revise";
+ }
+
+ return {
+ decision,
+ counts: {
+ critical: counts.critical || 0,
+ high: counts.high || 0,
+ medium: counts.medium || 0,
+ low: counts.low || 0
+ }
+ };
+}
+
+function makeGapPrompts(packet, findings) {
+ const checks = new Set(findings.map((item) => item.check));
+ const prompts = [];
+ if (checks.has("temperature-excursion")) {
+ prompts.push("Which biomarkers remain stable after the observed cold-chain excursion window?");
+ }
+ if (checks.has("blinding-leak")) {
+ prompts.push("Would blinded AI review change the severity or interpretation of the reported effect?");
+ }
+ if (checks.has("custody-time-gap")) {
+ prompts.push("What courier or intermediate storage metadata should be captured to close shipment evidence gaps?");
+ }
+ if (checks.has("assay-qc")) {
+ prompts.push("Can the failed assay be rerun from archived aliquots, or should the conclusion be limited to unaffected samples?");
+ }
+ if (prompts.length === 0) {
+ prompts.push(`What custody evidence would most improve reviewer confidence for ${packet.projectId}?`);
+ }
+ return prompts;
+}
+
+export function analyzePacket(packet) {
+ const perSampleFindings = new Map();
+ const findings = [];
+
+ for (const sample of packet.samples || []) {
+ const sampleFindings = analyzeSample(sample, packet);
+ perSampleFindings.set(sample.id, sampleFindings);
+ findings.push(...sampleFindings);
+ }
+
+ findings.push(...conclusionFindings(packet, perSampleFindings));
+ const summary = summarizeDecision(findings);
+
+ return {
+ projectId: packet.projectId,
+ title: packet.title,
+ generatedAt: packet.generatedAt,
+ assistant: "sample-chain-of-custody-review",
+ decision: summary.decision,
+ severityCounts: summary.counts,
+ findings: findings.sort((a, b) => {
+ return (SEVERITY_SCORE[b.severity] || 0) - (SEVERITY_SCORE[a.severity] || 0)
+ || a.sampleId.localeCompare(b.sampleId)
+ || a.check.localeCompare(b.check);
+ }),
+ researchGapPrompts: makeGapPrompts(packet, findings)
+ };
+}
+
+export function renderMarkdown(report) {
+ const lines = [
+ `# Sample Chain-of-Custody Review: ${report.projectId}`,
+ "",
+ `Title: ${report.title}`,
+ `Decision: ${report.decision.toUpperCase()}`,
+ "",
+ "## Severity Counts",
+ "",
+ `- Critical: ${report.severityCounts.critical}`,
+ `- High: ${report.severityCounts.high}`,
+ `- Medium: ${report.severityCounts.medium}`,
+ `- Low: ${report.severityCounts.low}`,
+ "",
+ "## Findings",
+ ""
+ ];
+
+ for (const item of report.findings) {
+ lines.push(`### [${item.severity.toUpperCase()}] ${item.check}`);
+ lines.push("");
+ lines.push(`- Sample/conclusion: ${item.sampleId}`);
+ lines.push(`- Evidence: ${item.evidence}`);
+ lines.push(`- Remediation: ${item.remediation}`);
+ lines.push("");
+ }
+
+ lines.push("## Research Gap Prompts", "");
+ for (const prompt of report.researchGapPrompts) {
+ lines.push(`- ${prompt}`);
+ }
+ lines.push("");
+
+ return lines.join("\n");
+}
+
+function parseArgs(argv) {
+ const args = [...argv];
+ const inputPath = args.find((arg) => !arg.startsWith("--"));
+ const formatIndex = args.indexOf("--format");
+ const format = formatIndex >= 0 ? args[formatIndex + 1] : "markdown";
+ return { inputPath, format };
+}
+
+function main() {
+ const { inputPath, format } = parseArgs(process.argv.slice(2));
+ if (!inputPath) {
+ console.error("Usage: node chain-custody-assistant.mjs [--format markdown|json]");
+ process.exit(2);
+ }
+
+ const absolutePath = path.resolve(inputPath);
+ const packet = JSON.parse(fs.readFileSync(absolutePath, "utf8"));
+ const report = analyzePacket(packet);
+
+ if (format === "json") {
+ console.log(JSON.stringify(report, null, 2));
+ } else {
+ console.log(renderMarkdown(report));
+ }
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ main();
+}
diff --git a/sample-chain-of-custody-assistant/test/run-tests.mjs b/sample-chain-of-custody-assistant/test/run-tests.mjs
new file mode 100644
index 00000000..ecf60526
--- /dev/null
+++ b/sample-chain-of-custody-assistant/test/run-tests.mjs
@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { analyzePacket, renderMarkdown } from "../src/chain-custody-assistant.mjs";
+
+const fixturePath = path.resolve("sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json");
+const packet = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
+const report = analyzePacket(packet);
+
+assert.equal(report.assistant, "sample-chain-of-custody-review");
+assert.equal(report.decision, "hold");
+assert.equal(report.severityCounts.critical, 4);
+assert.equal(report.severityCounts.high, 6);
+assert.ok(report.findings.some((finding) => finding.check === "consent-link" && finding.sampleId === "S-1002"));
+assert.ok(report.findings.some((finding) => finding.check === "assay-qc" && finding.sampleId === "S-1002"));
+assert.ok(report.findings.some((finding) => finding.check === "required-custody-event" && finding.sampleId === "S-1003"));
+assert.ok(report.findings.some((finding) => finding.check === "conclusion-release-gate" && finding.sampleId.includes("S-1002")));
+assert.ok(report.researchGapPrompts.some((prompt) => prompt.includes("cold-chain")));
+
+const markdown = renderMarkdown(report);
+assert.match(markdown, /Decision: HOLD/);
+assert.match(markdown, /Sample Chain-of-Custody Review/);
+assert.match(markdown, /Research Gap Prompts/);
+
+const cleanPacket = {
+ projectId: "SCI-CLEAN",
+ title: "Clean custody packet",
+ policy: {
+ maxTransitGapHours: 4,
+ requiredEvents: ["collect", "accession", "storage", "assay-run", "archive"],
+ releaseRequiresReviewerSignoff: true
+ },
+ conclusions: [{ id: "C-clean", sampleIds: ["S-clean"] }],
+ samples: [{
+ id: "S-clean",
+ participantId: "P-clean",
+ consentId: "CONS-clean",
+ visibleFields: ["sample_id", "collection_day"],
+ storageSpec: { minCelsius: 2, maxCelsius: 8 },
+ custodyEvents: [
+ { type: "collect", timestamp: "2026-01-01T09:00:00Z", actor: "a", signature: "s", location: "clinic" },
+ { type: "accession", timestamp: "2026-01-01T10:00:00Z", actor: "b", signature: "s", location: "lab" },
+ { type: "storage", timestamp: "2026-01-01T10:15:00Z", actor: "b", signature: "s", location: "fridge", temperatureCelsius: 4 },
+ { type: "assay-run", timestamp: "2026-01-01T11:15:00Z", actor: "c", signature: "s", location: "bench", instrumentId: "inst-1", qcStatus: "pass" },
+ { type: "archive", timestamp: "2026-01-01T12:00:00Z", actor: "d", signature: "s", location: "archive", temperatureCelsius: -80 }
+ ],
+ reviewerSignoff: { reviewer: "qa", timestamp: "2026-01-01T13:00:00Z" }
+ }]
+};
+
+const cleanReport = analyzePacket(cleanPacket);
+assert.equal(cleanReport.decision, "release");
+assert.equal(cleanReport.findings.length, 0);
+
+console.log("sample chain-of-custody assistant tests passed");