From 936a363f2ffa75e0291443b846cb90745c83f585 Mon Sep 17 00:00:00 2001 From: jalilhadjhabib Date: Wed, 20 May 2026 12:14:27 +0100 Subject: [PATCH] Add enterprise IRB consent governance --- README.md | 4 + enterprise-irb-consent-governance/README.md | 39 +++ .../acceptance-notes.md | 19 ++ enterprise-irb-consent-governance/demo.js | 126 +++++++ enterprise-irb-consent-governance/demo.mp4 | Bin 0 -> 24192 bytes enterprise-irb-consent-governance/demo.svg | 27 ++ enterprise-irb-consent-governance/index.js | 265 ++++++++++++++ .../irb-consent-report.json | 323 ++++++++++++++++++ .../requirements-map.md | 14 + .../reviewer-packet.md | 33 ++ .../sample-data.js | 195 +++++++++++ enterprise-irb-consent-governance/test.js | 41 +++ 12 files changed, 1086 insertions(+) create mode 100644 enterprise-irb-consent-governance/README.md create mode 100644 enterprise-irb-consent-governance/acceptance-notes.md create mode 100644 enterprise-irb-consent-governance/demo.js create mode 100644 enterprise-irb-consent-governance/demo.mp4 create mode 100644 enterprise-irb-consent-governance/demo.svg create mode 100644 enterprise-irb-consent-governance/index.js create mode 100644 enterprise-irb-consent-governance/irb-consent-report.json create mode 100644 enterprise-irb-consent-governance/requirements-map.md create mode 100644 enterprise-irb-consent-governance/reviewer-packet.md create mode 100644 enterprise-irb-consent-governance/sample-data.js create mode 100644 enterprise-irb-consent-governance/test.js 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 = ` + + Enterprise IRB Consent Governance + Approved ${packet.summary.approved} / Review ${packet.summary.review} / Blocked ${packet.summary.blocked} + ${rows} + Audit digest: ${packet.auditDigest.slice(0, 32)}... +` + + 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 0000000000000000000000000000000000000000..f43ec6b794c2c49cc618e15afa24bcf0bf8ccdca GIT binary patch literal 24192 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 + + Enterprise IRB Consent Governance + Approved 2 / Review 0 / Blocked 2 + + + + SCI-HEART-042 - approved + risk 0 + + + + SCI-YOUTH-091 - blocked + risk 230 + + + + SCI-GENOME-204 - blocked + risk 85 + + + + SCI-MATERIALS-018 - approved + risk 0 + + Audit digest: e64e96869e34aecaadf294ed697af075... + 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")