diff --git a/collaborative-ethics-consent-guard/README.md b/collaborative-ethics-consent-guard/README.md new file mode 100644 index 00000000..38838518 --- /dev/null +++ b/collaborative-ethics-consent-guard/README.md @@ -0,0 +1,57 @@ +# Collaborative Ethics and Consent Guard + +This module adds a focused collaborative ethics-and-consent export guard for the Real-Time Collaborative Editor in issue `#12`. + +Collaborative manuscripts can accumulate edits, comments, translations, and locked-section changes across many users. Before export, the editor needs a deterministic gate that checks whether ethics and consent content is safe to publish and whether private reviewer or participant details are still embedded in collaborative state. + +## Scope + +- Checks human-subjects manuscripts for ethics approval identifiers. +- Flags expired approvals and missing exemption rationale. +- Checks vulnerable-population review evidence. +- Checks consent statement presence and waiver rationale. +- Compares consent scope against the requested export scope. +- Checks required translated-consent coverage. +- Flags pending edits in locked ethics/consent sections without approved override. +- Flags stale ethics-section review after collaborative edits. +- Holds exports with private ethics comments or participant-like identifiers. +- Produces JSON, Markdown, SVG, and MP4 reviewer artifacts. +- Uses synthetic data only. +- Uses no external APIs, private manuscripts, real participant data, credentials, payment systems, or network calls. + +## Requirement Map + +| Issue #12 capability | Implementation | +| --- | --- | +| Real-time collaboration | Reviews collaborative comments, pending edits, locks, and section state before export. | +| Change tracking and suggestions | Detects pending ethics edits and unresolved ethics comments. | +| Rich scientific manuscript export | Guards ethics/consent statements before manuscript export. | +| Version history and autosave | Checks review freshness after collaborative changes. | +| Inline comments and review sidebar | Blocks private reviewer notes and participant-like identifiers from public export. | +| Publication-ready formatting | Confirms consent scope, translated-consent coverage, and ethics approval metadata. | + +## Run + +```bash +npm test +npm run demo +npm run video +npm run check +``` + +Demo artifacts are written to `reports/`: + +- `summary.json` +- `reviewer-packet.md` +- `summary.svg` +- `demo.mp4` + +## Decisions + +- `RELEASE`: ethics, consent, comments, and export scope are publication-ready. +- `REVIEW`: missing or stale evidence can be repaired before export. +- `HOLD`: missing approval/consent, expired approval, private comment leakage, or participant identifiers block export. + +## AI Assistance Disclosure + +Implemented with Codex assistance and locally verified with deterministic tests and generated artifacts. diff --git a/collaborative-ethics-consent-guard/data/sample_editor_packets.json b/collaborative-ethics-consent-guard/data/sample_editor_packets.json new file mode 100644 index 00000000..6f81d174 --- /dev/null +++ b/collaborative-ethics-consent-guard/data/sample_editor_packets.json @@ -0,0 +1,162 @@ +[ + { + "id": "release-clean-consent", + "title": "Clean collaborative consent export", + "reviewDate": "2026-08-15", + "manuscript": { + "domain": "public health", + "humanSubjects": true, + "participantGroups": ["adults"] + }, + "ethics": { + "approvalId": "IRB-2026-114", + "expiryDate": "2027-01-31", + "vulnerablePopulationReview": false + }, + "consent": { + "required": true, + "statementPresent": true, + "scope": "journal publication", + "requiredLanguages": ["English", "Spanish"], + "presentLanguages": ["English", "Spanish"] + }, + "collaborativeState": { + "lastEthicsReviewAt": "2026-08-01", + "ethicsSection": { + "locked": true, + "pendingEdits": false + } + }, + "comments": [ + { + "id": "c1", + "section": "methods ethics", + "private": false, + "exportVisible": false, + "resolved": true, + "text": "Consent statement reviewed." + } + ], + "exportTarget": { + "releaseScope": "journal publication" + } + }, + { + "id": "review-stale-vulnerable", + "title": "Vulnerable population review needs refresh", + "reviewDate": "2026-08-15", + "manuscript": { + "domain": "education research", + "humanSubjects": true, + "participantGroups": ["children"] + }, + "ethics": { + "approvalId": "REC-2025-09", + "expiryDate": "2026-12-01", + "vulnerablePopulationReview": false + }, + "consent": { + "required": true, + "statementPresent": true, + "scope": "conference abstract", + "requiredLanguages": ["English", "French"], + "presentLanguages": ["English"] + }, + "collaborativeState": { + "lastEthicsReviewAt": "2025-11-01", + "ethicsSection": { + "locked": true, + "pendingEdits": true, + "approvedOverride": true + } + }, + "comments": [ + { + "id": "c2", + "section": "ethics statement", + "private": false, + "exportVisible": false, + "resolved": false, + "text": "Need French consent upload confirmation." + } + ], + "exportTarget": { + "releaseScope": "conference abstract" + } + }, + { + "id": "hold-missing-approval", + "title": "Human-subjects packet without approval", + "reviewDate": "2026-08-15", + "manuscript": { + "domain": "clinical methods", + "humanSubjects": true, + "participantGroups": ["adults"] + }, + "ethics": { + "approvalId": "", + "exempt": false + }, + "consent": { + "required": true, + "statementPresent": false, + "scope": "journal publication", + "requiredLanguages": ["English"], + "presentLanguages": [] + }, + "collaborativeState": { + "lastEthicsReviewAt": "2026-08-01", + "ethicsSection": { + "locked": true, + "pendingEdits": false + } + }, + "comments": [], + "exportTarget": { + "releaseScope": "journal publication" + } + }, + { + "id": "hold-private-comment-leak", + "title": "Private participant details leaking through comments", + "reviewDate": "2026-08-15", + "manuscript": { + "domain": "behavioral science", + "humanSubjects": true, + "participantGroups": ["adults"] + }, + "ethics": { + "approvalId": "IRB-2026-202", + "expiryDate": "2027-03-01", + "vulnerablePopulationReview": false + }, + "consent": { + "required": true, + "statementPresent": true, + "scope": "journal publication", + "requiredLanguages": ["English"], + "presentLanguages": ["English"] + }, + "collaborativeState": { + "lastEthicsReviewAt": "2026-07-20", + "ethicsSection": { + "locked": true, + "pendingEdits": true, + "approvedOverride": false + } + }, + "comments": [ + { + "id": "c3", + "section": "ethics statement", + "private": true, + "exportVisible": true, + "resolved": false, + "text": "participant 104 withdrew after DOB: 02/04/1999 note." + } + ], + "exportTarget": { + "releaseScope": "journal publication" + } + } +] diff --git a/collaborative-ethics-consent-guard/package.json b/collaborative-ethics-consent-guard/package.json new file mode 100644 index 00000000..be486878 --- /dev/null +++ b/collaborative-ethics-consent-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "collaborative-ethics-consent-guard", + "version": "1.0.0", + "description": "Dependency-free ethics and consent export guard for collaborative research manuscripts.", + "main": "src/index.js", + "scripts": { + "test": "node --test", + "demo": "node scripts/demo.js", + "video": "node scripts/render-demo-video.js", + "check": "node --check src/index.js && node --check scripts/demo.js && node --check scripts/render-demo-video.js" + }, + "license": "MIT" +} diff --git a/collaborative-ethics-consent-guard/reports/demo.mp4 b/collaborative-ethics-consent-guard/reports/demo.mp4 new file mode 100644 index 00000000..2818b1b3 Binary files /dev/null and b/collaborative-ethics-consent-guard/reports/demo.mp4 differ diff --git a/collaborative-ethics-consent-guard/reports/reviewer-packet.md b/collaborative-ethics-consent-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..d4307b73 --- /dev/null +++ b/collaborative-ethics-consent-guard/reports/reviewer-packet.md @@ -0,0 +1,62 @@ +# Collaborative Ethics and Consent Export Review + +Generated: 2026-08-15T21:08:58.890Z + +## Decision Counts + +- RELEASE: 1 +- REVIEW: 1 +- HOLD: 2 + +## Packet Reviews + +### Clean collaborative consent export +Decision: RELEASE +Confidence: 0.96 +Human subjects: true +Comments checked: 1 +Reviewer note: public health: ethics, consent, comments, and export scope are publication-ready. + +### Vulnerable population review needs refresh +Decision: REVIEW +Confidence: 0.31 +Human subjects: true +Comments checked: 1 +Reviewer note: education research: REVIEW before export because the manuscript includes vulnerable participant groups without documented enhanced ethics review. +- MAJOR VULNERABLE_POPULATION_REVIEW_MISSING: The manuscript includes vulnerable participant groups without documented enhanced ethics review. + Remediation: Add vulnerable-population review evidence and consent safeguards before publication export. +- MAJOR TRANSLATED_CONSENT_COVERAGE_MISSING: Required consent-language coverage is missing for one or more participant groups. + Remediation: Add translated consent evidence or justify why those participant groups are not included in the export. +- MAJOR STALE_ETHICS_REVIEW: The last ethics-section review is older than the configured freshness window. + Remediation: Refresh ethics reviewer signoff after the latest collaborative edits. +- MINOR UNRESOLVED_ETHICS_COMMENT: An ethics-section comment remains unresolved at export time. + Remediation: Resolve or explicitly defer the ethics comment before publication export. + +### Human-subjects packet without approval +Decision: HOLD +Confidence: 0.11 +Human subjects: true +Comments checked: 0 +Reviewer note: clinical methods: HOLD before export because the manuscript involves human subjects but lacks an ethics approval identifier. +- CRITICAL MISSING_ETHICS_APPROVAL_ID: The manuscript involves human subjects but lacks an ethics approval identifier. + Remediation: Add the IRB/REC approval identifier or mark the study as exempt with supporting rationale. +- CRITICAL MISSING_CONSENT_STATEMENT: Consent is required but no consent statement is present in the collaborative manuscript. + Remediation: Add a consent statement or documented waiver before export. +- MAJOR TRANSLATED_CONSENT_COVERAGE_MISSING: Required consent-language coverage is missing for one or more participant groups. + Remediation: Add translated consent evidence or justify why those participant groups are not included in the export. + +### Private participant details leaking through comments +Decision: HOLD +Confidence: 0.1 +Human subjects: true +Comments checked: 1 +Reviewer note: behavioral science: HOLD before export because a locked ethics or consent section has pending collaborative edits without approved override. +- MAJOR LOCKED_ETHICS_SECTION_HAS_PENDING_EDITS: A locked ethics or consent section has pending collaborative edits without approved override. + Remediation: Route pending edits through the section owner or ethics reviewer before export. +- CRITICAL PRIVATE_ETHICS_COMMENT_VISIBLE_IN_EXPORT: A private ethics or reviewer comment is marked visible in the export packet. + Remediation: Redact or remove private comments before creating the public manuscript export. +- CRITICAL PARTICIPANT_IDENTIFIER_IN_COMMENT: A collaborative comment appears to contain a participant, subject, patient, MRN, or DOB marker. + Remediation: Redact participant identifiers and regenerate reviewer-safe comments before export. +- MINOR UNRESOLVED_ETHICS_COMMENT: An ethics-section comment remains unresolved at export time. + Remediation: Resolve or explicitly defer the ethics comment before publication export. + diff --git a/collaborative-ethics-consent-guard/reports/summary.json b/collaborative-ethics-consent-guard/reports/summary.json new file mode 100644 index 00000000..502db089 --- /dev/null +++ b/collaborative-ethics-consent-guard/reports/summary.json @@ -0,0 +1,169 @@ +{ + "generatedAt": "2026-08-15T21:08:58.890Z", + "packetCount": 4, + "decisionCounts": { + "RELEASE": 1, + "REVIEW": 1, + "HOLD": 2 + }, + "reviews": [ + { + "id": "release-clean-consent", + "title": "Clean collaborative consent export", + "decision": "RELEASE", + "confidence": 0.96, + "humanSubjects": true, + "commentCount": 1, + "findingCount": 0, + "findings": [], + "reviewerNote": "public health: ethics, consent, comments, and export scope are publication-ready." + }, + { + "id": "review-stale-vulnerable", + "title": "Vulnerable population review needs refresh", + "decision": "REVIEW", + "confidence": 0.31, + "humanSubjects": true, + "commentCount": 1, + "findingCount": 4, + "findings": [ + { + "code": "VULNERABLE_POPULATION_REVIEW_MISSING", + "severity": "major", + "message": "The manuscript includes vulnerable participant groups without documented enhanced ethics review.", + "evidence": { + "vulnerableGroups": [ + "children" + ] + }, + "remediation": "Add vulnerable-population review evidence and consent safeguards before publication export." + }, + { + "code": "TRANSLATED_CONSENT_COVERAGE_MISSING", + "severity": "major", + "message": "Required consent-language coverage is missing for one or more participant groups.", + "evidence": { + "missingLanguages": [ + "french" + ] + }, + "remediation": "Add translated consent evidence or justify why those participant groups are not included in the export." + }, + { + "code": "STALE_ETHICS_REVIEW", + "severity": "major", + "message": "The last ethics-section review is older than the configured freshness window.", + "evidence": { + "lastEthicsReviewAt": "2025-11-01", + "maxAgeDays": 180 + }, + "remediation": "Refresh ethics reviewer signoff after the latest collaborative edits." + }, + { + "code": "UNRESOLVED_ETHICS_COMMENT", + "severity": "minor", + "message": "An ethics-section comment remains unresolved at export time.", + "evidence": { + "commentId": "c2" + }, + "remediation": "Resolve or explicitly defer the ethics comment before publication export." + } + ], + "reviewerNote": "education research: REVIEW before export because the manuscript includes vulnerable participant groups without documented enhanced ethics review." + }, + { + "id": "hold-missing-approval", + "title": "Human-subjects packet without approval", + "decision": "HOLD", + "confidence": 0.11, + "humanSubjects": true, + "commentCount": 0, + "findingCount": 3, + "findings": [ + { + "code": "MISSING_ETHICS_APPROVAL_ID", + "severity": "critical", + "message": "The manuscript involves human subjects but lacks an ethics approval identifier.", + "evidence": { + "humanSubjects": true, + "approvalId": null + }, + "remediation": "Add the IRB/REC approval identifier or mark the study as exempt with supporting rationale." + }, + { + "code": "MISSING_CONSENT_STATEMENT", + "severity": "critical", + "message": "Consent is required but no consent statement is present in the collaborative manuscript.", + "evidence": { + "consentRequired": true, + "statementPresent": false + }, + "remediation": "Add a consent statement or documented waiver before export." + }, + { + "code": "TRANSLATED_CONSENT_COVERAGE_MISSING", + "severity": "major", + "message": "Required consent-language coverage is missing for one or more participant groups.", + "evidence": { + "missingLanguages": [ + "english" + ] + }, + "remediation": "Add translated consent evidence or justify why those participant groups are not included in the export." + } + ], + "reviewerNote": "clinical methods: HOLD before export because the manuscript involves human subjects but lacks an ethics approval identifier." + }, + { + "id": "hold-private-comment-leak", + "title": "Private participant details leaking through comments", + "decision": "HOLD", + "confidence": 0.1, + "humanSubjects": true, + "commentCount": 1, + "findingCount": 4, + "findings": [ + { + "code": "LOCKED_ETHICS_SECTION_HAS_PENDING_EDITS", + "severity": "major", + "message": "A locked ethics or consent section has pending collaborative edits without approved override.", + "evidence": { + "locked": true, + "pendingEdits": true + }, + "remediation": "Route pending edits through the section owner or ethics reviewer before export." + }, + { + "code": "PRIVATE_ETHICS_COMMENT_VISIBLE_IN_EXPORT", + "severity": "critical", + "message": "A private ethics or reviewer comment is marked visible in the export packet.", + "evidence": { + "commentId": "c3", + "section": "ethics statement" + }, + "remediation": "Redact or remove private comments before creating the public manuscript export." + }, + { + "code": "PARTICIPANT_IDENTIFIER_IN_COMMENT", + "severity": "critical", + "message": "A collaborative comment appears to contain a participant, subject, patient, MRN, or DOB marker.", + "evidence": { + "commentId": "c3", + "section": "ethics statement" + }, + "remediation": "Redact participant identifiers and regenerate reviewer-safe comments before export." + }, + { + "code": "UNRESOLVED_ETHICS_COMMENT", + "severity": "minor", + "message": "An ethics-section comment remains unresolved at export time.", + "evidence": { + "commentId": "c3" + }, + "remediation": "Resolve or explicitly defer the ethics comment before publication export." + } + ], + "reviewerNote": "behavioral science: HOLD before export because a locked ethics or consent section has pending collaborative edits without approved override." + } + ] +} diff --git a/collaborative-ethics-consent-guard/reports/summary.svg b/collaborative-ethics-consent-guard/reports/summary.svg new file mode 100644 index 00000000..c46e4698 --- /dev/null +++ b/collaborative-ethics-consent-guard/reports/summary.svg @@ -0,0 +1,14 @@ + + + Collaborative Ethics and Consent + Export guard for approval IDs, consent scope, translations, private comments, and locked ethics edits. + RELEASE: 1 + + + REVIEW: 1 + + + HOLD: 2 + + + \ No newline at end of file diff --git a/collaborative-ethics-consent-guard/scripts/demo.js b/collaborative-ethics-consent-guard/scripts/demo.js new file mode 100644 index 00000000..e8da0c0d --- /dev/null +++ b/collaborative-ethics-consent-guard/scripts/demo.js @@ -0,0 +1,76 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzePackets } = require("../src"); + +const root = path.resolve(__dirname, ".."); +const packets = JSON.parse(fs.readFileSync(path.join(root, "data", "sample_editor_packets.json"), "utf8")); +const report = analyzePackets(packets); +const outDir = path.join(root, "reports"); + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(report, null, 2)}\n`); +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), renderMarkdown(report)); +fs.writeFileSync(path.join(outDir, "summary.svg"), renderSvg(report)); + +console.log(JSON.stringify({ + packetCount: report.packetCount, + decisionCounts: report.decisionCounts, + reports: [ + "reports/summary.json", + "reports/reviewer-packet.md", + "reports/summary.svg" + ] +}, null, 2)); + +function renderMarkdown(report) { + const lines = [ + "# Collaborative Ethics and Consent Export Review", + "", + `Generated: ${report.generatedAt}`, + "", + "## Decision Counts", + "", + `- RELEASE: ${report.decisionCounts.RELEASE}`, + `- REVIEW: ${report.decisionCounts.REVIEW}`, + `- HOLD: ${report.decisionCounts.HOLD}`, + "", + "## Packet Reviews", + "" + ]; + + for (const review of report.reviews) { + lines.push(`### ${review.title}`); + lines.push(`Decision: ${review.decision}`); + lines.push(`Confidence: ${review.confidence}`); + lines.push(`Human subjects: ${review.humanSubjects}`); + lines.push(`Comments checked: ${review.commentCount}`); + lines.push(`Reviewer note: ${review.reviewerNote}`); + for (const item of review.findings) { + lines.push(`- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`); + lines.push(` Remediation: ${item.remediation}`); + } + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvg(report) { + return ` + + Collaborative Ethics and Consent + Export guard for approval IDs, consent scope, translations, private comments, and locked ethics edits. + ${bar("RELEASE", report.decisionCounts.RELEASE, 48, 148, "#36af8f")} + ${bar("REVIEW", report.decisionCounts.REVIEW, 48, 218, "#d4a235")} + ${bar("HOLD", report.decisionCounts.HOLD, 48, 288, "#d65d73")} +`; +} + +function bar(label, count, x, y, color) { + const width = 96 + count * 116; + return `${label}: ${count} + + `; +} diff --git a/collaborative-ethics-consent-guard/scripts/render-demo-video.js b/collaborative-ethics-consent-guard/scripts/render-demo-video.js new file mode 100644 index 00000000..3e01f6c5 --- /dev/null +++ b/collaborative-ethics-consent-guard/scripts/render-demo-video.js @@ -0,0 +1,60 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const ffmpeg = path.resolve( + root, + "..", + "..", + "tool_downloads", + "video_tools", + "node_modules", + "ffmpeg-static", + "ffmpeg.exe" +); +const outDir = path.join(root, "reports"); +const out = path.join(outDir, "demo.mp4"); +const font = "C\\:/Windows/Fonts/arial.ttf"; + +fs.mkdirSync(outDir, { recursive: true }); + +const draw = [ + `drawtext=fontfile=${font}:text='Collaborative Ethics and Consent':x=64:y=56:fontsize=42:fontcolor=black`, + `drawtext=fontfile=${font}:text='Real-time editor export guard for human-subjects manuscripts':x=64:y=120:fontsize=25:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic editor packets analyzed 4':x=64:y=210:fontsize=34:fontcolor=black`, + `drawtext=fontfile=${font}:text='RELEASE 1 REVIEW 1 HOLD 2':x=64:y=268:fontsize=38:fontcolor=0x245fd6`, + `drawtext=fontfile=${font}:text='Checks approval IDs consent scope translations locked ethics edits':x=64:y=358:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Flags participant identifiers private comments and expired approvals':x=64:y=422:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic generated slate only no desktop capture or private data':x=64:y=486:fontsize=26:fontcolor=0x34413b` +].join(","); + +if (!fs.existsSync(ffmpeg)) { + throw new Error(`ffmpeg binary not found at ${ffmpeg}`); +} + +const result = spawnSync(ffmpeg, [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0xf9faf8:s=1280x720:d=8:r=30", + "-vf", + draw, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + out +], { encoding: "utf8" }); + +if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(result.status || 1); +} + +console.log(JSON.stringify({ out, bytes: fs.statSync(out).size }, null, 2)); diff --git a/collaborative-ethics-consent-guard/src/index.js b/collaborative-ethics-consent-guard/src/index.js new file mode 100644 index 00000000..bbc17263 --- /dev/null +++ b/collaborative-ethics-consent-guard/src/index.js @@ -0,0 +1,297 @@ +"use strict"; + +const PARTICIPANT_MARKERS = [ + /\bparticipant\s+\d{2,}\b/i, + /\bsubject\s+\d{2,}\b/i, + /\bpatient\s+\d{2,}\b/i, + /\bmrn[:\s-]*\d{4,}\b/i, + /\bdob[:\s-]*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/i +]; + +const VULNERABLE_GROUPS = new Set([ + "children", + "minors", + "incarcerated people", + "pregnant participants", + "cognitively impaired participants", + "indigenous communities" +]); + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function normalizeText(value) { + return String(value || "").trim().toLowerCase(); +} + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function hasParticipantMarker(value) { + const text = String(value || ""); + return PARTICIPANT_MARKERS.some((pattern) => pattern.test(text)); +} + +function parseDate(value) { + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time) : null; +} + +function isOlderThanDays(dateValue, referenceValue, maxAgeDays) { + const date = parseDate(dateValue); + const reference = parseDate(referenceValue); + if (!date || !reference) return false; + return (reference.getTime() - date.getTime()) / 86400000 > maxAgeDays; +} + +function normalizePacket(packet) { + return { + id: packet.id, + title: packet.title || "Untitled collaborative manuscript packet", + reviewDate: packet.reviewDate || "2026-08-15", + manuscript: packet.manuscript || {}, + ethics: packet.ethics || {}, + consent: packet.consent || {}, + collaborativeState: packet.collaborativeState || {}, + comments: list(packet.comments), + exportTarget: packet.exportTarget || {} + }; +} + +function analyzeEthics(packet) { + const findings = []; + const ethics = packet.ethics; + const manuscript = packet.manuscript; + + if (manuscript.humanSubjects === true && !ethics.approvalId) { + findings.push(finding( + "MISSING_ETHICS_APPROVAL_ID", + "critical", + "The manuscript involves human subjects but lacks an ethics approval identifier.", + { humanSubjects: manuscript.humanSubjects, approvalId: ethics.approvalId || null }, + "Add the IRB/REC approval identifier or mark the study as exempt with supporting rationale." + )); + } + + if (ethics.exempt === true && !ethics.exemptionRationale) { + findings.push(finding( + "MISSING_EXEMPTION_RATIONALE", + "major", + "The packet marks the study exempt but does not include an exemption rationale.", + { exempt: ethics.exempt }, + "Add the exemption category, approving body, and rationale before export." + )); + } + + if (ethics.approvalId && ethics.expiryDate && parseDate(ethics.expiryDate) && parseDate(ethics.expiryDate) < parseDate(packet.reviewDate)) { + findings.push(finding( + "ETHICS_APPROVAL_EXPIRED", + "critical", + "The ethics approval appears expired before the export review date.", + { approvalId: ethics.approvalId, expiryDate: ethics.expiryDate, reviewDate: packet.reviewDate }, + "Hold export until renewed approval or a no-further-review determination is documented." + )); + } + + const participantGroups = list(manuscript.participantGroups).map(normalizeText); + const vulnerable = participantGroups.filter((group) => VULNERABLE_GROUPS.has(group)); + if (vulnerable.length > 0 && ethics.vulnerablePopulationReview !== true) { + findings.push(finding( + "VULNERABLE_POPULATION_REVIEW_MISSING", + "major", + "The manuscript includes vulnerable participant groups without documented enhanced ethics review.", + { vulnerableGroups: vulnerable }, + "Add vulnerable-population review evidence and consent safeguards before publication export." + )); + } + + return findings; +} + +function analyzeConsent(packet) { + const findings = []; + const consent = packet.consent; + const manuscript = packet.manuscript; + const target = packet.exportTarget; + + if (manuscript.humanSubjects === true && consent.required === true && consent.statementPresent !== true) { + findings.push(finding( + "MISSING_CONSENT_STATEMENT", + "critical", + "Consent is required but no consent statement is present in the collaborative manuscript.", + { consentRequired: consent.required, statementPresent: consent.statementPresent || false }, + "Add a consent statement or documented waiver before export." + )); + } + + if (consent.required === false && !consent.waiverReason && manuscript.humanSubjects === true) { + findings.push(finding( + "CONSENT_WAIVER_REASON_MISSING", + "major", + "The packet says consent is not required but provides no waiver reason.", + { consentRequired: consent.required, waiverReason: consent.waiverReason || null }, + "Add the consent waiver rationale and approving body." + )); + } + + if (consent.statementPresent === true && consent.scope && target.releaseScope && normalizeText(consent.scope) !== normalizeText(target.releaseScope)) { + findings.push(finding( + "CONSENT_SCOPE_EXPORT_MISMATCH", + "major", + "The consent statement scope does not match the requested export scope.", + { consentScope: consent.scope, exportScope: target.releaseScope }, + "Update the export target or document consent coverage for the publication scope." + )); + } + + const requiredLanguages = list(consent.requiredLanguages).map(normalizeText); + const presentLanguages = list(consent.presentLanguages).map(normalizeText); + const missingLanguages = requiredLanguages.filter((language) => !presentLanguages.includes(language)); + if (missingLanguages.length > 0) { + findings.push(finding( + "TRANSLATED_CONSENT_COVERAGE_MISSING", + "major", + "Required consent-language coverage is missing for one or more participant groups.", + { missingLanguages }, + "Add translated consent evidence or justify why those participant groups are not included in the export." + )); + } + + return findings; +} + +function analyzeCollaboration(packet) { + const findings = []; + const state = packet.collaborativeState; + const ethicsSection = state.ethicsSection || {}; + + if (ethicsSection.locked === true && ethicsSection.pendingEdits === true && ethicsSection.approvedOverride !== true) { + findings.push(finding( + "LOCKED_ETHICS_SECTION_HAS_PENDING_EDITS", + "major", + "A locked ethics or consent section has pending collaborative edits without approved override.", + { locked: ethicsSection.locked, pendingEdits: ethicsSection.pendingEdits }, + "Route pending edits through the section owner or ethics reviewer before export." + )); + } + + if (state.lastEthicsReviewAt && isOlderThanDays(state.lastEthicsReviewAt, packet.reviewDate, 180)) { + findings.push(finding( + "STALE_ETHICS_REVIEW", + "major", + "The last ethics-section review is older than the configured freshness window.", + { lastEthicsReviewAt: state.lastEthicsReviewAt, maxAgeDays: 180 }, + "Refresh ethics reviewer signoff after the latest collaborative edits." + )); + } + + for (const comment of packet.comments) { + if (comment.private === true && comment.exportVisible === true) { + findings.push(finding( + "PRIVATE_ETHICS_COMMENT_VISIBLE_IN_EXPORT", + "critical", + "A private ethics or reviewer comment is marked visible in the export packet.", + { commentId: comment.id || null, section: comment.section || null }, + "Redact or remove private comments before creating the public manuscript export." + )); + } + + if (hasParticipantMarker(comment.text)) { + findings.push(finding( + "PARTICIPANT_IDENTIFIER_IN_COMMENT", + "critical", + "A collaborative comment appears to contain a participant, subject, patient, MRN, or DOB marker.", + { commentId: comment.id || null, section: comment.section || null }, + "Redact participant identifiers and regenerate reviewer-safe comments before export." + )); + } + + if (comment.resolved !== true && normalizeText(comment.section).includes("ethics")) { + findings.push(finding( + "UNRESOLVED_ETHICS_COMMENT", + "minor", + "An ethics-section comment remains unresolved at export time.", + { commentId: comment.id || null }, + "Resolve or explicitly defer the ethics comment before publication export." + )); + } + } + + return findings; +} + +function severityWeight(severity) { + if (severity === "critical") return 5; + if (severity === "major") return 3; + return 1; +} + +function decisionFromFindings(findings) { + if (findings.some((item) => item.severity === "critical")) return "HOLD"; + if (findings.length > 0) return "REVIEW"; + return "RELEASE"; +} + +function confidenceFromFindings(findings) { + const penalty = findings.reduce((total, item) => total + severityWeight(item.severity), 0); + return Math.max(0.1, Number((0.96 - penalty * 0.065).toFixed(2))); +} + +function reviewerNote(packet, decision, findings) { + if (decision === "RELEASE") { + return `${packet.manuscript.domain || "collaborative manuscript"}: ethics, consent, comments, and export scope are publication-ready.`; + } + const first = findings[0]; + return `${packet.manuscript.domain || "collaborative manuscript"}: ${decision} before export because ${first.message.toLowerCase()}`; +} + +function analyzePacket(input) { + const packet = normalizePacket(input); + const findings = [ + ...analyzeEthics(packet), + ...analyzeConsent(packet), + ...analyzeCollaboration(packet) + ]; + const decision = decisionFromFindings(findings); + + return { + id: packet.id, + title: packet.title, + decision, + confidence: confidenceFromFindings(findings), + humanSubjects: packet.manuscript.humanSubjects === true, + commentCount: packet.comments.length, + findingCount: findings.length, + findings, + reviewerNote: reviewerNote(packet, decision, findings) + }; +} + +function analyzePackets(packets) { + const reviews = packets.map(analyzePacket); + const counts = reviews.reduce((acc, review) => { + acc[review.decision] = (acc[review.decision] || 0) + 1; + return acc; + }, {}); + + return { + generatedAt: new Date().toISOString(), + packetCount: reviews.length, + decisionCounts: { + RELEASE: counts.RELEASE || 0, + REVIEW: counts.REVIEW || 0, + HOLD: counts.HOLD || 0 + }, + reviews + }; +} + +module.exports = { + PARTICIPANT_MARKERS, + analyzePacket, + analyzePackets, + decisionFromFindings, + hasParticipantMarker +}; diff --git a/collaborative-ethics-consent-guard/test/collaborative-ethics-consent-guard.test.js b/collaborative-ethics-consent-guard/test/collaborative-ethics-consent-guard.test.js new file mode 100644 index 00000000..31418fa2 --- /dev/null +++ b/collaborative-ethics-consent-guard/test/collaborative-ethics-consent-guard.test.js @@ -0,0 +1,148 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { analyzePacket, analyzePackets, decisionFromFindings, hasParticipantMarker } = require("../src"); + +test("detects participant-like identifiers in collaborative comments", () => { + assert.equal(hasParticipantMarker("participant 104 withdrew"), true); + assert.equal(hasParticipantMarker("ethics wording reviewed"), false); +}); + +test("releases complete ethics and consent export packets", () => { + const review = analyzePacket({ + id: "clean", + title: "Clean packet", + reviewDate: "2026-08-15", + manuscript: { humanSubjects: true, participantGroups: ["adults"], domain: "public health" }, + ethics: { approvalId: "IRB-1", expiryDate: "2027-01-01" }, + consent: { + required: true, + statementPresent: true, + scope: "journal publication", + requiredLanguages: ["English"], + presentLanguages: ["English"] + }, + collaborativeState: { + lastEthicsReviewAt: "2026-08-01", + ethicsSection: { locked: true, pendingEdits: false } + }, + comments: [{ section: "ethics", resolved: true, text: "ready" }], + exportTarget: { releaseScope: "journal publication" } + }); + + assert.equal(review.decision, "RELEASE"); + assert.equal(review.findingCount, 0); +}); + +test("holds human-subjects manuscript without approval or consent", () => { + const review = analyzePacket({ + id: "missing", + reviewDate: "2026-08-15", + manuscript: { humanSubjects: true, participantGroups: ["adults"] }, + ethics: {}, + consent: { required: true, statementPresent: false }, + collaborativeState: {}, + comments: [], + exportTarget: {} + }); + + assert.equal(review.decision, "HOLD"); + assert.ok(review.findings.some((item) => item.code === "MISSING_ETHICS_APPROVAL_ID")); + assert.ok(review.findings.some((item) => item.code === "MISSING_CONSENT_STATEMENT")); +}); + +test("routes vulnerable population and stale review gaps to review", () => { + const review = analyzePacket({ + id: "review", + reviewDate: "2026-08-15", + manuscript: { humanSubjects: true, participantGroups: ["children"] }, + ethics: { approvalId: "REC-1", expiryDate: "2027-01-01", vulnerablePopulationReview: false }, + consent: { + required: true, + statementPresent: true, + scope: "conference abstract", + requiredLanguages: ["English", "French"], + presentLanguages: ["English"] + }, + collaborativeState: { + lastEthicsReviewAt: "2025-11-01", + ethicsSection: { locked: true, pendingEdits: false } + }, + comments: [{ id: "c1", section: "ethics", resolved: false, text: "Need language proof" }], + exportTarget: { releaseScope: "conference abstract" } + }); + + assert.equal(review.decision, "REVIEW"); + assert.ok(review.findings.some((item) => item.code === "VULNERABLE_POPULATION_REVIEW_MISSING")); + assert.ok(review.findings.some((item) => item.code === "TRANSLATED_CONSENT_COVERAGE_MISSING")); + assert.ok(review.findings.some((item) => item.code === "STALE_ETHICS_REVIEW")); +}); + +test("holds private comments and participant identifiers visible in export", () => { + const review = analyzePacket({ + id: "private", + reviewDate: "2026-08-15", + manuscript: { humanSubjects: true, participantGroups: ["adults"] }, + ethics: { approvalId: "IRB-2", expiryDate: "2027-01-01" }, + consent: { + required: true, + statementPresent: true, + scope: "journal publication", + requiredLanguages: ["English"], + presentLanguages: ["English"] + }, + collaborativeState: { + lastEthicsReviewAt: "2026-08-01", + ethicsSection: { locked: true, pendingEdits: true, approvedOverride: false } + }, + comments: [ + { + id: "c2", + section: "ethics", + private: true, + exportVisible: true, + resolved: false, + text: "participant 104 has DOB: 02/04/1999" + } + ], + exportTarget: { releaseScope: "journal publication" } + }); + + assert.equal(review.decision, "HOLD"); + assert.ok(review.findings.some((item) => item.code === "PRIVATE_ETHICS_COMMENT_VISIBLE_IN_EXPORT")); + assert.ok(review.findings.some((item) => item.code === "PARTICIPANT_IDENTIFIER_IN_COMMENT")); +}); + +test("summarizes packet decisions deterministically", () => { + const report = analyzePackets([ + { + id: "release", + manuscript: { humanSubjects: false }, + ethics: {}, + consent: {}, + collaborativeState: {}, + comments: [], + exportTarget: {} + }, + { + id: "hold", + manuscript: { humanSubjects: true }, + ethics: {}, + consent: { required: true, statementPresent: false }, + collaborativeState: {}, + comments: [], + exportTarget: {} + } + ]); + + assert.equal(report.packetCount, 2); + assert.equal(report.decisionCounts.RELEASE, 1); + assert.equal(report.decisionCounts.HOLD, 1); +}); + +test("decision helper maps severity to release, review, hold", () => { + assert.equal(decisionFromFindings([]), "RELEASE"); + assert.equal(decisionFromFindings([{ severity: "minor" }]), "REVIEW"); + assert.equal(decisionFromFindings([{ severity: "critical" }]), "HOLD"); +});