diff --git a/instrument-calibration-graph-guard/README.md b/instrument-calibration-graph-guard/README.md
new file mode 100644
index 00000000..db215b10
--- /dev/null
+++ b/instrument-calibration-graph-guard/README.md
@@ -0,0 +1,50 @@
+# Instrument Calibration Graph Guard
+
+This module adds a focused Scientific Knowledge Graph Integration slice for instrument calibration provenance.
+
+It answers:
+
+> Can an instrument/tool node and its experiment edges be published or used for recommendations without misleading researchers about calibration validity?
+
+## Scope
+
+This is distinct from broad entity extraction, ontology aliasing, sample custody, chemical identity, image metadata, software dependency, funding provenance, protocol-deviation, and temporal-validity slices. It focuses only on instrument/tool graph nodes and calibration certificate evidence.
+
+The guard checks:
+
+- instrument nodes have stable identifiers and serial/model metadata
+- each public experiment edge points to a calibration certificate
+- certificate hashes and issuers are present
+- calibration validity covers the experiment timestamp
+- certificates are not revoked
+- measurement units match the instrument capability profile
+- recommendation/publication edges are held when calibration evidence is missing or expired
+
+## Decisions
+
+- `PUBLISH_GRAPH_EDGE`: graph node and experiment edge are safe to publish.
+- `HOLD_FOR_CURATOR`: evidence is incomplete or stale but recoverable.
+- `BLOCK_RECOMMENDATION`: publishing would create a misleading recommendation or entity page.
+
+## Run
+
+```bash
+npm test
+npm run demo
+```
+
+Demo outputs are written to `artifacts/`:
+
+- `instrument-calibration-results.json`
+- `instrument-calibration-report.md`
+- `instrument-calibration-summary.svg`
+- `instrument-calibration-demo.mp4`
+
+## Boundaries
+
+- synthetic graph packets only
+- no lab system integration
+- no private experiment data
+- no external APIs
+- no credentials
+- no live compliance claims
diff --git a/instrument-calibration-graph-guard/artifacts/instrument-calibration-demo.mp4 b/instrument-calibration-graph-guard/artifacts/instrument-calibration-demo.mp4
new file mode 100644
index 00000000..2416d53f
Binary files /dev/null and b/instrument-calibration-graph-guard/artifacts/instrument-calibration-demo.mp4 differ
diff --git a/instrument-calibration-graph-guard/artifacts/instrument-calibration-report.md b/instrument-calibration-graph-guard/artifacts/instrument-calibration-report.md
new file mode 100644
index 00000000..985a0448
--- /dev/null
+++ b/instrument-calibration-graph-guard/artifacts/instrument-calibration-report.md
@@ -0,0 +1,59 @@
+# Instrument Calibration Graph Guard Report
+
+Generated: 2026-06-12T00:00:00Z
+
+## Summary
+
+- Blocking findings: 4
+- Review findings: 2
+- Passing findings: 12
+
+## Instrument Decisions
+
+### instrument:mass-spec:orbitrap-0021
+
+- Decision: PUBLISH_GRAPH_EDGE
+- Instrument type: mass_spectrometer
+- Certificate: cal-2026-ms-0021
+- Experiment edges: 1
+
+| Severity | Code | Message | Remediation |
+| --- | --- | --- | --- |
+| pass | instrument_identity_ready | Instrument node has stable model, serial, type, and graph ID metadata. | No remediation required. |
+| pass | calibration_certificate_present | Calibration certificate metadata and artifact hash are present. | No remediation required. |
+| pass | calibration_current | Calibration certificate is current at review time. | No remediation required. |
+| pass | experiment_edges_covered | All experiment edges fall inside the calibration validity window. | No remediation required. |
+| pass | unit_compatibility_ready | Experiment measurement units match declared instrument capabilities. | No remediation required. |
+| pass | recommendation_controls_ready | Recommendation targets can be held when calibration evidence is not publishable. | No remediation required. |
+
+### instrument:microscope:confocal-17
+
+- Decision: HOLD_FOR_CURATOR
+- Instrument type: confocal_microscope
+- Certificate: cal-2025-micro-17
+- Experiment edges: 1
+
+| Severity | Code | Message | Remediation |
+| --- | --- | --- | --- |
+| pass | instrument_identity_ready | Instrument node has stable model, serial, type, and graph ID metadata. | No remediation required. |
+| pass | calibration_certificate_present | Calibration certificate metadata and artifact hash are present. | No remediation required. |
+| review | calibration_expired | Calibration certificate expired at 2025-12-31T23:59:59.000Z. | Refresh calibration evidence or hold graph publication for affected instrument edges. |
+| pass | experiment_edges_covered | All experiment edges fall inside the calibration validity window. | No remediation required. |
+| pass | unit_compatibility_ready | Experiment measurement units match declared instrument capabilities. | No remediation required. |
+| pass | recommendation_controls_ready | Recommendation targets can be held when calibration evidence is not publishable. | No remediation required. |
+
+### instrument:sequencer:seq-09
+
+- Decision: BLOCK_RECOMMENDATION
+- Instrument type: sequencer
+- Certificate: cal-2026-seq-09
+- Experiment edges: 1
+
+| Severity | Code | Message | Remediation |
+| --- | --- | --- | --- |
+| pass | instrument_identity_ready | Instrument node has stable model, serial, type, and graph ID metadata. | No remediation required. |
+| block | calibration_certificate_missing | Calibration certificate evidence missing: artifactHash. | Attach calibration certificate ID, issuer, artifact hash, and validity dates. |
+| block | calibration_revoked | Calibration certificate is revoked. | Remove the instrument from public recommendations until a valid replacement certificate is attached. |
+| block | experiment_outside_calibration_window | Experiment edges outside calibration window: edge:rnaseq-run-19. | Split or hold experiment edges measured outside the calibration validity window. |
+| review | unit_incompatible | Measurement units not declared for instrument capability: edge:rnaseq-run-19:cycles. | Normalize units or route the graph packet to a curator for capability review. |
+| block | recommendation_without_hold_control | Recommendation targets need hold controls before publication: recommend:single-cell-pipeline. | Add hold controls so recommendation edges are suppressed when calibration evidence fails. |
diff --git a/instrument-calibration-graph-guard/artifacts/instrument-calibration-results.json b/instrument-calibration-graph-guard/artifacts/instrument-calibration-results.json
new file mode 100644
index 00000000..dd1eae9e
--- /dev/null
+++ b/instrument-calibration-graph-guard/artifacts/instrument-calibration-results.json
@@ -0,0 +1,164 @@
+[
+ {
+ "graphNodeId": "instrument:mass-spec:orbitrap-0021",
+ "decision": "PUBLISH_GRAPH_EDGE",
+ "checkedAt": "2026-06-12T00:00:00.000Z",
+ "summary": {
+ "blocks": 0,
+ "reviews": 0,
+ "passes": 6
+ },
+ "findings": [
+ {
+ "code": "instrument_identity_ready",
+ "severity": "pass",
+ "message": "Instrument node has stable model, serial, type, and graph ID metadata.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "calibration_certificate_present",
+ "severity": "pass",
+ "message": "Calibration certificate metadata and artifact hash are present.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "calibration_current",
+ "severity": "pass",
+ "message": "Calibration certificate is current at review time.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "experiment_edges_covered",
+ "severity": "pass",
+ "message": "All experiment edges fall inside the calibration validity window.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "unit_compatibility_ready",
+ "severity": "pass",
+ "message": "Experiment measurement units match declared instrument capabilities.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "recommendation_controls_ready",
+ "severity": "pass",
+ "message": "Recommendation targets can be held when calibration evidence is not publishable.",
+ "remediation": "No remediation required."
+ }
+ ],
+ "audit": {
+ "instrumentType": "mass_spectrometer",
+ "certificateId": "cal-2026-ms-0021",
+ "experimentCount": 1,
+ "recommendationTargets": 1
+ }
+ },
+ {
+ "graphNodeId": "instrument:microscope:confocal-17",
+ "decision": "HOLD_FOR_CURATOR",
+ "checkedAt": "2026-06-12T00:00:00.000Z",
+ "summary": {
+ "blocks": 0,
+ "reviews": 1,
+ "passes": 5
+ },
+ "findings": [
+ {
+ "code": "instrument_identity_ready",
+ "severity": "pass",
+ "message": "Instrument node has stable model, serial, type, and graph ID metadata.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "calibration_certificate_present",
+ "severity": "pass",
+ "message": "Calibration certificate metadata and artifact hash are present.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "calibration_expired",
+ "severity": "review",
+ "message": "Calibration certificate expired at 2025-12-31T23:59:59.000Z.",
+ "remediation": "Refresh calibration evidence or hold graph publication for affected instrument edges."
+ },
+ {
+ "code": "experiment_edges_covered",
+ "severity": "pass",
+ "message": "All experiment edges fall inside the calibration validity window.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "unit_compatibility_ready",
+ "severity": "pass",
+ "message": "Experiment measurement units match declared instrument capabilities.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "recommendation_controls_ready",
+ "severity": "pass",
+ "message": "Recommendation targets can be held when calibration evidence is not publishable.",
+ "remediation": "No remediation required."
+ }
+ ],
+ "audit": {
+ "instrumentType": "confocal_microscope",
+ "certificateId": "cal-2025-micro-17",
+ "experimentCount": 1,
+ "recommendationTargets": 1
+ }
+ },
+ {
+ "graphNodeId": "instrument:sequencer:seq-09",
+ "decision": "BLOCK_RECOMMENDATION",
+ "checkedAt": "2026-06-12T00:00:00.000Z",
+ "summary": {
+ "blocks": 4,
+ "reviews": 1,
+ "passes": 1
+ },
+ "findings": [
+ {
+ "code": "instrument_identity_ready",
+ "severity": "pass",
+ "message": "Instrument node has stable model, serial, type, and graph ID metadata.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "calibration_certificate_missing",
+ "severity": "block",
+ "message": "Calibration certificate evidence missing: artifactHash.",
+ "remediation": "Attach calibration certificate ID, issuer, artifact hash, and validity dates."
+ },
+ {
+ "code": "calibration_revoked",
+ "severity": "block",
+ "message": "Calibration certificate is revoked.",
+ "remediation": "Remove the instrument from public recommendations until a valid replacement certificate is attached."
+ },
+ {
+ "code": "experiment_outside_calibration_window",
+ "severity": "block",
+ "message": "Experiment edges outside calibration window: edge:rnaseq-run-19.",
+ "remediation": "Split or hold experiment edges measured outside the calibration validity window."
+ },
+ {
+ "code": "unit_incompatible",
+ "severity": "review",
+ "message": "Measurement units not declared for instrument capability: edge:rnaseq-run-19:cycles.",
+ "remediation": "Normalize units or route the graph packet to a curator for capability review."
+ },
+ {
+ "code": "recommendation_without_hold_control",
+ "severity": "block",
+ "message": "Recommendation targets need hold controls before publication: recommend:single-cell-pipeline.",
+ "remediation": "Add hold controls so recommendation edges are suppressed when calibration evidence fails."
+ }
+ ],
+ "audit": {
+ "instrumentType": "sequencer",
+ "certificateId": "cal-2026-seq-09",
+ "experimentCount": 1,
+ "recommendationTargets": 1
+ }
+ }
+]
diff --git a/instrument-calibration-graph-guard/artifacts/instrument-calibration-summary.svg b/instrument-calibration-graph-guard/artifacts/instrument-calibration-summary.svg
new file mode 100644
index 00000000..50ac0925
--- /dev/null
+++ b/instrument-calibration-graph-guard/artifacts/instrument-calibration-summary.svg
@@ -0,0 +1,23 @@
+
diff --git a/instrument-calibration-graph-guard/examples/instrument-graph-packets.json b/instrument-calibration-graph-guard/examples/instrument-graph-packets.json
new file mode 100644
index 00000000..8f90e466
--- /dev/null
+++ b/instrument-calibration-graph-guard/examples/instrument-graph-packets.json
@@ -0,0 +1,101 @@
+[
+ {
+ "instrument": {
+ "nodeId": "instrument:mass-spec:orbitrap-0021",
+ "type": "mass_spectrometer",
+ "model": "Orbitrap Exploris 480",
+ "serialNumber": "OE480-21",
+ "capabilityUnits": ["ppm", "m/z", "Da"]
+ },
+ "calibration": {
+ "certificateId": "cal-2026-ms-0021",
+ "issuer": "SCIBASE Metrology Core",
+ "artifactHash": "sha256:26d78a1c174fd405f8f6d3d403cc6df1a4338a4c9fe1adf5e8c4b85e6f75be33",
+ "validFrom": "2026-01-01T00:00:00Z",
+ "validUntil": "2026-12-31T23:59:59Z",
+ "revoked": false
+ },
+ "experimentEdges": [
+ {
+ "edgeId": "edge:protein-atlas-run-77",
+ "projectId": "protein-atlas",
+ "measuredAt": "2026-04-12T10:15:00Z",
+ "measurementUnit": "ppm"
+ }
+ ],
+ "recommendationTargets": [
+ {
+ "targetId": "recommend:similar-proteomics-workflows",
+ "usesInstrumentReliability": true,
+ "requiresCurrentCalibration": true,
+ "publicationMode": "holdable"
+ }
+ ]
+ },
+ {
+ "instrument": {
+ "nodeId": "instrument:microscope:confocal-17",
+ "type": "confocal_microscope",
+ "model": "LSM 980",
+ "serialNumber": "LSM980-17",
+ "capabilityUnits": ["nm", "um", "pixel"]
+ },
+ "calibration": {
+ "certificateId": "cal-2025-micro-17",
+ "issuer": "Imaging Core",
+ "artifactHash": "sha256:80dfe725cb5f2ed3ab2237ae6d8ac7d759956275961874cc47bdbf48c8562d5f",
+ "validFrom": "2025-01-01T00:00:00Z",
+ "validUntil": "2025-12-31T23:59:59Z",
+ "revoked": false
+ },
+ "experimentEdges": [
+ {
+ "edgeId": "edge:neuron-synapse-image-12",
+ "projectId": "synapse-map",
+ "measuredAt": "2025-10-15T09:30:00Z",
+ "measurementUnit": "nm"
+ }
+ ],
+ "recommendationTargets": [
+ {
+ "targetId": "recommend:microscopy-protocols",
+ "usesInstrumentReliability": true,
+ "requiresCurrentCalibration": true,
+ "publicationMode": "holdable"
+ }
+ ]
+ },
+ {
+ "instrument": {
+ "nodeId": "instrument:sequencer:seq-09",
+ "type": "sequencer",
+ "model": "NovaSeq X",
+ "serialNumber": "NSX-09",
+ "capabilityUnits": ["reads", "bp"]
+ },
+ "calibration": {
+ "certificateId": "cal-2026-seq-09",
+ "issuer": "Genome Core",
+ "artifactHash": "",
+ "validFrom": "2026-01-01T00:00:00Z",
+ "validUntil": "2026-06-01T00:00:00Z",
+ "revoked": true
+ },
+ "experimentEdges": [
+ {
+ "edgeId": "edge:rnaseq-run-19",
+ "projectId": "immune-atlas",
+ "measuredAt": "2026-06-10T11:20:00Z",
+ "measurementUnit": "cycles"
+ }
+ ],
+ "recommendationTargets": [
+ {
+ "targetId": "recommend:single-cell-pipeline",
+ "usesInstrumentReliability": true,
+ "requiresCurrentCalibration": true,
+ "publicationMode": "public"
+ }
+ ]
+ }
+]
diff --git a/instrument-calibration-graph-guard/package.json b/instrument-calibration-graph-guard/package.json
new file mode 100644
index 00000000..5fa93dda
--- /dev/null
+++ b/instrument-calibration-graph-guard/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "instrument-calibration-graph-guard",
+ "version": "1.0.0",
+ "description": "Knowledge graph guard for instrument calibration provenance before entity/recommendation publication.",
+ "type": "module",
+ "scripts": {
+ "test": "node test/instrumentCalibrationGraphGuard.test.js",
+ "demo": "node scripts/demo.js"
+ },
+ "license": "MIT"
+}
diff --git a/instrument-calibration-graph-guard/scripts/demo.js b/instrument-calibration-graph-guard/scripts/demo.js
new file mode 100644
index 00000000..78d7d173
--- /dev/null
+++ b/instrument-calibration-graph-guard/scripts/demo.js
@@ -0,0 +1,146 @@
+import { mkdir, writeFile } from "node:fs/promises"
+import { spawnSync } from "node:child_process"
+import { join } from "node:path"
+import { tmpdir } from "node:os"
+import packets from "../examples/instrument-graph-packets.json" with { type: "json" }
+import { evaluateInstrumentGraphBatch, summarize } from "../src/index.js"
+
+const ARTIFACT_DIR = new URL("../artifacts/", import.meta.url)
+const now = "2026-06-12T00:00:00Z"
+const results = evaluateInstrumentGraphBatch(packets, { now })
+const summary = summarize(results)
+
+await mkdir(ARTIFACT_DIR, { recursive: true })
+await writeFile(new URL("instrument-calibration-results.json", ARTIFACT_DIR), `${JSON.stringify(results, null, 2)}\n`)
+await writeFile(new URL("instrument-calibration-report.md", ARTIFACT_DIR), renderReport(results, summary))
+await writeFile(new URL("instrument-calibration-summary.svg", ARTIFACT_DIR), renderSvg(results, summary))
+await renderMp4()
+
+console.log("Instrument calibration graph guard demo generated")
+console.log(`- decisions: ${results.map((result) => `${result.graphNodeId}:${result.decision}`).join(", ")}`)
+console.log(`- summary: ${JSON.stringify(summary)}`)
+
+function renderReport(results, summary) {
+ const lines = [
+ "# Instrument Calibration Graph Guard Report",
+ "",
+ `Generated: ${now}`,
+ "",
+ "## Summary",
+ "",
+ `- Blocking findings: ${summary.blocks}`,
+ `- Review findings: ${summary.reviews}`,
+ `- Passing findings: ${summary.passes}`,
+ "",
+ "## Instrument Decisions",
+ ""
+ ]
+
+ for (const result of results) {
+ lines.push(`### ${result.graphNodeId}`)
+ lines.push("")
+ lines.push(`- Decision: ${result.decision}`)
+ lines.push(`- Instrument type: ${result.audit.instrumentType}`)
+ lines.push(`- Certificate: ${result.audit.certificateId ?? "none"}`)
+ lines.push(`- Experiment edges: ${result.audit.experimentCount}`)
+ lines.push("")
+ lines.push("| Severity | Code | Message | Remediation |")
+ lines.push("| --- | --- | --- | --- |")
+ for (const finding of result.findings) {
+ lines.push(`| ${finding.severity} | ${finding.code} | ${finding.message} | ${finding.remediation} |`)
+ }
+ lines.push("")
+ }
+
+ return `${lines.join("\n")}\n`
+}
+
+function renderSvg(results, summary) {
+ const rows = results.map((result, index) => {
+ const y = 126 + index * 66
+ const color = result.decision === "PUBLISH_GRAPH_EDGE" ? "#15803d" : result.decision === "HOLD_FOR_CURATOR" ? "#b45309" : "#b91c1c"
+ return `
+ ${escapeXml(result.graphNodeId)}
+
+ ${result.decision}`
+ }).join("\n")
+
+ return `
+`
+}
+
+async function renderMp4() {
+ const ppmPath = join(tmpdir(), `instrument-calibration-frame-${Date.now()}.ppm`)
+ const mp4Path = new URL("instrument-calibration-demo.mp4", ARTIFACT_DIR)
+ await writeFile(ppmPath, renderPpmFrame())
+
+ const ffmpeg = spawnSync("ffmpeg", [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-loop",
+ "1",
+ "-framerate",
+ "1",
+ "-i",
+ ppmPath,
+ "-t",
+ "5",
+ "-pix_fmt",
+ "yuv420p",
+ mp4Path.pathname
+ ], { encoding: "utf8" })
+
+ if (ffmpeg.status !== 0) {
+ await writeFile(new URL("instrument-calibration-demo-fallback.txt", ARTIFACT_DIR), ffmpeg.stderr || "ffmpeg failed")
+ }
+}
+
+function renderPpmFrame() {
+ const width = 920
+ const height = 360
+ const pixels = Buffer.alloc(width * height * 3, 248)
+
+ fillRect(pixels, width, 0, 0, width, height, [248, 250, 252])
+ fillRect(pixels, width, 40, 48, 840, 38, [15, 23, 42])
+ fillRect(pixels, width, 40, 112, 250, 72, [21, 128, 61])
+ fillRect(pixels, width, 335, 112, 250, 72, [180, 83, 9])
+ fillRect(pixels, width, 630, 112, 250, 72, [185, 28, 28])
+ fillRect(pixels, width, 40, 226, 840, 54, [226, 232, 240])
+ fillRect(pixels, width, 40, 304, 250, 18, [21, 128, 61])
+ fillRect(pixels, width, 335, 304, 250, 18, [180, 83, 9])
+ fillRect(pixels, width, 630, 304, 250, 18, [185, 28, 28])
+
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii")
+ return Buffer.concat([header, pixels])
+}
+
+function fillRect(pixels, imageWidth, x, y, width, height, rgb) {
+ for (let row = y; row < y + height; row += 1) {
+ for (let col = x; col < x + width; col += 1) {
+ const offset = (row * imageWidth + col) * 3
+ pixels[offset] = rgb[0]
+ pixels[offset + 1] = rgb[1]
+ pixels[offset + 2] = rgb[2]
+ }
+ }
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+}
diff --git a/instrument-calibration-graph-guard/src/index.js b/instrument-calibration-graph-guard/src/index.js
new file mode 100644
index 00000000..71f1c344
--- /dev/null
+++ b/instrument-calibration-graph-guard/src/index.js
@@ -0,0 +1,189 @@
+export function evaluateInstrumentGraphPacket(packet, options = {}) {
+ const now = normalizeDate(options.now ?? new Date())
+ const findings = []
+
+ checkInstrumentIdentity(packet, findings)
+ checkCertificatePresence(packet, findings)
+ checkCertificateValidity(packet, findings, now)
+ checkExperimentCoverage(packet, findings)
+ checkUnitCompatibility(packet, findings)
+ checkRecommendationSafety(packet, findings)
+
+ const blocks = findings.filter((finding) => finding.severity === "block")
+ const reviews = findings.filter((finding) => finding.severity === "review")
+
+ const decision = blocks.length
+ ? "BLOCK_RECOMMENDATION"
+ : reviews.length
+ ? "HOLD_FOR_CURATOR"
+ : "PUBLISH_GRAPH_EDGE"
+
+ return {
+ graphNodeId: packet.instrument?.nodeId ?? "unknown",
+ decision,
+ checkedAt: now.toISOString(),
+ summary: summarize(findings),
+ findings,
+ audit: {
+ instrumentType: packet.instrument?.type ?? "unknown",
+ certificateId: packet.calibration?.certificateId ?? null,
+ experimentCount: packet.experimentEdges?.length ?? 0,
+ recommendationTargets: packet.recommendationTargets?.length ?? 0
+ }
+ }
+}
+
+export function evaluateInstrumentGraphBatch(packets, options = {}) {
+ return packets.map((packet) => evaluateInstrumentGraphPacket(packet, options))
+}
+
+export function summarize(resultsOrFindings) {
+ const findings = Array.isArray(resultsOrFindings)
+ ? resultsOrFindings.flatMap((item) => item.findings ?? item)
+ : []
+
+ return {
+ blocks: findings.filter((finding) => finding.severity === "block").length,
+ reviews: findings.filter((finding) => finding.severity === "review").length,
+ passes: findings.filter((finding) => finding.severity === "pass").length
+ }
+}
+
+function checkInstrumentIdentity(packet, findings) {
+ const instrument = packet.instrument ?? {}
+ const missing = ["nodeId", "model", "serialNumber", "type"].filter((field) => !instrument[field])
+
+ if (missing.length) {
+ findings.push(issue("instrument_identity_incomplete", "block", `Instrument identity missing: ${missing.join(", ")}.`))
+ return
+ }
+
+ findings.push(issue("instrument_identity_ready", "pass", "Instrument node has stable model, serial, type, and graph ID metadata."))
+}
+
+function checkCertificatePresence(packet, findings) {
+ const calibration = packet.calibration ?? {}
+ const missing = ["certificateId", "issuer", "artifactHash", "validFrom", "validUntil"].filter((field) => !calibration[field])
+
+ if (missing.length) {
+ findings.push(issue("calibration_certificate_missing", "block", `Calibration certificate evidence missing: ${missing.join(", ")}.`))
+ return
+ }
+
+ findings.push(issue("calibration_certificate_present", "pass", "Calibration certificate metadata and artifact hash are present."))
+}
+
+function checkCertificateValidity(packet, findings, now) {
+ const calibration = packet.calibration ?? {}
+ if (calibration.revoked === true) {
+ findings.push(issue("calibration_revoked", "block", "Calibration certificate is revoked."))
+ return
+ }
+
+ const validFrom = parseOptionalDate(calibration.validFrom)
+ const validUntil = parseOptionalDate(calibration.validUntil)
+ if (!validFrom || !validUntil) return
+
+ if (validUntil < now) {
+ findings.push(issue("calibration_expired", "review", `Calibration certificate expired at ${validUntil.toISOString()}.`))
+ return
+ }
+
+ findings.push(issue("calibration_current", "pass", "Calibration certificate is current at review time."))
+}
+
+function checkExperimentCoverage(packet, findings) {
+ const calibration = packet.calibration ?? {}
+ const validFrom = parseOptionalDate(calibration.validFrom)
+ const validUntil = parseOptionalDate(calibration.validUntil)
+ const edges = packet.experimentEdges ?? []
+
+ if (!edges.length) {
+ findings.push(issue("experiment_edges_missing", "review", "No experiment edges are attached to the instrument node."))
+ return
+ }
+
+ const uncovered = edges.filter((edge) => {
+ const measuredAt = parseOptionalDate(edge.measuredAt)
+ return !measuredAt || !validFrom || !validUntil || measuredAt < validFrom || measuredAt > validUntil
+ })
+
+ if (uncovered.length) {
+ findings.push(issue("experiment_outside_calibration_window", "block", `Experiment edges outside calibration window: ${uncovered.map((edge) => edge.edgeId).join(", ")}.`))
+ return
+ }
+
+ findings.push(issue("experiment_edges_covered", "pass", "All experiment edges fall inside the calibration validity window."))
+}
+
+function checkUnitCompatibility(packet, findings) {
+ const allowedUnits = new Set(packet.instrument?.capabilityUnits ?? [])
+ const incompatible = (packet.experimentEdges ?? []).filter((edge) => !allowedUnits.has(edge.measurementUnit))
+
+ if (allowedUnits.size === 0) {
+ findings.push(issue("capability_units_missing", "review", "Instrument capability units are missing."))
+ return
+ }
+
+ if (incompatible.length) {
+ findings.push(issue("unit_incompatible", "review", `Measurement units not declared for instrument capability: ${incompatible.map((edge) => `${edge.edgeId}:${edge.measurementUnit}`).join(", ")}.`))
+ return
+ }
+
+ findings.push(issue("unit_compatibility_ready", "pass", "Experiment measurement units match declared instrument capabilities."))
+}
+
+function checkRecommendationSafety(packet, findings) {
+ const targets = packet.recommendationTargets ?? []
+ if (!targets.length) {
+ findings.push(issue("recommendation_targets_missing", "review", "No recommendation targets are attached for graph navigation."))
+ return
+ }
+
+ const unsafe = targets.filter((target) => target.usesInstrumentReliability === true && target.requiresCurrentCalibration === true && target.publicationMode !== "holdable")
+ if (unsafe.length) {
+ findings.push(issue("recommendation_without_hold_control", "block", `Recommendation targets need hold controls before publication: ${unsafe.map((target) => target.targetId).join(", ")}.`))
+ return
+ }
+
+ findings.push(issue("recommendation_controls_ready", "pass", "Recommendation targets can be held when calibration evidence is not publishable."))
+}
+
+function issue(code, severity, message) {
+ return {
+ code,
+ severity,
+ message,
+ remediation: remediationFor(code)
+ }
+}
+
+function remediationFor(code) {
+ const remediations = {
+ instrument_identity_incomplete: "Add stable instrument node ID, type, model, and serial metadata before graph publication.",
+ calibration_certificate_missing: "Attach calibration certificate ID, issuer, artifact hash, and validity dates.",
+ calibration_revoked: "Remove the instrument from public recommendations until a valid replacement certificate is attached.",
+ calibration_expired: "Refresh calibration evidence or hold graph publication for affected instrument edges.",
+ experiment_edges_missing: "Attach at least one experiment edge or suppress empty instrument pages.",
+ experiment_outside_calibration_window: "Split or hold experiment edges measured outside the calibration validity window.",
+ capability_units_missing: "Declare instrument capability units before enabling unit-sensitive recommendations.",
+ unit_incompatible: "Normalize units or route the graph packet to a curator for capability review.",
+ recommendation_targets_missing: "Attach intended recommendation targets or mark the node as navigation-only.",
+ recommendation_without_hold_control: "Add hold controls so recommendation edges are suppressed when calibration evidence fails."
+ }
+
+ return remediations[code] ?? "No remediation required."
+}
+
+function normalizeDate(value) {
+ const date = value instanceof Date ? value : new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ throw new Error(`Invalid date: ${value}`)
+ }
+ return date
+}
+
+function parseOptionalDate(value) {
+ if (!value) return null
+ return normalizeDate(value)
+}
diff --git a/instrument-calibration-graph-guard/test/instrumentCalibrationGraphGuard.test.js b/instrument-calibration-graph-guard/test/instrumentCalibrationGraphGuard.test.js
new file mode 100644
index 00000000..91790712
--- /dev/null
+++ b/instrument-calibration-graph-guard/test/instrumentCalibrationGraphGuard.test.js
@@ -0,0 +1,39 @@
+import assert from "node:assert/strict"
+import packets from "../examples/instrument-graph-packets.json" with { type: "json" }
+import { evaluateInstrumentGraphBatch, evaluateInstrumentGraphPacket, summarize } from "../src/index.js"
+
+const now = "2026-06-12T00:00:00Z"
+const [ready, hold, blocked] = evaluateInstrumentGraphBatch(packets, { now })
+
+assert.equal(ready.decision, "PUBLISH_GRAPH_EDGE")
+assert.equal(ready.summary.blocks, 0)
+assert.equal(ready.summary.reviews, 0)
+assert.ok(ready.findings.some((finding) => finding.code === "calibration_current"))
+
+assert.equal(hold.decision, "HOLD_FOR_CURATOR")
+assert.ok(hold.findings.some((finding) => finding.code === "calibration_expired"))
+assert.equal(hold.summary.blocks, 0)
+
+assert.equal(blocked.decision, "BLOCK_RECOMMENDATION")
+assert.ok(blocked.findings.some((finding) => finding.code === "calibration_certificate_missing"))
+assert.ok(blocked.findings.some((finding) => finding.code === "calibration_revoked"))
+assert.ok(blocked.findings.some((finding) => finding.code === "experiment_outside_calibration_window"))
+assert.ok(blocked.findings.some((finding) => finding.code === "recommendation_without_hold_control"))
+
+const missingIdentity = structuredClone(packets[0])
+delete missingIdentity.instrument.serialNumber
+const missingIdentityResult = evaluateInstrumentGraphPacket(missingIdentity, { now })
+assert.equal(missingIdentityResult.decision, "BLOCK_RECOMMENDATION")
+assert.ok(missingIdentityResult.findings.some((finding) => finding.code === "instrument_identity_incomplete"))
+
+const noTargets = structuredClone(packets[0])
+noTargets.recommendationTargets = []
+const noTargetsResult = evaluateInstrumentGraphPacket(noTargets, { now })
+assert.equal(noTargetsResult.decision, "HOLD_FOR_CURATOR")
+assert.ok(noTargetsResult.findings.some((finding) => finding.code === "recommendation_targets_missing"))
+
+const batchSummary = summarize([ready, hold, blocked])
+assert.equal(batchSummary.blocks, blocked.summary.blocks)
+assert.equal(batchSummary.reviews, hold.summary.reviews + blocked.summary.reviews)
+
+console.log("instrument calibration graph guard tests passed")