diff --git a/knowledge-graph-temporal-validity-guard/README.md b/knowledge-graph-temporal-validity-guard/README.md
new file mode 100644
index 00000000..5c518347
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/README.md
@@ -0,0 +1,25 @@
+# Knowledge Graph Temporal Validity Guard
+
+Focused Scientific Knowledge Graph Integration slice for issue #17.
+
+The guard validates graph snapshots before entity pages and AI recommendations are shown. It checks whether nodes, edges, dataset versions, method applicability records, institution affiliations, and recommendation paths are effective on the graph snapshot date and have not expired or drifted to stale versions.
+
+## Run
+
+```bash
+npm test
+npm run check
+npm run demo
+npm run video
+```
+
+## Reviewer Artifacts
+
+- `reports/temporal-validity-packet.json`
+- `reports/temporal-validity-report.md`
+- `reports/summary.svg`
+- `reports/demo.mp4`
+
+## Scope
+
+This is not an evidence freshness, ontology drift, author disambiguation, recommendation diversity, or accession crosswalk module. It focuses only on effective-date, expiry-date, and version-window validity for graph objects at one snapshot date.
diff --git a/knowledge-graph-temporal-validity-guard/acceptance-notes.md b/knowledge-graph-temporal-validity-guard/acceptance-notes.md
new file mode 100644
index 00000000..722c5a74
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/acceptance-notes.md
@@ -0,0 +1,6 @@
+# Acceptance Notes
+
+- Dependency-free Node.js implementation.
+- Synthetic fixtures only; no private graph data, external ontology services, credentials, network calls, or live graph mutations.
+- TDD red check was observed before implementation: `npm --prefix knowledge-graph-temporal-validity-guard test` failed because `./index` did not exist.
+- Demo intentionally includes one temporal hold packet and one temporal-ready packet.
diff --git a/knowledge-graph-temporal-validity-guard/demo.js b/knowledge-graph-temporal-validity-guard/demo.js
new file mode 100644
index 00000000..7a23e15b
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/demo.js
@@ -0,0 +1,76 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const {analyzeTemporalGraph} = require('./index');
+const {temporalRiskGraph, temporalValidGraph} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const risk = analyzeTemporalGraph(temporalRiskGraph);
+const clean = analyzeTemporalGraph(temporalValidGraph);
+const packet = {
+ generatedAt: new Date('2026-05-23T00:00:00.000Z').toISOString(),
+ guard: 'knowledge-graph-temporal-validity-guard',
+ results: [risk, clean],
+};
+
+fs.writeFileSync(path.join(reportsDir, 'temporal-validity-packet.json'), `${JSON.stringify(packet, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, 'temporal-validity-report.md'), renderMarkdown(packet));
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), renderSvg(risk, clean));
+
+console.log(`risk=${risk.status} blockers=${risk.summary.blockers} suppressed=${risk.summary.suppressedRecommendations}`);
+console.log(`clean=${clean.status} blockers=${clean.summary.blockers} suppressed=${clean.summary.suppressedRecommendations}`);
+console.log(`reports=${reportsDir}`);
+
+function renderMarkdown(data) {
+ const rows = data.results.map((result) => (
+ `| ${result.graphId} | ${result.status} | ${result.summary.blockers} | ${result.summary.reviewItems} | ${result.summary.suppressedRecommendations} | ${result.digest} |`
+ ));
+ const findings = data.results
+ .flatMap((result) => result.findings.map((finding) => `- ${result.graphId}: ${finding.code} on ${finding.targetType} \`${finding.targetId}\` - ${finding.message}`))
+ .join('\n');
+
+ return `# Knowledge Graph Temporal Validity Guard
+
+This reviewer packet checks whether graph nodes, relationship edges, dataset versions, method applicability windows, author affiliations, and recommendation paths are valid for the graph snapshot date before entity pages or AI recommendations are shown.
+
+| Graph | Status | Blockers | Review items | Suppressed recommendations | Digest |
+| --- | --- | ---: | ---: | ---: | --- |
+${rows.join('\n')}
+
+## Findings
+
+${findings || '- No temporal findings.'}
+
+## Scope Boundaries
+
+- Synthetic graph fixtures only.
+- No network calls, live ontology APIs, private research objects, or graph database mutations.
+- Distinct from evidence freshness: this guard checks effective/expiry windows and version validity for graph objects at a specific snapshot date.
+`;
+}
+
+function renderSvg(risk, clean) {
+ return `
+`;
+}
diff --git a/knowledge-graph-temporal-validity-guard/index.js b/knowledge-graph-temporal-validity-guard/index.js
new file mode 100644
index 00000000..c4bfeb9d
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/index.js
@@ -0,0 +1,186 @@
+const crypto = require('node:crypto');
+
+function parseDate(value) {
+ if (!value) return null;
+ const date = new Date(`${value}T00:00:00.000Z`);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function stableStringify(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => stableStringify(item)).join(',')}]`;
+ }
+ if (value && typeof value === 'object') {
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
+ .join(',')}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function digest(value) {
+ return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`;
+}
+
+function makeFinding(code, severity, targetType, targetId, message, evidence = {}) {
+ return {code, severity, targetType, targetId, message, evidence};
+}
+
+function isExpired(record, asOfDate) {
+ const until = parseDate(record.validUntil);
+ return Boolean(until && until < asOfDate);
+}
+
+function isFuture(record, asOfDate) {
+ const from = parseDate(record.validFrom);
+ return Boolean(from && from > asOfDate);
+}
+
+function analyzeTemporalGraph(graph) {
+ const asOfDate = parseDate(graph.asOf);
+ if (!asOfDate) {
+ throw new Error('graph.asOf must be a valid YYYY-MM-DD date');
+ }
+
+ const nodes = new Map((graph.nodes || []).map((node) => [node.id, node]));
+ const edges = new Map((graph.edges || []).map((edge) => [edge.id, edge]));
+ const findings = [];
+
+ for (const node of nodes.values()) {
+ if (isFuture(node, asOfDate)) {
+ findings.push(makeFinding(
+ 'NODE_NOT_YET_EFFECTIVE',
+ 'blocker',
+ 'node',
+ node.id,
+ `Node ${node.id} is not effective on ${graph.asOf}.`,
+ {validFrom: node.validFrom},
+ ));
+ }
+ if (isExpired(node, asOfDate)) {
+ findings.push(makeFinding(
+ 'NODE_EXPIRED',
+ 'blocker',
+ 'node',
+ node.id,
+ `Node ${node.id} expired before ${graph.asOf}.`,
+ {validUntil: node.validUntil},
+ ));
+ }
+ }
+
+ for (const edge of edges.values()) {
+ const target = nodes.get(edge.to);
+ const expired = isExpired(edge, asOfDate);
+ const future = isFuture(edge, asOfDate);
+ if (future) {
+ findings.push(makeFinding(
+ 'EDGE_NOT_YET_EFFECTIVE',
+ 'blocker',
+ 'edge',
+ edge.id,
+ `Edge ${edge.id} is not effective on ${graph.asOf}.`,
+ {validFrom: edge.validFrom},
+ ));
+ }
+ if (expired) {
+ if (edge.type === 'affiliated-with') {
+ findings.push(makeFinding(
+ 'AFFILIATION_EXPIRED',
+ 'review',
+ 'edge',
+ edge.id,
+ `Affiliation edge ${edge.id} expired and needs curator review.`,
+ {validUntil: edge.validUntil},
+ ));
+ } else {
+ findings.push(makeFinding(
+ 'EDGE_EXPIRED',
+ 'blocker',
+ 'edge',
+ edge.id,
+ `Edge ${edge.id} expired before ${graph.asOf}.`,
+ {validUntil: edge.validUntil},
+ ));
+ }
+ }
+ if (target && edge.evidenceVersion && target.version && edge.evidenceVersion !== target.version) {
+ findings.push(makeFinding(
+ 'EVIDENCE_VERSION_DRIFT',
+ 'blocker',
+ 'edge',
+ edge.id,
+ `Edge ${edge.id} cites ${edge.evidenceVersion}, but target ${target.id} is ${target.version}.`,
+ {edgeVersion: edge.evidenceVersion, targetVersion: target.version, targetId: target.id},
+ ));
+ }
+ }
+
+ const blockingTargets = new Set(
+ findings
+ .filter((finding) => finding.severity === 'blocker')
+ .map((finding) => `${finding.targetType}:${finding.targetId}`),
+ );
+
+ const recommendationDecisions = (graph.recommendations || []).map((recommendation) => {
+ const reasons = [];
+ for (const nodeId of recommendation.path || []) {
+ if (blockingTargets.has(`node:${nodeId}`)) reasons.push(`NODE_BLOCKED:${nodeId}`);
+ }
+ for (const edgeId of recommendation.edgeIds || []) {
+ if (blockingTargets.has(`edge:${edgeId}`)) reasons.push(`EDGE_BLOCKED:${edgeId}`);
+ }
+ return {
+ id: recommendation.id,
+ title: recommendation.title,
+ decision: reasons.length ? 'suppress' : 'publish',
+ reasons,
+ };
+ });
+
+ const blockers = findings.filter((finding) => finding.severity === 'blocker').length;
+ const reviewItems = findings.filter((finding) => finding.severity === 'review').length;
+ const status = blockers ? 'hold_temporal_review' : (reviewItems ? 'needs_curator_review' : 'temporal_ready');
+ const summary = {
+ blockers,
+ reviewItems,
+ nodeFindings: findings.filter((finding) => finding.targetType === 'node').length,
+ edgeFindings: findings.filter((finding) => finding.targetType === 'edge').length,
+ versionDrift: findings.filter((finding) => finding.code === 'EVIDENCE_VERSION_DRIFT').length,
+ suppressedRecommendations: recommendationDecisions.filter((item) => item.decision === 'suppress').length,
+ };
+
+ return {
+ graphId: graph.graphId,
+ asOf: graph.asOf,
+ status,
+ summary,
+ findings,
+ recommendationDecisions,
+ curatorActions: buildCuratorActions(findings),
+ digest: digest({
+ graphId: graph.graphId,
+ asOf: graph.asOf,
+ findings,
+ recommendationDecisions,
+ }),
+ };
+}
+
+function buildCuratorActions(findings) {
+ const actions = new Set();
+ for (const finding of findings) {
+ if (finding.code === 'NODE_EXPIRED') actions.add('Retire expired nodes from public entity pages or map them to a current version.');
+ if (finding.code === 'NODE_NOT_YET_EFFECTIVE') actions.add('Keep future-dated nodes hidden until their effective date.');
+ if (finding.code === 'EDGE_EXPIRED') actions.add('Remove or replace expired relationship edges before graph recommendation release.');
+ if (finding.code === 'EDGE_NOT_YET_EFFECTIVE') actions.add('Delay relationship publication until the effective date.');
+ if (finding.code === 'AFFILIATION_EXPIRED') actions.add('Route expired affiliations to curator review before profile/entity-page display.');
+ if (finding.code === 'EVIDENCE_VERSION_DRIFT') actions.add('Refresh relationship evidence to match the current target node or dataset version.');
+ }
+ return [...actions];
+}
+
+module.exports = {
+ analyzeTemporalGraph,
+};
diff --git a/knowledge-graph-temporal-validity-guard/package.json b/knowledge-graph-temporal-validity-guard/package.json
new file mode 100644
index 00000000..0adc5f34
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "knowledge-graph-temporal-validity-guard",
+ "version": "1.0.0",
+ "private": true,
+ "type": "commonjs",
+ "scripts": {
+ "test": "node test.js",
+ "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check render-video.js && node --check test.js",
+ "demo": "node demo.js",
+ "video": "node render-video.js"
+ }
+}
diff --git a/knowledge-graph-temporal-validity-guard/render-video.js b/knowledge-graph-temporal-validity-guard/render-video.js
new file mode 100644
index 00000000..5a7501b3
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/render-video.js
@@ -0,0 +1,49 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const {spawnSync} = require('node:child_process');
+
+const reportsDir = path.join(__dirname, 'reports');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const candidates = [
+ process.env.FFMPEG_PATH,
+ path.join(__dirname, '..', '..', '..', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe'),
+ path.join(__dirname, '..', '..', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe'),
+ path.join(__dirname, 'node_modules', 'ffmpeg-static', 'ffmpeg.exe'),
+ 'ffmpeg',
+].filter(Boolean);
+
+const ffmpeg = candidates.find((candidate) => candidate === 'ffmpeg' || fs.existsSync(candidate));
+if (!ffmpeg) {
+ throw new Error('ffmpeg not found. Set FFMPEG_PATH or install ffmpeg-static.');
+}
+
+const output = path.join(reportsDir, 'demo.mp4');
+const vf = [
+ 'drawbox=x=70:y=70:w=1140:h=580:color=white@0.95:t=fill',
+ 'drawbox=x=120:y=130:w=460:h=360:color=0xb42318@0.88:t=fill',
+ 'drawbox=x=700:y=130:w=460:h=360:color=0x147d3f@0.88:t=fill',
+ 'drawbox=x=165:y=535:w=950:h=45:color=0x374151@0.95:t=fill',
+ 'drawbox=x=165:y=535:w=330:h=45:color=0xb42318@0.95:t=fill',
+ 'drawbox=x=500:y=535:w=260:h=45:color=0xf59e0b@0.95:t=fill',
+ 'drawbox=x=760:y=535:w=355:h=45:color=0x147d3f@0.95:t=fill',
+ 'drawbox=x=120:y=510:w=460:h=26:color=0xfef3c7@0.95:t=fill',
+ 'drawbox=x=700:y=510:w=460:h=26:color=0xd1fae5@0.95:t=fill',
+].join(',');
+
+const result = spawnSync(ffmpeg, [
+ '-y',
+ '-loglevel', 'error',
+ '-f', 'lavfi',
+ '-i', 'color=c=0x1f2933:s=1280x720:d=12:r=15',
+ '-vf', vf,
+ '-pix_fmt', 'yuv420p',
+ '-c:v', 'libx264',
+ output,
+], {stdio: 'inherit'});
+
+if (result.status !== 0) {
+ throw new Error(`ffmpeg failed with status ${result.status}: ${result.error ? result.error.message : 'see ffmpeg output'}`);
+}
+
+console.log(output);
diff --git a/knowledge-graph-temporal-validity-guard/reports/demo.mp4 b/knowledge-graph-temporal-validity-guard/reports/demo.mp4
new file mode 100644
index 00000000..9a781ff1
Binary files /dev/null and b/knowledge-graph-temporal-validity-guard/reports/demo.mp4 differ
diff --git a/knowledge-graph-temporal-validity-guard/reports/summary.svg b/knowledge-graph-temporal-validity-guard/reports/summary.svg
new file mode 100644
index 00000000..a4e94359
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/reports/summary.svg
@@ -0,0 +1,21 @@
+
diff --git a/knowledge-graph-temporal-validity-guard/reports/temporal-validity-packet.json b/knowledge-graph-temporal-validity-guard/reports/temporal-validity-packet.json
new file mode 100644
index 00000000..323ec0b3
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/reports/temporal-validity-packet.json
@@ -0,0 +1,149 @@
+{
+ "generatedAt": "2026-05-23T00:00:00.000Z",
+ "guard": "knowledge-graph-temporal-validity-guard",
+ "results": [
+ {
+ "graphId": "kg-temporal-review-2026-05",
+ "asOf": "2026-05-23",
+ "status": "hold_temporal_review",
+ "summary": {
+ "blockers": 6,
+ "reviewItems": 1,
+ "nodeFindings": 2,
+ "edgeFindings": 5,
+ "versionDrift": 2,
+ "suppressedRecommendations": 2
+ },
+ "findings": [
+ {
+ "code": "NODE_EXPIRED",
+ "severity": "blocker",
+ "targetType": "node",
+ "targetId": "dataset-scrna-v2",
+ "message": "Node dataset-scrna-v2 expired before 2026-05-23.",
+ "evidence": {
+ "validUntil": "2026-04-15"
+ }
+ },
+ {
+ "code": "NODE_NOT_YET_EFFECTIVE",
+ "severity": "blocker",
+ "targetType": "node",
+ "targetId": "dataset-scrna-v4",
+ "message": "Node dataset-scrna-v4 is not effective on 2026-05-23.",
+ "evidence": {
+ "validFrom": "2026-08-01"
+ }
+ },
+ {
+ "code": "EDGE_EXPIRED",
+ "severity": "blocker",
+ "targetType": "edge",
+ "targetId": "edge-expired-dataset",
+ "message": "Edge edge-expired-dataset expired before 2026-05-23.",
+ "evidence": {
+ "validUntil": "2026-04-01"
+ }
+ },
+ {
+ "code": "EVIDENCE_VERSION_DRIFT",
+ "severity": "blocker",
+ "targetType": "edge",
+ "targetId": "edge-expired-dataset",
+ "message": "Edge edge-expired-dataset cites v1, but target dataset-scrna-v2 is v2.",
+ "evidence": {
+ "edgeVersion": "v1",
+ "targetVersion": "v2",
+ "targetId": "dataset-scrna-v2"
+ }
+ },
+ {
+ "code": "EDGE_NOT_YET_EFFECTIVE",
+ "severity": "blocker",
+ "targetType": "edge",
+ "targetId": "edge-future-dataset",
+ "message": "Edge edge-future-dataset is not effective on 2026-05-23.",
+ "evidence": {
+ "validFrom": "2026-08-02"
+ }
+ },
+ {
+ "code": "EVIDENCE_VERSION_DRIFT",
+ "severity": "blocker",
+ "targetType": "edge",
+ "targetId": "edge-paper-method",
+ "message": "Edge edge-paper-method cites v1, but target method-scrna-normalization-v2 is v2.",
+ "evidence": {
+ "edgeVersion": "v1",
+ "targetVersion": "v2",
+ "targetId": "method-scrna-normalization-v2"
+ }
+ },
+ {
+ "code": "AFFILIATION_EXPIRED",
+ "severity": "review",
+ "targetType": "edge",
+ "targetId": "edge-author-affiliation",
+ "message": "Affiliation edge edge-author-affiliation expired and needs curator review.",
+ "evidence": {
+ "validUntil": "2026-01-31"
+ }
+ }
+ ],
+ "recommendationDecisions": [
+ {
+ "id": "rec-risky-crispr",
+ "title": "Recommend stale CRISPR dataset workflow",
+ "decision": "suppress",
+ "reasons": [
+ "NODE_BLOCKED:dataset-scrna-v2",
+ "EDGE_BLOCKED:edge-expired-dataset",
+ "EDGE_BLOCKED:edge-paper-method"
+ ]
+ },
+ {
+ "id": "rec-future-dataset",
+ "title": "Recommend future dataset release",
+ "decision": "suppress",
+ "reasons": [
+ "NODE_BLOCKED:dataset-scrna-v4",
+ "EDGE_BLOCKED:edge-future-dataset"
+ ]
+ }
+ ],
+ "curatorActions": [
+ "Retire expired nodes from public entity pages or map them to a current version.",
+ "Keep future-dated nodes hidden until their effective date.",
+ "Remove or replace expired relationship edges before graph recommendation release.",
+ "Refresh relationship evidence to match the current target node or dataset version.",
+ "Delay relationship publication until the effective date.",
+ "Route expired affiliations to curator review before profile/entity-page display."
+ ],
+ "digest": "sha256:7abaeae1e266b2772822a7799e80bd64e017f52369350e102a7b3641ef486371"
+ },
+ {
+ "graphId": "kg-temporal-2026-05",
+ "asOf": "2026-05-23",
+ "status": "temporal_ready",
+ "summary": {
+ "blockers": 0,
+ "reviewItems": 0,
+ "nodeFindings": 0,
+ "edgeFindings": 0,
+ "versionDrift": 0,
+ "suppressedRecommendations": 0
+ },
+ "findings": [],
+ "recommendationDecisions": [
+ {
+ "id": "rec-crispr-single-cell",
+ "title": "Recommend CRISPR single-cell workflow",
+ "decision": "publish",
+ "reasons": []
+ }
+ ],
+ "curatorActions": [],
+ "digest": "sha256:3dd829ed8fb5ed4d46fd3f3043f111f67068e6bca0175514b6f4966b49349797"
+ }
+ ]
+}
diff --git a/knowledge-graph-temporal-validity-guard/reports/temporal-validity-report.md b/knowledge-graph-temporal-validity-guard/reports/temporal-validity-report.md
new file mode 100644
index 00000000..e5e837c6
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/reports/temporal-validity-report.md
@@ -0,0 +1,24 @@
+# Knowledge Graph Temporal Validity Guard
+
+This reviewer packet checks whether graph nodes, relationship edges, dataset versions, method applicability windows, author affiliations, and recommendation paths are valid for the graph snapshot date before entity pages or AI recommendations are shown.
+
+| Graph | Status | Blockers | Review items | Suppressed recommendations | Digest |
+| --- | --- | ---: | ---: | ---: | --- |
+| kg-temporal-review-2026-05 | hold_temporal_review | 6 | 1 | 2 | sha256:7abaeae1e266b2772822a7799e80bd64e017f52369350e102a7b3641ef486371 |
+| kg-temporal-2026-05 | temporal_ready | 0 | 0 | 0 | sha256:3dd829ed8fb5ed4d46fd3f3043f111f67068e6bca0175514b6f4966b49349797 |
+
+## Findings
+
+- kg-temporal-review-2026-05: NODE_EXPIRED on node `dataset-scrna-v2` - Node dataset-scrna-v2 expired before 2026-05-23.
+- kg-temporal-review-2026-05: NODE_NOT_YET_EFFECTIVE on node `dataset-scrna-v4` - Node dataset-scrna-v4 is not effective on 2026-05-23.
+- kg-temporal-review-2026-05: EDGE_EXPIRED on edge `edge-expired-dataset` - Edge edge-expired-dataset expired before 2026-05-23.
+- kg-temporal-review-2026-05: EVIDENCE_VERSION_DRIFT on edge `edge-expired-dataset` - Edge edge-expired-dataset cites v1, but target dataset-scrna-v2 is v2.
+- kg-temporal-review-2026-05: EDGE_NOT_YET_EFFECTIVE on edge `edge-future-dataset` - Edge edge-future-dataset is not effective on 2026-05-23.
+- kg-temporal-review-2026-05: EVIDENCE_VERSION_DRIFT on edge `edge-paper-method` - Edge edge-paper-method cites v1, but target method-scrna-normalization-v2 is v2.
+- kg-temporal-review-2026-05: AFFILIATION_EXPIRED on edge `edge-author-affiliation` - Affiliation edge edge-author-affiliation expired and needs curator review.
+
+## Scope Boundaries
+
+- Synthetic graph fixtures only.
+- No network calls, live ontology APIs, private research objects, or graph database mutations.
+- Distinct from evidence freshness: this guard checks effective/expiry windows and version validity for graph objects at a specific snapshot date.
diff --git a/knowledge-graph-temporal-validity-guard/requirements-map.md b/knowledge-graph-temporal-validity-guard/requirements-map.md
new file mode 100644
index 00000000..9c3338cc
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/requirements-map.md
@@ -0,0 +1,14 @@
+# Requirements Map
+
+| Issue #17 capability | Guard coverage |
+| --- | --- |
+| Linked graph metadata | Validates temporal windows for graph nodes and edges before graph metadata is published. |
+| Entity pages | Holds expired or future-dated nodes before entity pages expose them. |
+| Knowledge navigation | Suppresses recommendations that traverse expired or future-dated paths. |
+| Dynamic node types | Handles papers, datasets, methods, authors, and institutions with versioned validity windows. |
+| AI recommendations | Produces publish/suppress decisions per recommendation path. |
+| Graph trust and curation | Emits curator actions and deterministic digests for temporal review packets. |
+
+## Non-overlap
+
+This slice avoids broad graph extraction/navigation, link audit, ontology drift/alias/synonym work, relationship conflict arbitration, author-affiliation disambiguation, artifact lineage, evidence freshness, instrument-method compatibility, recommendation visibility/diversity, negative-result replication, measurement harmonization, claim qualifier checks, ethics provenance, funder award lineage, clinical trial registry guards, software/runtime compatibility, geospatial sample provenance, and biological accession crosswalk work.
diff --git a/knowledge-graph-temporal-validity-guard/sample-data.js b/knowledge-graph-temporal-validity-guard/sample-data.js
new file mode 100644
index 00000000..8195dc27
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/sample-data.js
@@ -0,0 +1,62 @@
+const temporalValidGraph = {
+ graphId: 'kg-temporal-2026-05',
+ asOf: '2026-05-23',
+ nodes: [
+ {id: 'paper-crispr-2026', type: 'paper', validFrom: '2026-01-01', validUntil: null, version: 'v1'},
+ {id: 'dataset-scrna-v3', type: 'dataset', validFrom: '2026-02-01', validUntil: null, version: 'v3'},
+ {id: 'method-scrna-normalization-v2', type: 'method', validFrom: '2026-03-01', validUntil: null, version: 'v2'},
+ {id: 'author-lee', type: 'author', validFrom: '2021-01-01', validUntil: null, version: 'v4'},
+ {id: 'institution-northstar', type: 'institution', validFrom: '2020-01-01', validUntil: null, version: 'v2'},
+ ],
+ edges: [
+ {id: 'edge-paper-dataset', from: 'paper-crispr-2026', to: 'dataset-scrna-v3', type: 'uses-dataset', validFrom: '2026-02-15', validUntil: null, evidenceVersion: 'v3'},
+ {id: 'edge-paper-method', from: 'paper-crispr-2026', to: 'method-scrna-normalization-v2', type: 'uses-method', validFrom: '2026-03-15', validUntil: null, evidenceVersion: 'v2'},
+ {id: 'edge-author-affiliation', from: 'author-lee', to: 'institution-northstar', type: 'affiliated-with', validFrom: '2025-01-01', validUntil: null, evidenceVersion: 'v2'},
+ ],
+ recommendations: [
+ {
+ id: 'rec-crispr-single-cell',
+ title: 'Recommend CRISPR single-cell workflow',
+ path: ['paper-crispr-2026', 'dataset-scrna-v3', 'method-scrna-normalization-v2'],
+ edgeIds: ['edge-paper-dataset', 'edge-paper-method'],
+ },
+ ],
+};
+
+const temporalRiskGraph = {
+ ...temporalValidGraph,
+ graphId: 'kg-temporal-review-2026-05',
+ nodes: [
+ {id: 'paper-crispr-2026', type: 'paper', validFrom: '2026-01-01', validUntil: null, version: 'v1'},
+ {id: 'dataset-scrna-v2', type: 'dataset', validFrom: '2025-01-01', validUntil: '2026-04-15', version: 'v2'},
+ {id: 'dataset-scrna-v4', type: 'dataset', validFrom: '2026-08-01', validUntil: null, version: 'v4'},
+ {id: 'method-scrna-normalization-v2', type: 'method', validFrom: '2026-03-01', validUntil: null, version: 'v2'},
+ {id: 'author-lee', type: 'author', validFrom: '2021-01-01', validUntil: null, version: 'v4'},
+ {id: 'institution-old-lab', type: 'institution', validFrom: '2020-01-01', validUntil: null, version: 'v1'},
+ ],
+ edges: [
+ {id: 'edge-expired-dataset', from: 'paper-crispr-2026', to: 'dataset-scrna-v2', type: 'uses-dataset', validFrom: '2025-02-15', validUntil: '2026-04-01', evidenceVersion: 'v1'},
+ {id: 'edge-future-dataset', from: 'paper-crispr-2026', to: 'dataset-scrna-v4', type: 'uses-dataset', validFrom: '2026-08-02', validUntil: null, evidenceVersion: 'v4'},
+ {id: 'edge-paper-method', from: 'paper-crispr-2026', to: 'method-scrna-normalization-v2', type: 'uses-method', validFrom: '2026-03-15', validUntil: null, evidenceVersion: 'v1'},
+ {id: 'edge-author-affiliation', from: 'author-lee', to: 'institution-old-lab', type: 'affiliated-with', validFrom: '2023-01-01', validUntil: '2026-01-31', evidenceVersion: 'v1'},
+ ],
+ recommendations: [
+ {
+ id: 'rec-risky-crispr',
+ title: 'Recommend stale CRISPR dataset workflow',
+ path: ['paper-crispr-2026', 'dataset-scrna-v2', 'method-scrna-normalization-v2'],
+ edgeIds: ['edge-expired-dataset', 'edge-paper-method'],
+ },
+ {
+ id: 'rec-future-dataset',
+ title: 'Recommend future dataset release',
+ path: ['paper-crispr-2026', 'dataset-scrna-v4'],
+ edgeIds: ['edge-future-dataset'],
+ },
+ ],
+};
+
+module.exports = {
+ temporalValidGraph,
+ temporalRiskGraph,
+};
diff --git a/knowledge-graph-temporal-validity-guard/test.js b/knowledge-graph-temporal-validity-guard/test.js
new file mode 100644
index 00000000..5b0d6df0
--- /dev/null
+++ b/knowledge-graph-temporal-validity-guard/test.js
@@ -0,0 +1,99 @@
+const assert = require('node:assert/strict');
+const {analyzeTemporalGraph} = require('./index');
+
+const baseGraph = {
+ graphId: 'kg-temporal-2026-05',
+ asOf: '2026-05-23',
+ nodes: [
+ {id: 'paper-crispr-2026', type: 'paper', validFrom: '2026-01-01', validUntil: null, version: 'v1'},
+ {id: 'dataset-scrna-v3', type: 'dataset', validFrom: '2026-02-01', validUntil: null, version: 'v3'},
+ {id: 'method-scrna-normalization-v2', type: 'method', validFrom: '2026-03-01', validUntil: null, version: 'v2'},
+ {id: 'author-lee', type: 'author', validFrom: '2021-01-01', validUntil: null, version: 'v4'},
+ {id: 'institution-northstar', type: 'institution', validFrom: '2020-01-01', validUntil: null, version: 'v2'},
+ ],
+ edges: [
+ {id: 'edge-paper-dataset', from: 'paper-crispr-2026', to: 'dataset-scrna-v3', type: 'uses-dataset', validFrom: '2026-02-15', validUntil: null, evidenceVersion: 'v3'},
+ {id: 'edge-paper-method', from: 'paper-crispr-2026', to: 'method-scrna-normalization-v2', type: 'uses-method', validFrom: '2026-03-15', validUntil: null, evidenceVersion: 'v2'},
+ {id: 'edge-author-affiliation', from: 'author-lee', to: 'institution-northstar', type: 'affiliated-with', validFrom: '2025-01-01', validUntil: null, evidenceVersion: 'v2'},
+ ],
+ recommendations: [
+ {
+ id: 'rec-crispr-single-cell',
+ title: 'Recommend CRISPR single-cell workflow',
+ path: ['paper-crispr-2026', 'dataset-scrna-v3', 'method-scrna-normalization-v2'],
+ edgeIds: ['edge-paper-dataset', 'edge-paper-method'],
+ },
+ ],
+};
+
+const clone = (value) => JSON.parse(JSON.stringify(value));
+
+function test(name, fn) {
+ try {
+ fn();
+ console.log(`ok - ${name}`);
+ } catch (error) {
+ console.error(`not ok - ${name}`);
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
+
+test('suppresses recommendations that traverse expired edges or nodes', () => {
+ const graph = clone(baseGraph);
+ graph.edges[0].validUntil = '2026-04-01';
+ graph.nodes[1].validUntil = '2026-04-15';
+
+ const result = analyzeTemporalGraph(graph);
+
+ assert.equal(result.status, 'hold_temporal_review');
+ assert(result.findings.some((finding) => finding.code === 'EDGE_EXPIRED'));
+ assert(result.findings.some((finding) => finding.code === 'NODE_EXPIRED'));
+ assert.equal(result.recommendationDecisions[0].decision, 'suppress');
+});
+
+test('holds future-dated graph objects before entity pages use them', () => {
+ const graph = clone(baseGraph);
+ graph.nodes.push({id: 'dataset-future-v4', type: 'dataset', validFrom: '2026-08-01', validUntil: null, version: 'v4'});
+ graph.edges.push({id: 'edge-future', from: 'paper-crispr-2026', to: 'dataset-future-v4', type: 'uses-dataset', validFrom: '2026-08-02', validUntil: null, evidenceVersion: 'v4'});
+ graph.recommendations[0].path.push('dataset-future-v4');
+ graph.recommendations[0].edgeIds.push('edge-future');
+
+ const result = analyzeTemporalGraph(graph);
+
+ assert.equal(result.status, 'hold_temporal_review');
+ assert(result.findings.some((finding) => finding.code === 'NODE_NOT_YET_EFFECTIVE'));
+ assert(result.findings.some((finding) => finding.code === 'EDGE_NOT_YET_EFFECTIVE'));
+});
+
+test('flags relationship evidence version drift before graph publication', () => {
+ const graph = clone(baseGraph);
+ graph.edges[1].evidenceVersion = 'v1';
+
+ const result = analyzeTemporalGraph(graph);
+
+ assert.equal(result.status, 'hold_temporal_review');
+ assert(result.findings.some((finding) => finding.code === 'EVIDENCE_VERSION_DRIFT'));
+ assert.equal(result.summary.versionDrift, 1);
+});
+
+test('routes expired affiliations to curator review without suppressing unrelated recommendations', () => {
+ const graph = clone(baseGraph);
+ graph.edges[2].validUntil = '2026-01-31';
+
+ const result = analyzeTemporalGraph(graph);
+
+ assert.equal(result.status, 'needs_curator_review');
+ assert(result.findings.some((finding) => finding.code === 'AFFILIATION_EXPIRED'));
+ assert.equal(result.recommendationDecisions[0].decision, 'publish');
+});
+
+test('returns ready status and deterministic digest for temporally valid graph packets', () => {
+ const first = analyzeTemporalGraph(clone(baseGraph));
+ const second = analyzeTemporalGraph(clone(baseGraph));
+
+ assert.equal(first.status, 'temporal_ready');
+ assert.equal(first.summary.blockers, 0);
+ assert.match(first.digest, /^sha256:[a-f0-9]{64}$/);
+ assert.equal(first.digest, second.digest);
+});