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 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 @@ + + + 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")