diff --git a/enterprise-training-policy-guard/README.md b/enterprise-training-policy-guard/README.md
new file mode 100644
index 00000000..66da6083
--- /dev/null
+++ b/enterprise-training-policy-guard/README.md
@@ -0,0 +1,27 @@
+# Enterprise Training Policy Guard
+
+This module adds a focused Enterprise Tooling guard for training currency and policy acknowledgement evidence before privileged exports or project actions continue.
+
+It evaluates synthetic institutional access packets for:
+
+- missing required policy acknowledgements
+- stale policy acknowledgements older than 180 days
+- missing role-specific training modules
+- expired training certificates
+- missing receipt IDs for completed training
+
+The output is deterministic and reviewer-ready: `release`, `revise`, or `hold`, plus remediation text for each finding.
+
+## Run
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+Generated artifacts are written to `reports/`.
+
+## Safety
+
+The sample packets are synthetic. The module does not call external services and does not use credentials, private research records, payment systems, wallets, live SSO/SCIM systems, or production exports.
diff --git a/enterprise-training-policy-guard/demo.js b/enterprise-training-policy-guard/demo.js
new file mode 100644
index 00000000..f400836c
--- /dev/null
+++ b/enterprise-training-policy-guard/demo.js
@@ -0,0 +1,60 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+const { evaluateTrainingPolicyPacket } = require("./index");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const release = evaluateTrainingPolicyPacket(cleanPacket);
+const hold = evaluateTrainingPolicyPacket(riskyPacket);
+const combined = { generatedAt: new Date().toISOString(), scenarios: [release, hold] };
+
+fs.writeFileSync(
+ path.join(reportsDir, "training-policy-report.json"),
+ JSON.stringify(combined, null, 2)
+);
+
+const markdown = [
+ "# Enterprise Training Policy Guard Demo",
+ "",
+ `Generated: ${combined.generatedAt}`,
+ "",
+ "| Scenario | Decision | Blockers | Revisions | Summary |",
+ "| --- | --- | ---: | ---: | --- |",
+ ...combined.scenarios.map((item) =>
+ `| ${item.actionId} | ${item.decision} | ${item.blockerCount} | ${item.revisionCount} | ${item.reviewerSummary} |`
+ ),
+ "",
+ "## Hold Findings",
+ "",
+ ...hold.findings.map((item) =>
+ `- **${item.code}** (${item.severity}) for ${item.researcherId}: ${item.message} ${item.remediation}`
+ )
+].join("\n");
+
+fs.writeFileSync(path.join(reportsDir, "training-policy-report.md"), markdown);
+
+const svg = ``;
+
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg);
+fs.writeFileSync(
+ path.join(reportsDir, "demo-script.txt"),
+ "Show the release scenario, then the restricted export hold scenario, then the remediation list."
+);
+
+console.log(JSON.stringify(combined, null, 2));
diff --git a/enterprise-training-policy-guard/demo_video.py b/enterprise-training-policy-guard/demo_video.py
new file mode 100644
index 00000000..59873d53
--- /dev/null
+++ b/enterprise-training-policy-guard/demo_video.py
@@ -0,0 +1,88 @@
+import os
+from pathlib import Path
+import subprocess
+
+ROOT = Path(__file__).resolve().parent
+REPORTS = ROOT / "reports"
+FRAMES = REPORTS / "frames"
+
+REPORTS.mkdir(exist_ok=True)
+FRAMES.mkdir(exist_ok=True)
+
+COLORS = [
+ ("RELEASE", "0x16a34a", "All training and acknowledgements current"),
+ ("REVISE", "0xca8a04", "Receipt and acknowledgement gaps detected"),
+ ("HOLD", "0xdc2626", "Expired training blocks privileged export"),
+]
+
+gif_path = REPORTS / "demo.gif"
+mp4_path = REPORTS / "demo.mp4"
+
+ffmpeg = os.environ.get("FFMPEG")
+if not ffmpeg:
+ candidates = list((ROOT.parents[2] / "tools" / "video-gen").glob("node_modules/**/ffmpeg.exe"))
+ ffmpeg = str(candidates[0]) if candidates else "ffmpeg"
+
+font = "C\\:/Windows/Fonts/arial.ttf"
+
+for index, (label, color, subtitle) in enumerate(COLORS):
+ frame_path = FRAMES / f"frame-{index:03d}.png"
+ filters = (
+ "drawbox=x=60:y=56:w=780:h=408:color=0xf8fafc:t=fill,"
+ "drawtext=fontfile='{font}':text='Enterprise Training Policy Guard':x=104:y=118:fontsize=34:fontcolor=0x0f172a,"
+ "drawtext=fontfile='{font}':text='Decision\\: {label}':x=104:y=190:fontsize=30:fontcolor={color},"
+ "drawtext=fontfile='{font}':text='{subtitle}':x=104:y=248:fontsize=22:fontcolor=0x334155,"
+ "drawbox=x=104:y=318:w={bar}:h=54:color={color}:t=fill,"
+ "drawtext=fontfile='{font}':text='Synthetic data only - reviewer-ready findings':x=104:y=414:fontsize=18:fontcolor=0x475569"
+ ).format(font=font, label=label, subtitle=subtitle, color=color, bar=240 + index * 120)
+ subprocess.run(
+ [
+ ffmpeg,
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=0x0f172a:s=900x520:d=1",
+ "-vf",
+ filters,
+ "-frames:v",
+ "1",
+ str(frame_path),
+ ],
+ check=True,
+ )
+
+subprocess.run(
+ [
+ ffmpeg,
+ "-y",
+ "-framerate",
+ "1",
+ "-i",
+ str(FRAMES / "frame-%03d.png"),
+ "-vf",
+ "scale=900:520:flags=lanczos",
+ str(gif_path),
+ ],
+ check=True,
+)
+
+subprocess.run(
+ [
+ ffmpeg,
+ "-y",
+ "-framerate",
+ "1",
+ "-i",
+ str(FRAMES / "frame-%03d.png"),
+ "-vf",
+ "scale=900:520:flags=lanczos,format=yuv420p",
+ "-movflags",
+ "+faststart",
+ str(mp4_path),
+ ],
+ check=True,
+)
+
+print(f"wrote {gif_path}")
+print(f"wrote {mp4_path}")
diff --git a/enterprise-training-policy-guard/index.js b/enterprise-training-policy-guard/index.js
new file mode 100644
index 00000000..58658354
--- /dev/null
+++ b/enterprise-training-policy-guard/index.js
@@ -0,0 +1,145 @@
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+const ROLE_TRAINING = {
+ principal_investigator: ["human-subjects", "secure-export", "ai-governance"],
+ data_steward: ["secure-export", "ai-governance"],
+ external_collaborator: ["human-subjects", "secure-export"]
+};
+
+function parseDate(value, field) {
+ const date = new Date(`${value}T00:00:00Z`);
+ if (Number.isNaN(date.getTime())) {
+ throw new Error(`Invalid date for ${field}: ${value}`);
+ }
+ return date;
+}
+
+function daysBetween(a, b) {
+ return Math.floor((b.getTime() - a.getTime()) / DAY_MS);
+}
+
+function buildFinding(code, severity, researcherId, message, remediation) {
+ return { code, severity, researcherId, message, remediation };
+}
+
+function requiredTrainingFor(researcher, action) {
+ const roleRequired = ROLE_TRAINING[researcher.role] || [];
+ return Array.from(new Set([...(action.requiredTraining || []), ...roleRequired])).sort();
+}
+
+function evaluateResearcher(researcher, action, now) {
+ const findings = [];
+ const policies = researcher.policies || {};
+ const training = researcher.training || {};
+
+ for (const policy of action.requiredPolicies || []) {
+ if (!policies[policy]) {
+ findings.push(buildFinding(
+ "POLICY_ACK_MISSING",
+ "block",
+ researcher.id,
+ `${researcher.id} has not acknowledged required policy ${policy}.`,
+ `Collect ${policy} acknowledgement before ${action.type}.`
+ ));
+ continue;
+ }
+
+ const acknowledgedAt = parseDate(policies[policy], `${researcher.id}.${policy}`);
+ if (daysBetween(acknowledgedAt, now) > 180) {
+ findings.push(buildFinding(
+ "POLICY_ACK_STALE",
+ "revise",
+ researcher.id,
+ `${researcher.id} acknowledged ${policy} more than 180 days ago.`,
+ `Refresh ${policy} acknowledgement and attach the receipt.`
+ ));
+ }
+ }
+
+ for (const moduleName of requiredTrainingFor(researcher, action)) {
+ const record = training[moduleName];
+ if (!record) {
+ findings.push(buildFinding(
+ "TRAINING_MISSING",
+ "block",
+ researcher.id,
+ `${researcher.id} is missing required training ${moduleName}.`,
+ `Complete ${moduleName} training before privileged access is released.`
+ ));
+ continue;
+ }
+
+ const expiresAt = parseDate(record.expiresAt, `${researcher.id}.${moduleName}.expiresAt`);
+ if (expiresAt < now) {
+ findings.push(buildFinding(
+ "TRAINING_EXPIRED",
+ "block",
+ researcher.id,
+ `${researcher.id} has expired ${moduleName} training.`,
+ `Renew ${moduleName} training and upload a current certificate.`
+ ));
+ }
+
+ if (!record.receiptId || String(record.receiptId).trim() === "") {
+ findings.push(buildFinding(
+ "TRAINING_RECEIPT_MISSING",
+ "revise",
+ researcher.id,
+ `${researcher.id} has ${moduleName} completion without evidence receipt.`,
+ `Attach a signed receipt for ${moduleName}.`
+ ));
+ }
+ }
+
+ return findings;
+}
+
+function summarizeDecision(findings) {
+ if (findings.some((item) => item.severity === "block")) {
+ return "hold";
+ }
+ if (findings.some((item) => item.severity === "revise")) {
+ return "revise";
+ }
+ return "release";
+}
+
+function evaluateTrainingPolicyPacket(packet) {
+ if (!packet || typeof packet !== "object") {
+ throw new Error("Packet must be an object.");
+ }
+ if (!packet.action || !Array.isArray(packet.researchers)) {
+ throw new Error("Packet requires action and researchers.");
+ }
+
+ const now = new Date(packet.generatedAt || Date.now());
+ if (Number.isNaN(now.getTime())) {
+ throw new Error("generatedAt must be a valid timestamp.");
+ }
+
+ const findings = packet.researchers.flatMap((researcher) =>
+ evaluateResearcher(researcher, packet.action, now)
+ );
+ const decision = summarizeDecision(findings);
+
+ return {
+ actionId: packet.action.id,
+ projectId: packet.action.projectId,
+ department: packet.action.department,
+ decision,
+ checkedResearchers: packet.researchers.length,
+ blockerCount: findings.filter((item) => item.severity === "block").length,
+ revisionCount: findings.filter((item) => item.severity === "revise").length,
+ findings,
+ reviewerSummary: decision === "release"
+ ? "All required enterprise training and policy acknowledgements are current."
+ : "Hold or revise before enterprise export/privileged access continues."
+ };
+}
+
+module.exports = {
+ ROLE_TRAINING,
+ evaluateTrainingPolicyPacket,
+ requiredTrainingFor,
+ summarizeDecision
+};
diff --git a/enterprise-training-policy-guard/package.json b/enterprise-training-policy-guard/package.json
new file mode 100644
index 00000000..abe88afc
--- /dev/null
+++ b/enterprise-training-policy-guard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "enterprise-training-policy-guard",
+ "version": "1.0.0",
+ "description": "Enterprise training and policy acknowledgement expiry guard for SCIBASE Enterprise Tooling",
+ "main": "index.js",
+ "type": "commonjs",
+ "scripts": {
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "python demo_video.py"
+ },
+ "license": "MIT"
+}
diff --git a/enterprise-training-policy-guard/reports/demo-script.txt b/enterprise-training-policy-guard/reports/demo-script.txt
new file mode 100644
index 00000000..03e6b3a0
--- /dev/null
+++ b/enterprise-training-policy-guard/reports/demo-script.txt
@@ -0,0 +1 @@
+Show the release scenario, then the restricted export hold scenario, then the remediation list.
\ No newline at end of file
diff --git a/enterprise-training-policy-guard/reports/demo.gif b/enterprise-training-policy-guard/reports/demo.gif
new file mode 100644
index 00000000..de57b227
Binary files /dev/null and b/enterprise-training-policy-guard/reports/demo.gif differ
diff --git a/enterprise-training-policy-guard/reports/demo.mp4 b/enterprise-training-policy-guard/reports/demo.mp4
new file mode 100644
index 00000000..122bf48a
Binary files /dev/null and b/enterprise-training-policy-guard/reports/demo.mp4 differ
diff --git a/enterprise-training-policy-guard/reports/summary.svg b/enterprise-training-policy-guard/reports/summary.svg
new file mode 100644
index 00000000..2c498252
--- /dev/null
+++ b/enterprise-training-policy-guard/reports/summary.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/enterprise-training-policy-guard/reports/training-policy-report.json b/enterprise-training-policy-guard/reports/training-policy-report.json
new file mode 100644
index 00000000..73f6cc62
--- /dev/null
+++ b/enterprise-training-policy-guard/reports/training-policy-report.json
@@ -0,0 +1,70 @@
+{
+ "generatedAt": "2026-06-01T20:13:21.063Z",
+ "scenarios": [
+ {
+ "actionId": "enterprise-export-001",
+ "projectId": "proj-open-clinical-reuse",
+ "department": "Clinical Informatics",
+ "decision": "release",
+ "checkedResearchers": 2,
+ "blockerCount": 0,
+ "revisionCount": 0,
+ "findings": [],
+ "reviewerSummary": "All required enterprise training and policy acknowledgements are current."
+ },
+ {
+ "actionId": "enterprise-export-002",
+ "projectId": "proj-restricted-genomics",
+ "department": "Translational Genomics",
+ "decision": "hold",
+ "checkedResearchers": 2,
+ "blockerCount": 4,
+ "revisionCount": 2,
+ "findings": [
+ {
+ "code": "POLICY_ACK_STALE",
+ "severity": "revise",
+ "researcherId": "u-200",
+ "message": "u-200 acknowledged data-use more than 180 days ago.",
+ "remediation": "Refresh data-use acknowledgement and attach the receipt."
+ },
+ {
+ "code": "POLICY_ACK_MISSING",
+ "severity": "block",
+ "researcherId": "u-200",
+ "message": "u-200 has not acknowledged required policy ai-review.",
+ "remediation": "Collect ai-review acknowledgement before privileged_export."
+ },
+ {
+ "code": "TRAINING_EXPIRED",
+ "severity": "block",
+ "researcherId": "u-200",
+ "message": "u-200 has expired human-subjects training.",
+ "remediation": "Renew human-subjects training and upload a current certificate."
+ },
+ {
+ "code": "TRAINING_RECEIPT_MISSING",
+ "severity": "revise",
+ "researcherId": "u-200",
+ "message": "u-200 has secure-export completion without evidence receipt.",
+ "remediation": "Attach a signed receipt for secure-export."
+ },
+ {
+ "code": "POLICY_ACK_MISSING",
+ "severity": "block",
+ "researcherId": "u-201",
+ "message": "u-201 has not acknowledged required policy export-control.",
+ "remediation": "Collect export-control acknowledgement before privileged_export."
+ },
+ {
+ "code": "TRAINING_MISSING",
+ "severity": "block",
+ "researcherId": "u-201",
+ "message": "u-201 is missing required training secure-export.",
+ "remediation": "Complete secure-export training before privileged access is released."
+ }
+ ],
+ "reviewerSummary": "Hold or revise before enterprise export/privileged access continues."
+ }
+ ]
+}
\ No newline at end of file
diff --git a/enterprise-training-policy-guard/reports/training-policy-report.md b/enterprise-training-policy-guard/reports/training-policy-report.md
new file mode 100644
index 00000000..4075189f
--- /dev/null
+++ b/enterprise-training-policy-guard/reports/training-policy-report.md
@@ -0,0 +1,17 @@
+# Enterprise Training Policy Guard Demo
+
+Generated: 2026-06-01T20:13:21.063Z
+
+| Scenario | Decision | Blockers | Revisions | Summary |
+| --- | --- | ---: | ---: | --- |
+| enterprise-export-001 | release | 0 | 0 | All required enterprise training and policy acknowledgements are current. |
+| enterprise-export-002 | hold | 4 | 2 | Hold or revise before enterprise export/privileged access continues. |
+
+## Hold Findings
+
+- **POLICY_ACK_STALE** (revise) for u-200: u-200 acknowledged data-use more than 180 days ago. Refresh data-use acknowledgement and attach the receipt.
+- **POLICY_ACK_MISSING** (block) for u-200: u-200 has not acknowledged required policy ai-review. Collect ai-review acknowledgement before privileged_export.
+- **TRAINING_EXPIRED** (block) for u-200: u-200 has expired human-subjects training. Renew human-subjects training and upload a current certificate.
+- **TRAINING_RECEIPT_MISSING** (revise) for u-200: u-200 has secure-export completion without evidence receipt. Attach a signed receipt for secure-export.
+- **POLICY_ACK_MISSING** (block) for u-201: u-201 has not acknowledged required policy export-control. Collect export-control acknowledgement before privileged_export.
+- **TRAINING_MISSING** (block) for u-201: u-201 is missing required training secure-export. Complete secure-export training before privileged access is released.
\ No newline at end of file
diff --git a/enterprise-training-policy-guard/requirements-map.md b/enterprise-training-policy-guard/requirements-map.md
new file mode 100644
index 00000000..48ad9112
--- /dev/null
+++ b/enterprise-training-policy-guard/requirements-map.md
@@ -0,0 +1,12 @@
+# Requirements Map
+
+Issue #19 asks for Enterprise Tooling that gives institutions governance, compliance tracking, analytics, and integration controls.
+
+This slice covers a distinct compliance gate:
+
+- **Compliance tracking:** verifies policy acknowledgements and training receipts before privileged enterprise actions.
+- **Governance controls:** produces `hold` decisions when certificates are expired or missing.
+- **Admin review readiness:** emits blocker/revision counts, researcher IDs, and remediation messages.
+- **Low-risk implementation:** dependency-free JavaScript, synthetic data only, no production integrations.
+
+Non-overlap: this is not another dashboard, webhook, SSO/SCIM, repository sync, API rate limit, vendor DPA, contract drift, legal hold, data residency, journal/style provenance, or admin audit slice.
diff --git a/enterprise-training-policy-guard/sample-data.js b/enterprise-training-policy-guard/sample-data.js
new file mode 100644
index 00000000..6fd68567
--- /dev/null
+++ b/enterprise-training-policy-guard/sample-data.js
@@ -0,0 +1,82 @@
+const cleanPacket = {
+ generatedAt: "2026-06-01T18:00:00Z",
+ action: {
+ id: "enterprise-export-001",
+ type: "privileged_export",
+ projectId: "proj-open-clinical-reuse",
+ department: "Clinical Informatics",
+ requiredPolicies: ["data-use", "export-control", "ai-review"],
+ requiredTraining: ["human-subjects", "secure-export", "ai-governance"]
+ },
+ researchers: [
+ {
+ id: "u-100",
+ role: "principal_investigator",
+ policies: {
+ "data-use": "2026-05-20",
+ "export-control": "2026-05-22",
+ "ai-review": "2026-05-25"
+ },
+ training: {
+ "human-subjects": { completedAt: "2026-03-01", expiresAt: "2027-03-01", receiptId: "hs-100" },
+ "secure-export": { completedAt: "2026-05-02", expiresAt: "2027-05-02", receiptId: "se-100" },
+ "ai-governance": { completedAt: "2026-05-05", expiresAt: "2027-05-05", receiptId: "ai-100" }
+ }
+ },
+ {
+ id: "u-101",
+ role: "data_steward",
+ policies: {
+ "data-use": "2026-05-18",
+ "export-control": "2026-05-18",
+ "ai-review": "2026-05-19"
+ },
+ training: {
+ "human-subjects": { completedAt: "2026-02-11", expiresAt: "2027-02-11", receiptId: "hs-101" },
+ "secure-export": { completedAt: "2026-04-08", expiresAt: "2027-04-08", receiptId: "se-101" },
+ "ai-governance": { completedAt: "2026-04-09", expiresAt: "2027-04-09", receiptId: "ai-101" }
+ }
+ }
+ ]
+};
+
+const riskyPacket = {
+ generatedAt: "2026-06-01T18:00:00Z",
+ action: {
+ id: "enterprise-export-002",
+ type: "privileged_export",
+ projectId: "proj-restricted-genomics",
+ department: "Translational Genomics",
+ requiredPolicies: ["data-use", "export-control", "ai-review"],
+ requiredTraining: ["human-subjects", "secure-export", "ai-governance"]
+ },
+ researchers: [
+ {
+ id: "u-200",
+ role: "principal_investigator",
+ policies: {
+ "data-use": "2025-11-15",
+ "export-control": "2026-05-01"
+ },
+ training: {
+ "human-subjects": { completedAt: "2025-01-15", expiresAt: "2026-01-15", receiptId: "hs-200" },
+ "secure-export": { completedAt: "2026-03-04", expiresAt: "2027-03-04", receiptId: "" },
+ "ai-governance": { completedAt: "2026-02-10", expiresAt: "2027-02-10", receiptId: "ai-200" }
+ }
+ },
+ {
+ id: "u-201",
+ role: "external_collaborator",
+ policies: {
+ "data-use": "2026-05-15",
+ "ai-review": "2026-05-15"
+ },
+ training: {
+ "human-subjects": { completedAt: "2026-05-10", expiresAt: "2027-05-10", receiptId: "hs-201" },
+ "ai-governance": { completedAt: "2026-05-12", expiresAt: "2027-05-12", receiptId: "ai-201" }
+ }
+ }
+ ]
+};
+
+module.exports = { cleanPacket, riskyPacket };
diff --git a/enterprise-training-policy-guard/test.js b/enterprise-training-policy-guard/test.js
new file mode 100644
index 00000000..8a8525cb
--- /dev/null
+++ b/enterprise-training-policy-guard/test.js
@@ -0,0 +1,36 @@
+const assert = require("node:assert/strict");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+const {
+ evaluateTrainingPolicyPacket,
+ requiredTrainingFor,
+ summarizeDecision
+} = require("./index");
+
+const clean = evaluateTrainingPolicyPacket(cleanPacket);
+assert.equal(clean.decision, "release");
+assert.equal(clean.blockerCount, 0);
+assert.equal(clean.revisionCount, 0);
+assert.equal(clean.checkedResearchers, 2);
+
+const risky = evaluateTrainingPolicyPacket(riskyPacket);
+assert.equal(risky.decision, "hold");
+assert.equal(risky.blockerCount, 4);
+assert.equal(risky.revisionCount, 2);
+assert.ok(risky.findings.some((item) => item.code === "TRAINING_EXPIRED"));
+assert.ok(risky.findings.some((item) => item.code === "POLICY_ACK_MISSING"));
+assert.ok(risky.findings.some((item) => item.code === "TRAINING_RECEIPT_MISSING"));
+assert.ok(risky.findings.some((item) => item.researcherId === "u-201" && item.code === "TRAINING_MISSING"));
+
+assert.deepEqual(
+ requiredTrainingFor(
+ { id: "u-300", role: "external_collaborator", training: {}, policies: {} },
+ { requiredTraining: ["ai-governance"] }
+ ),
+ ["ai-governance", "human-subjects", "secure-export"]
+);
+
+assert.equal(summarizeDecision([{ severity: "revise" }]), "revise");
+assert.equal(summarizeDecision([{ severity: "block" }, { severity: "revise" }]), "hold");
+assert.throws(() => evaluateTrainingPolicyPacket({ generatedAt: "not-a-date", action: {}, researchers: [] }));
+
+console.log("enterprise training policy guard tests passed");