diff --git a/README.md b/README.md
index d338cf6..b378405 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,6 @@
# deepevents.ai
deepevents.ai main codebase
+
+## Enterprise tooling modules
+
+- [Enterprise IRB consent governance](enterprise-irb-consent-governance/README.md) validates human-subjects research projects against IRB approval, consent scope, data-use, export, retention, and webhook evidence requirements.
diff --git a/enterprise-irb-consent-governance/README.md b/enterprise-irb-consent-governance/README.md
new file mode 100644
index 0000000..2ff4f9e
--- /dev/null
+++ b/enterprise-irb-consent-governance/README.md
@@ -0,0 +1,39 @@
+# Enterprise IRB Consent Governance
+
+This module adds an Enterprise Tooling slice for institutional review and human-subjects research governance. It is self-contained, dependency-free, and uses synthetic project data only.
+
+It evaluates whether projects can be exported, published, or synced to institutional systems when they include human participants, controlled data, or consent-limited datasets.
+
+## What it checks
+
+- IRB approval presence, status, and expiry.
+- Consent scope coverage for each data-use purpose.
+- Guardian consent for minor participants.
+- De-identification requirements before external export.
+- Data-use agreement coverage for PHI, genomic, and private clinical data.
+- Export destination restrictions and jurisdiction limits.
+- Retention and deletion clock violations.
+- Signed webhook-ready governance events for institutional audit systems.
+
+## Files
+
+- `index.js` - governance evaluator and packet generator.
+- `sample-data.js` - synthetic institutional policy and project samples.
+- `test.js` - deterministic unit tests.
+- `demo.js` - writes reviewer artifacts and a short MP4 demo when `ffmpeg` is available.
+- `requirements-map.md` - maps the module to issue #19 requirements.
+- `acceptance-notes.md` - reviewer notes and scope boundaries.
+
+## Run
+
+```bash
+node enterprise-irb-consent-governance/test.js
+node enterprise-irb-consent-governance/demo.js
+```
+
+The demo writes:
+
+- `irb-consent-report.json`
+- `reviewer-packet.md`
+- `demo.svg`
+- `demo.mp4` when `ffmpeg` is available
diff --git a/enterprise-irb-consent-governance/acceptance-notes.md b/enterprise-irb-consent-governance/acceptance-notes.md
new file mode 100644
index 0000000..1548ecc
--- /dev/null
+++ b/enterprise-irb-consent-governance/acceptance-notes.md
@@ -0,0 +1,19 @@
+# Acceptance Notes
+
+## Scope
+
+This PR adds a self-contained governance gate for research involving human participants or consent-limited data. It can be reviewed without external accounts, third-party APIs, or credentials.
+
+## Reviewer workflow
+
+1. Run `node enterprise-irb-consent-governance/test.js`.
+2. Run `node enterprise-irb-consent-governance/demo.js`.
+3. Inspect `irb-consent-report.json`, `reviewer-packet.md`, `demo.svg`, and `demo.mp4`.
+
+## Safety boundaries
+
+- Uses synthetic sample data only.
+- Does not store or request credentials.
+- Does not call external services.
+- Webhook signatures use a synthetic secret from `sample-data.js`.
+- Export decisions are deterministic and auditable through `auditDigest` and per-project `evidenceDigest` fields.
diff --git a/enterprise-irb-consent-governance/demo.js b/enterprise-irb-consent-governance/demo.js
new file mode 100644
index 0000000..8614cf1
--- /dev/null
+++ b/enterprise-irb-consent-governance/demo.js
@@ -0,0 +1,126 @@
+const fs = require("fs")
+const path = require("path")
+const { spawnSync } = require("child_process")
+const { generateGovernancePacket } = require("./index")
+const { policy, projects } = require("./sample-data")
+
+const outDir = __dirname
+const packet = generateGovernancePacket(projects, policy)
+
+function writeJson() {
+ fs.writeFileSync(
+ path.join(outDir, "irb-consent-report.json"),
+ `${JSON.stringify(packet, null, 2)}\n`,
+ )
+}
+
+function writeReviewerPacket() {
+ const lines = [
+ "# Enterprise IRB Consent Governance Review Packet",
+ "",
+ `Generated: ${packet.generatedAt}`,
+ `Audit digest: ${packet.auditDigest}`,
+ "",
+ "## Summary",
+ "",
+ `- Projects evaluated: ${packet.summary.projectCount}`,
+ `- Approved: ${packet.summary.approved}`,
+ `- Review: ${packet.summary.review}`,
+ `- Blocked: ${packet.summary.blocked}`,
+ "",
+ "## Top risks",
+ "",
+ ...packet.summary.topRisks.map(
+ (risk) => `- ${risk.projectId}: ${risk.title} - ${risk.status} (${risk.riskScore})`,
+ ),
+ "",
+ "## Action queue",
+ "",
+ ...packet.evaluations.flatMap((evaluation) =>
+ evaluation.actionQueue.map(
+ (action) =>
+ `- ${evaluation.projectId}: ${action.code} assigned to ${action.owner}, due in ${action.dueInDays} day(s)`,
+ ),
+ ),
+ "",
+ ]
+
+ fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), `${lines.join("\n")}\n`)
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+}
+
+function writeSvg() {
+ const rows = packet.evaluations
+ .map((evaluation, index) => {
+ const y = 150 + index * 54
+ const color = evaluation.status === "approved" ? "#2f9e44" : evaluation.status === "review" ? "#f08c00" : "#c92a2a"
+ return `
+
+
+ ${escapeXml(evaluation.projectId)} - ${escapeXml(evaluation.status)}
+ risk ${evaluation.riskScore}
+ `
+ })
+ .join("")
+
+ const svg = ``
+
+ fs.writeFileSync(path.join(outDir, "demo.svg"), `${svg}\n`)
+}
+
+function writeMp4() {
+ const mp4Path = path.join(outDir, "demo.mp4")
+ const title = "Enterprise IRB Consent Governance"
+ const summary = `Approved ${packet.summary.approved} Review ${packet.summary.review} Blocked ${packet.summary.blocked}`
+ const topRisk = packet.summary.topRisks[0]
+ const riskText = `Top risk: ${topRisk.projectId} score ${topRisk.riskScore}`
+ const font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
+
+ const filter = [
+ `drawtext=fontfile=${font}:text='${title}':x=60:y=80:fontsize=34:fontcolor=white`,
+ `drawtext=fontfile=${font}:text='${summary}':x=60:y=150:fontsize=28:fontcolor=white`,
+ `drawtext=fontfile=${font}:text='${riskText}':x=60:y=220:fontsize=24:fontcolor=white`,
+ `drawtext=fontfile=${font}:text='Signed webhook events and reviewer packet generated':x=60:y=290:fontsize=22:fontcolor=white`,
+ ].join(",")
+
+ const result = spawnSync(
+ "ffmpeg",
+ [
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=0x111827:s=1280x720:d=7:r=24",
+ "-vf",
+ filter,
+ "-pix_fmt",
+ "yuv420p",
+ mp4Path,
+ ],
+ { stdio: "pipe" },
+ )
+
+ if (result.status !== 0) {
+ fs.writeFileSync(path.join(outDir, "demo-video-warning.txt"), result.stderr.toString())
+ }
+}
+
+writeJson()
+writeReviewerPacket()
+writeSvg()
+writeMp4()
+
+console.log(`Wrote enterprise IRB consent governance artifacts to ${outDir}`)
diff --git a/enterprise-irb-consent-governance/demo.mp4 b/enterprise-irb-consent-governance/demo.mp4
new file mode 100644
index 0000000..f43ec6b
Binary files /dev/null and b/enterprise-irb-consent-governance/demo.mp4 differ
diff --git a/enterprise-irb-consent-governance/demo.svg b/enterprise-irb-consent-governance/demo.svg
new file mode 100644
index 0000000..8230bb0
--- /dev/null
+++ b/enterprise-irb-consent-governance/demo.svg
@@ -0,0 +1,27 @@
+
diff --git a/enterprise-irb-consent-governance/index.js b/enterprise-irb-consent-governance/index.js
new file mode 100644
index 0000000..90a7166
--- /dev/null
+++ b/enterprise-irb-consent-governance/index.js
@@ -0,0 +1,265 @@
+const crypto = require("crypto")
+
+const BLOCKING_SEVERITIES = new Set(["critical", "high"])
+
+function stableStringify(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).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 sha256(value) {
+ return crypto.createHash("sha256").update(String(value)).digest("hex")
+}
+
+function hmacSha256(secret, value) {
+ return crypto.createHmac("sha256", secret).update(String(value)).digest("hex")
+}
+
+function daysBetween(start, end) {
+ const ms = Date.parse(end) - Date.parse(start)
+ return Math.floor(ms / 86400000)
+}
+
+function unique(values) {
+ return [...new Set(values.filter(Boolean))]
+}
+
+function containsAny(values, candidates) {
+ return values.some((value) => candidates.includes(value))
+}
+
+function addFinding(findings, severity, code, message, evidence = {}) {
+ findings.push({
+ severity,
+ code,
+ message,
+ evidence,
+ })
+}
+
+function evaluateConsentPurposeCoverage(project, findings) {
+ for (const dataset of project.datasets) {
+ const missingPurposes = dataset.purposes.filter(
+ (purpose) => !project.consent.scopes.includes(purpose),
+ )
+
+ if (missingPurposes.length > 0) {
+ addFinding(
+ findings,
+ "high",
+ "CONSENT_SCOPE_GAP",
+ `${dataset.id} uses purposes that are not covered by participant consent.`,
+ { dataset: dataset.id, missingPurposes },
+ )
+ }
+ }
+}
+
+function evaluateHumanSubjects(project, policy, asOf, findings) {
+ if (!project.humanSubjects) return
+
+ if (!project.irb || project.irb.status !== "approved") {
+ addFinding(findings, "critical", "IRB_NOT_APPROVED", "Human-subjects project is missing active IRB approval.", {
+ irb: project.irb || null,
+ })
+ }
+
+ if (project.irb?.expiresAt && daysBetween(asOf, project.irb.expiresAt) < 0) {
+ addFinding(findings, "critical", "IRB_EXPIRED", "IRB approval has expired.", {
+ expiresAt: project.irb.expiresAt,
+ })
+ }
+
+ if (project.irb?.expiresAt && daysBetween(asOf, project.irb.expiresAt) <= policy.warningWindows.irbExpiryDays) {
+ addFinding(findings, "medium", "IRB_EXPIRY_SOON", "IRB approval is close to expiry.", {
+ expiresAt: project.irb.expiresAt,
+ warningDays: policy.warningWindows.irbExpiryDays,
+ })
+ }
+
+ if (project.participants.minors && !project.consent.guardianConsent) {
+ addFinding(findings, "critical", "MINOR_GUARDIAN_CONSENT_MISSING", "Minor participants require guardian consent.", {
+ minors: project.participants.minors,
+ })
+ }
+
+ evaluateConsentPurposeCoverage(project, findings)
+}
+
+function evaluateDataUse(project, policy, findings) {
+ const allClasses = unique(project.datasets.flatMap((dataset) => dataset.dataClasses))
+ const controlledClasses = allClasses.filter((dataClass) => policy.controlledDataClasses.includes(dataClass))
+
+ if (controlledClasses.length > 0 && !project.dataUseAgreement.active) {
+ addFinding(findings, "high", "DUA_MISSING", "Controlled data classes require an active data-use agreement.", {
+ controlledClasses,
+ })
+ }
+
+ for (const dataset of project.datasets) {
+ if (containsAny(dataset.dataClasses, policy.deidentificationRequiredFor) && !dataset.deidentified) {
+ addFinding(findings, "high", "DEIDENTIFICATION_REQUIRED", `${dataset.id} must be de-identified before export.`, {
+ dataset: dataset.id,
+ dataClasses: dataset.dataClasses,
+ })
+ }
+ }
+}
+
+function evaluateExports(project, policy, findings) {
+ for (const target of project.exportTargets) {
+ if (!policy.allowedExportTargets.includes(target.type)) {
+ addFinding(findings, "high", "EXPORT_TARGET_NOT_ALLOWED", `${target.type} is not an allowed enterprise target.`, {
+ target,
+ })
+ }
+
+ if (!policy.allowedJurisdictions.includes(target.jurisdiction)) {
+ addFinding(findings, "high", "EXPORT_JURISDICTION_BLOCKED", `${target.jurisdiction} is outside allowed jurisdictions.`, {
+ target,
+ })
+ }
+
+ if (target.requiresAnonymizedData) {
+ const rawDatasets = project.datasets.filter((dataset) => !dataset.deidentified)
+ if (rawDatasets.length > 0) {
+ addFinding(findings, "high", "RAW_DATA_EXPORT_BLOCKED", "Target requires anonymized data but project has raw datasets.", {
+ target: target.name,
+ rawDatasets: rawDatasets.map((dataset) => dataset.id),
+ })
+ }
+ }
+ }
+}
+
+function evaluateRetention(project, policy, asOf, findings) {
+ for (const dataset of project.datasets) {
+ const ageDays = daysBetween(dataset.collectedAt, asOf)
+ const retentionLimit = policy.retentionDaysByClass[dataset.dataClasses[0]] || policy.defaultRetentionDays
+
+ if (ageDays > retentionLimit) {
+ addFinding(findings, "medium", "RETENTION_REVIEW_REQUIRED", `${dataset.id} exceeded its retention review window.`, {
+ dataset: dataset.id,
+ ageDays,
+ retentionLimit,
+ })
+ }
+ }
+}
+
+function buildActionQueue(project, findings) {
+ return findings.map((finding) => {
+ const owner =
+ finding.code === "IRB_NOT_APPROVED" || finding.code === "IRB_EXPIRED"
+ ? project.owners.irbAdmin
+ : finding.code.includes("CONSENT")
+ ? project.owners.consentOfficer
+ : project.owners.dataSteward
+
+ return {
+ owner,
+ code: finding.code,
+ severity: finding.severity,
+ dueInDays: finding.severity === "critical" ? 1 : finding.severity === "high" ? 3 : 14,
+ summary: finding.message,
+ }
+ })
+}
+
+function evaluateProject(project, policy, options = {}) {
+ const asOf = options.asOf || policy.asOf
+ const findings = []
+
+ evaluateHumanSubjects(project, policy, asOf, findings)
+ evaluateDataUse(project, policy, findings)
+ evaluateExports(project, policy, findings)
+ evaluateRetention(project, policy, asOf, findings)
+
+ const blockingFindings = findings.filter((finding) => BLOCKING_SEVERITIES.has(finding.severity))
+ const status = blockingFindings.length > 0 ? "blocked" : findings.length > 0 ? "review" : "approved"
+ const riskScore = findings.reduce((score, finding) => {
+ const weights = { critical: 40, high: 25, medium: 10, low: 3 }
+ return score + weights[finding.severity]
+ }, 0)
+
+ const event = {
+ type: "enterprise.irb_consent_governance.evaluated",
+ projectId: project.id,
+ status,
+ findingCount: findings.length,
+ blockingCount: blockingFindings.length,
+ generatedAt: asOf,
+ }
+
+ const canonicalEvent = stableStringify(event)
+
+ return {
+ projectId: project.id,
+ title: project.title,
+ status,
+ riskScore,
+ findings,
+ actionQueue: buildActionQueue(project, findings),
+ exportDecision: status === "approved" ? "allow" : status === "review" ? "hold-for-review" : "block",
+ evidenceDigest: sha256(stableStringify({ project, findings })),
+ webhookEvent: {
+ ...event,
+ signature: `sha256=${hmacSha256(policy.webhookSecret, canonicalEvent)}`,
+ },
+ }
+}
+
+function summarizeEvaluations(evaluations) {
+ const counts = evaluations.reduce(
+ (acc, evaluation) => {
+ acc[evaluation.status] += 1
+ return acc
+ },
+ { approved: 0, review: 0, blocked: 0 },
+ )
+
+ return {
+ projectCount: evaluations.length,
+ ...counts,
+ topRisks: evaluations
+ .slice()
+ .sort((a, b) => b.riskScore - a.riskScore)
+ .slice(0, 3)
+ .map((evaluation) => ({
+ projectId: evaluation.projectId,
+ title: evaluation.title,
+ status: evaluation.status,
+ riskScore: evaluation.riskScore,
+ })),
+ }
+}
+
+function generateGovernancePacket(projects, policy, options = {}) {
+ const evaluations = projects.map((project) => evaluateProject(project, policy, options))
+
+ return {
+ generatedAt: options.asOf || policy.asOf,
+ module: "enterprise-irb-consent-governance",
+ summary: summarizeEvaluations(evaluations),
+ evaluations,
+ auditDigest: sha256(stableStringify(evaluations)),
+ }
+}
+
+module.exports = {
+ evaluateProject,
+ generateGovernancePacket,
+ stableStringify,
+ sha256,
+ hmacSha256,
+}
diff --git a/enterprise-irb-consent-governance/irb-consent-report.json b/enterprise-irb-consent-governance/irb-consent-report.json
new file mode 100644
index 0000000..5a5425d
--- /dev/null
+++ b/enterprise-irb-consent-governance/irb-consent-report.json
@@ -0,0 +1,323 @@
+{
+ "generatedAt": "2026-05-20T12:00:00.000Z",
+ "module": "enterprise-irb-consent-governance",
+ "summary": {
+ "projectCount": 4,
+ "approved": 2,
+ "review": 0,
+ "blocked": 2,
+ "topRisks": [
+ {
+ "projectId": "SCI-YOUTH-091",
+ "title": "Youth sleep and learning intervention",
+ "status": "blocked",
+ "riskScore": 230
+ },
+ {
+ "projectId": "SCI-GENOME-204",
+ "title": "Cross-border genomic phenotype atlas",
+ "status": "blocked",
+ "riskScore": 85
+ },
+ {
+ "projectId": "SCI-HEART-042",
+ "title": "Remote cardiac recovery cohort",
+ "status": "approved",
+ "riskScore": 0
+ }
+ ]
+ },
+ "evaluations": [
+ {
+ "projectId": "SCI-HEART-042",
+ "title": "Remote cardiac recovery cohort",
+ "status": "approved",
+ "riskScore": 0,
+ "findings": [],
+ "actionQueue": [],
+ "exportDecision": "allow",
+ "evidenceDigest": "ad13727c41d326b7fdd284621f84817f663f9ec067cfccf4fc0190afd625969c",
+ "webhookEvent": {
+ "type": "enterprise.irb_consent_governance.evaluated",
+ "projectId": "SCI-HEART-042",
+ "status": "approved",
+ "findingCount": 0,
+ "blockingCount": 0,
+ "generatedAt": "2026-05-20T12:00:00.000Z",
+ "signature": "sha256=6e4a990d2f968bfb0bcb63e786434106636e773c330962c803a07389df48d921"
+ }
+ },
+ {
+ "projectId": "SCI-YOUTH-091",
+ "title": "Youth sleep and learning intervention",
+ "status": "blocked",
+ "riskScore": 230,
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "IRB_NOT_APPROVED",
+ "message": "Human-subjects project is missing active IRB approval.",
+ "evidence": {
+ "irb": {
+ "id": "IRB-2025-221",
+ "status": "expired",
+ "expiresAt": "2026-03-10T00:00:00.000Z"
+ }
+ }
+ },
+ {
+ "severity": "critical",
+ "code": "IRB_EXPIRED",
+ "message": "IRB approval has expired.",
+ "evidence": {
+ "expiresAt": "2026-03-10T00:00:00.000Z"
+ }
+ },
+ {
+ "severity": "medium",
+ "code": "IRB_EXPIRY_SOON",
+ "message": "IRB approval is close to expiry.",
+ "evidence": {
+ "expiresAt": "2026-03-10T00:00:00.000Z",
+ "warningDays": 45
+ }
+ },
+ {
+ "severity": "critical",
+ "code": "MINOR_GUARDIAN_CONSENT_MISSING",
+ "message": "Minor participants require guardian consent.",
+ "evidence": {
+ "minors": true
+ }
+ },
+ {
+ "severity": "high",
+ "code": "CONSENT_SCOPE_GAP",
+ "message": "student-surveys uses purposes that are not covered by participant consent.",
+ "evidence": {
+ "dataset": "student-surveys",
+ "missingPurposes": [
+ "publication"
+ ]
+ }
+ },
+ {
+ "severity": "high",
+ "code": "DUA_MISSING",
+ "message": "Controlled data classes require an active data-use agreement.",
+ "evidence": {
+ "controlledClasses": [
+ "PII"
+ ]
+ }
+ },
+ {
+ "severity": "high",
+ "code": "DEIDENTIFICATION_REQUIRED",
+ "message": "student-surveys must be de-identified before export.",
+ "evidence": {
+ "dataset": "student-surveys",
+ "dataClasses": [
+ "PII"
+ ]
+ }
+ },
+ {
+ "severity": "high",
+ "code": "RAW_DATA_EXPORT_BLOCKED",
+ "message": "Target requires anonymized data but project has raw datasets.",
+ "evidence": {
+ "target": "Journal package",
+ "rawDatasets": [
+ "student-surveys"
+ ]
+ }
+ }
+ ],
+ "actionQueue": [
+ {
+ "owner": "irb-office@example.edu",
+ "code": "IRB_NOT_APPROVED",
+ "severity": "critical",
+ "dueInDays": 1,
+ "summary": "Human-subjects project is missing active IRB approval."
+ },
+ {
+ "owner": "irb-office@example.edu",
+ "code": "IRB_EXPIRED",
+ "severity": "critical",
+ "dueInDays": 1,
+ "summary": "IRB approval has expired."
+ },
+ {
+ "owner": "data-steward@example.edu",
+ "code": "IRB_EXPIRY_SOON",
+ "severity": "medium",
+ "dueInDays": 14,
+ "summary": "IRB approval is close to expiry."
+ },
+ {
+ "owner": "consent-review@example.edu",
+ "code": "MINOR_GUARDIAN_CONSENT_MISSING",
+ "severity": "critical",
+ "dueInDays": 1,
+ "summary": "Minor participants require guardian consent."
+ },
+ {
+ "owner": "consent-review@example.edu",
+ "code": "CONSENT_SCOPE_GAP",
+ "severity": "high",
+ "dueInDays": 3,
+ "summary": "student-surveys uses purposes that are not covered by participant consent."
+ },
+ {
+ "owner": "data-steward@example.edu",
+ "code": "DUA_MISSING",
+ "severity": "high",
+ "dueInDays": 3,
+ "summary": "Controlled data classes require an active data-use agreement."
+ },
+ {
+ "owner": "data-steward@example.edu",
+ "code": "DEIDENTIFICATION_REQUIRED",
+ "severity": "high",
+ "dueInDays": 3,
+ "summary": "student-surveys must be de-identified before export."
+ },
+ {
+ "owner": "data-steward@example.edu",
+ "code": "RAW_DATA_EXPORT_BLOCKED",
+ "severity": "high",
+ "dueInDays": 3,
+ "summary": "Target requires anonymized data but project has raw datasets."
+ }
+ ],
+ "exportDecision": "block",
+ "evidenceDigest": "66e66ad186fea077abaa03803793269d1073925d7e29b05066fbc1b3a8f76877",
+ "webhookEvent": {
+ "type": "enterprise.irb_consent_governance.evaluated",
+ "projectId": "SCI-YOUTH-091",
+ "status": "blocked",
+ "findingCount": 8,
+ "blockingCount": 7,
+ "generatedAt": "2026-05-20T12:00:00.000Z",
+ "signature": "sha256=6a420749c91abb8303b7eb745cc67111a95748853a4b3acf70453e577c531880"
+ }
+ },
+ {
+ "projectId": "SCI-GENOME-204",
+ "title": "Cross-border genomic phenotype atlas",
+ "status": "blocked",
+ "riskScore": 85,
+ "findings": [
+ {
+ "severity": "medium",
+ "code": "IRB_EXPIRY_SOON",
+ "message": "IRB approval is close to expiry.",
+ "evidence": {
+ "expiresAt": "2026-06-15T00:00:00.000Z",
+ "warningDays": 45
+ }
+ },
+ {
+ "severity": "high",
+ "code": "CONSENT_SCOPE_GAP",
+ "message": "genotype-table uses purposes that are not covered by participant consent.",
+ "evidence": {
+ "dataset": "genotype-table",
+ "missingPurposes": [
+ "external_ai_training"
+ ]
+ }
+ },
+ {
+ "severity": "high",
+ "code": "EXPORT_TARGET_NOT_ALLOWED",
+ "message": "external_ai_platform is not an allowed enterprise target.",
+ "evidence": {
+ "target": {
+ "name": "Partner analytics platform",
+ "type": "external_ai_platform",
+ "jurisdiction": "SG",
+ "requiresAnonymizedData": true
+ }
+ }
+ },
+ {
+ "severity": "high",
+ "code": "EXPORT_JURISDICTION_BLOCKED",
+ "message": "SG is outside allowed jurisdictions.",
+ "evidence": {
+ "target": {
+ "name": "Partner analytics platform",
+ "type": "external_ai_platform",
+ "jurisdiction": "SG",
+ "requiresAnonymizedData": true
+ }
+ }
+ }
+ ],
+ "actionQueue": [
+ {
+ "owner": "data-steward@example.edu",
+ "code": "IRB_EXPIRY_SOON",
+ "severity": "medium",
+ "dueInDays": 14,
+ "summary": "IRB approval is close to expiry."
+ },
+ {
+ "owner": "consent-review@example.edu",
+ "code": "CONSENT_SCOPE_GAP",
+ "severity": "high",
+ "dueInDays": 3,
+ "summary": "genotype-table uses purposes that are not covered by participant consent."
+ },
+ {
+ "owner": "data-steward@example.edu",
+ "code": "EXPORT_TARGET_NOT_ALLOWED",
+ "severity": "high",
+ "dueInDays": 3,
+ "summary": "external_ai_platform is not an allowed enterprise target."
+ },
+ {
+ "owner": "data-steward@example.edu",
+ "code": "EXPORT_JURISDICTION_BLOCKED",
+ "severity": "high",
+ "dueInDays": 3,
+ "summary": "SG is outside allowed jurisdictions."
+ }
+ ],
+ "exportDecision": "block",
+ "evidenceDigest": "9cc93dbc906bb67df1a0eebe4eb3738745ed0eb36ce8df8b68d0ab70916edd9f",
+ "webhookEvent": {
+ "type": "enterprise.irb_consent_governance.evaluated",
+ "projectId": "SCI-GENOME-204",
+ "status": "blocked",
+ "findingCount": 4,
+ "blockingCount": 3,
+ "generatedAt": "2026-05-20T12:00:00.000Z",
+ "signature": "sha256=d353783744c4f341cf5e98dafca451197fcf9ae9f23f623d25a4d12012115af9"
+ }
+ },
+ {
+ "projectId": "SCI-MATERIALS-018",
+ "title": "Open polymer degradation benchmark",
+ "status": "approved",
+ "riskScore": 0,
+ "findings": [],
+ "actionQueue": [],
+ "exportDecision": "allow",
+ "evidenceDigest": "0f3ddc2559ed1cfcddd519f81964775ff2a73f1fd3877e5b3df993c00e2b58a4",
+ "webhookEvent": {
+ "type": "enterprise.irb_consent_governance.evaluated",
+ "projectId": "SCI-MATERIALS-018",
+ "status": "approved",
+ "findingCount": 0,
+ "blockingCount": 0,
+ "generatedAt": "2026-05-20T12:00:00.000Z",
+ "signature": "sha256=5c0aa458e224c70d9a41135137c7f7604f90d66942a7153f637f5c18e9e425cb"
+ }
+ }
+ ],
+ "auditDigest": "e64e96869e34aecaadf294ed697af075b06b1fd7b755922661f61e0a179f8f80"
+}
diff --git a/enterprise-irb-consent-governance/requirements-map.md b/enterprise-irb-consent-governance/requirements-map.md
new file mode 100644
index 0000000..a928424
--- /dev/null
+++ b/enterprise-irb-consent-governance/requirements-map.md
@@ -0,0 +1,14 @@
+# Requirements Map
+
+Issue #19 asks for Enterprise Tooling across admin visibility, governance controls, APIs/webhooks, export pipelines, and compliance tracking. This module implements a distinct human-subjects governance gate.
+
+| Requirement area | Coverage in this slice |
+| --- | --- |
+| Organization-wide governance | Scores projects for IRB, consent, DUA, retention, and export readiness. |
+| Compliance tracking | Flags expired IRB approvals, missing guardian consent, consent-purpose gaps, DUA gaps, and retention review needs. |
+| Export pipelines | Produces allow, hold-for-review, or block decisions for repository, journal, funder, and dashboard destinations. |
+| API and webhook support | Emits deterministic signed webhook-ready events per project. |
+| Admin dashboard inputs | Writes JSON and Markdown reviewer packets with top risks and action queues. |
+| Enterprise interoperability | Models institutional repository, journal, funder, and internal dashboard export targets. |
+
+This is intentionally separate from previous enterprise slices for funder reporting, incident response, data residency, dashboard attribution, quotas, secret rotation, API change governance, and connector certification.
diff --git a/enterprise-irb-consent-governance/reviewer-packet.md b/enterprise-irb-consent-governance/reviewer-packet.md
new file mode 100644
index 0000000..0f60587
--- /dev/null
+++ b/enterprise-irb-consent-governance/reviewer-packet.md
@@ -0,0 +1,33 @@
+# Enterprise IRB Consent Governance Review Packet
+
+Generated: 2026-05-20T12:00:00.000Z
+Audit digest: e64e96869e34aecaadf294ed697af075b06b1fd7b755922661f61e0a179f8f80
+
+## Summary
+
+- Projects evaluated: 4
+- Approved: 2
+- Review: 0
+- Blocked: 2
+
+## Top risks
+
+- SCI-YOUTH-091: Youth sleep and learning intervention - blocked (230)
+- SCI-GENOME-204: Cross-border genomic phenotype atlas - blocked (85)
+- SCI-HEART-042: Remote cardiac recovery cohort - approved (0)
+
+## Action queue
+
+- SCI-YOUTH-091: IRB_NOT_APPROVED assigned to irb-office@example.edu, due in 1 day(s)
+- SCI-YOUTH-091: IRB_EXPIRED assigned to irb-office@example.edu, due in 1 day(s)
+- SCI-YOUTH-091: IRB_EXPIRY_SOON assigned to data-steward@example.edu, due in 14 day(s)
+- SCI-YOUTH-091: MINOR_GUARDIAN_CONSENT_MISSING assigned to consent-review@example.edu, due in 1 day(s)
+- SCI-YOUTH-091: CONSENT_SCOPE_GAP assigned to consent-review@example.edu, due in 3 day(s)
+- SCI-YOUTH-091: DUA_MISSING assigned to data-steward@example.edu, due in 3 day(s)
+- SCI-YOUTH-091: DEIDENTIFICATION_REQUIRED assigned to data-steward@example.edu, due in 3 day(s)
+- SCI-YOUTH-091: RAW_DATA_EXPORT_BLOCKED assigned to data-steward@example.edu, due in 3 day(s)
+- SCI-GENOME-204: IRB_EXPIRY_SOON assigned to data-steward@example.edu, due in 14 day(s)
+- SCI-GENOME-204: CONSENT_SCOPE_GAP assigned to consent-review@example.edu, due in 3 day(s)
+- SCI-GENOME-204: EXPORT_TARGET_NOT_ALLOWED assigned to data-steward@example.edu, due in 3 day(s)
+- SCI-GENOME-204: EXPORT_JURISDICTION_BLOCKED assigned to data-steward@example.edu, due in 3 day(s)
+
diff --git a/enterprise-irb-consent-governance/sample-data.js b/enterprise-irb-consent-governance/sample-data.js
new file mode 100644
index 0000000..edff584
--- /dev/null
+++ b/enterprise-irb-consent-governance/sample-data.js
@@ -0,0 +1,195 @@
+const policy = {
+ asOf: "2026-05-20T12:00:00.000Z",
+ webhookSecret: "synthetic-review-secret",
+ controlledDataClasses: ["PHI", "PII", "GENOMIC", "PRIVATE_CLINICAL"],
+ deidentificationRequiredFor: ["PHI", "PII", "GENOMIC", "PRIVATE_CLINICAL"],
+ allowedExportTargets: ["institutional_repository", "journal_submission", "funder_portal", "internal_dashboard"],
+ allowedJurisdictions: ["US", "EU", "UK", "CA"],
+ defaultRetentionDays: 1825,
+ retentionDaysByClass: {
+ PHI: 2555,
+ PII: 1825,
+ GENOMIC: 3650,
+ PUBLIC: 7300,
+ },
+ warningWindows: {
+ irbExpiryDays: 45,
+ },
+}
+
+const projects = [
+ {
+ id: "SCI-HEART-042",
+ title: "Remote cardiac recovery cohort",
+ humanSubjects: true,
+ irb: {
+ id: "IRB-2026-118",
+ status: "approved",
+ expiresAt: "2026-12-01T00:00:00.000Z",
+ },
+ participants: {
+ count: 240,
+ minors: false,
+ },
+ consent: {
+ scopes: ["analysis", "publication", "funder_reporting"],
+ guardianConsent: false,
+ },
+ dataUseAgreement: {
+ active: true,
+ id: "DUA-HEART-2026",
+ },
+ datasets: [
+ {
+ id: "heart-vitals",
+ dataClasses: ["PHI"],
+ purposes: ["analysis", "publication"],
+ deidentified: true,
+ collectedAt: "2026-01-04T00:00:00.000Z",
+ },
+ ],
+ exportTargets: [
+ {
+ name: "Zenodo release",
+ type: "institutional_repository",
+ jurisdiction: "EU",
+ requiresAnonymizedData: true,
+ },
+ ],
+ owners: {
+ irbAdmin: "irb-office@example.edu",
+ consentOfficer: "consent-review@example.edu",
+ dataSteward: "data-steward@example.edu",
+ },
+ },
+ {
+ id: "SCI-YOUTH-091",
+ title: "Youth sleep and learning intervention",
+ humanSubjects: true,
+ irb: {
+ id: "IRB-2025-221",
+ status: "expired",
+ expiresAt: "2026-03-10T00:00:00.000Z",
+ },
+ participants: {
+ count: 88,
+ minors: true,
+ },
+ consent: {
+ scopes: ["analysis"],
+ guardianConsent: false,
+ },
+ dataUseAgreement: {
+ active: false,
+ id: null,
+ },
+ datasets: [
+ {
+ id: "student-surveys",
+ dataClasses: ["PII"],
+ purposes: ["analysis", "publication"],
+ deidentified: false,
+ collectedAt: "2025-09-01T00:00:00.000Z",
+ },
+ ],
+ exportTargets: [
+ {
+ name: "Journal package",
+ type: "journal_submission",
+ jurisdiction: "US",
+ requiresAnonymizedData: true,
+ },
+ ],
+ owners: {
+ irbAdmin: "irb-office@example.edu",
+ consentOfficer: "consent-review@example.edu",
+ dataSteward: "data-steward@example.edu",
+ },
+ },
+ {
+ id: "SCI-GENOME-204",
+ title: "Cross-border genomic phenotype atlas",
+ humanSubjects: true,
+ irb: {
+ id: "IRB-2026-044",
+ status: "approved",
+ expiresAt: "2026-06-15T00:00:00.000Z",
+ },
+ participants: {
+ count: 510,
+ minors: false,
+ },
+ consent: {
+ scopes: ["analysis", "publication"],
+ guardianConsent: false,
+ },
+ dataUseAgreement: {
+ active: true,
+ id: "DUA-GENOME-2026",
+ },
+ datasets: [
+ {
+ id: "genotype-table",
+ dataClasses: ["GENOMIC"],
+ purposes: ["analysis", "external_ai_training"],
+ deidentified: true,
+ collectedAt: "2023-04-12T00:00:00.000Z",
+ },
+ ],
+ exportTargets: [
+ {
+ name: "Partner analytics platform",
+ type: "external_ai_platform",
+ jurisdiction: "SG",
+ requiresAnonymizedData: true,
+ },
+ ],
+ owners: {
+ irbAdmin: "irb-office@example.edu",
+ consentOfficer: "consent-review@example.edu",
+ dataSteward: "data-steward@example.edu",
+ },
+ },
+ {
+ id: "SCI-MATERIALS-018",
+ title: "Open polymer degradation benchmark",
+ humanSubjects: false,
+ irb: null,
+ participants: {
+ count: 0,
+ minors: false,
+ },
+ consent: {
+ scopes: [],
+ guardianConsent: false,
+ },
+ dataUseAgreement: {
+ active: false,
+ id: null,
+ },
+ datasets: [
+ {
+ id: "polymer-open-table",
+ dataClasses: ["PUBLIC"],
+ purposes: ["publication"],
+ deidentified: true,
+ collectedAt: "2024-02-20T00:00:00.000Z",
+ },
+ ],
+ exportTargets: [
+ {
+ name: "Public repository",
+ type: "institutional_repository",
+ jurisdiction: "US",
+ requiresAnonymizedData: false,
+ },
+ ],
+ owners: {
+ irbAdmin: "irb-office@example.edu",
+ consentOfficer: "consent-review@example.edu",
+ dataSteward: "data-steward@example.edu",
+ },
+ },
+]
+
+module.exports = { policy, projects }
diff --git a/enterprise-irb-consent-governance/test.js b/enterprise-irb-consent-governance/test.js
new file mode 100644
index 0000000..8442077
--- /dev/null
+++ b/enterprise-irb-consent-governance/test.js
@@ -0,0 +1,41 @@
+const assert = require("assert")
+const { generateGovernancePacket, evaluateProject, hmacSha256, stableStringify } = require("./index")
+const { policy, projects } = require("./sample-data")
+
+const packet = generateGovernancePacket(projects, policy)
+
+assert.strictEqual(packet.summary.projectCount, 4)
+assert.strictEqual(packet.summary.approved, 2)
+assert.strictEqual(packet.summary.blocked, 2)
+assert.strictEqual(packet.summary.review, 0)
+assert.match(packet.auditDigest, /^[a-f0-9]{64}$/)
+
+const youthProject = projects.find((project) => project.id === "SCI-YOUTH-091")
+const youthEvaluation = evaluateProject(youthProject, policy)
+
+assert.strictEqual(youthEvaluation.status, "blocked")
+assert.strictEqual(youthEvaluation.exportDecision, "block")
+assert(youthEvaluation.findings.some((finding) => finding.code === "IRB_EXPIRED"))
+assert(youthEvaluation.findings.some((finding) => finding.code === "MINOR_GUARDIAN_CONSENT_MISSING"))
+assert(youthEvaluation.findings.some((finding) => finding.code === "CONSENT_SCOPE_GAP"))
+assert(youthEvaluation.findings.some((finding) => finding.code === "RAW_DATA_EXPORT_BLOCKED"))
+
+const heartProject = projects.find((project) => project.id === "SCI-HEART-042")
+const heartEvaluation = evaluateProject(heartProject, policy)
+
+assert.strictEqual(heartEvaluation.status, "approved")
+assert.strictEqual(heartEvaluation.exportDecision, "allow")
+assert.strictEqual(heartEvaluation.findings.length, 0)
+
+const { signature, ...eventWithoutSignature } = heartEvaluation.webhookEvent
+const expectedSignature = `sha256=${hmacSha256(policy.webhookSecret, stableStringify(eventWithoutSignature))}`
+
+assert.strictEqual(signature, expectedSignature)
+
+const genomeEvaluation = packet.evaluations.find((evaluation) => evaluation.projectId === "SCI-GENOME-204")
+
+assert(genomeEvaluation.findings.some((finding) => finding.code === "EXPORT_TARGET_NOT_ALLOWED"))
+assert(genomeEvaluation.findings.some((finding) => finding.code === "EXPORT_JURISDICTION_BLOCKED"))
+assert(genomeEvaluation.findings.some((finding) => finding.code === "CONSENT_SCOPE_GAP"))
+
+console.log("enterprise-irb-consent-governance tests passed")