From 39db748bebe0bdf9fa904def8e6046bd7ec2bd50 Mon Sep 17 00:00:00 2001 From: Telemark Codex Agent Date: Sat, 15 Aug 2026 23:09:47 +0200 Subject: [PATCH] Add collaborative ethics consent guard --- collaborative-ethics-consent-guard/README.md | 57 ++++ .../data/sample_editor_packets.json | 162 ++++++++++ .../package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 52889 bytes .../reports/reviewer-packet.md | 62 ++++ .../reports/summary.json | 169 ++++++++++ .../reports/summary.svg | 14 + .../scripts/demo.js | 76 +++++ .../scripts/render-demo-video.js | 60 ++++ .../src/index.js | 297 ++++++++++++++++++ ...collaborative-ethics-consent-guard.test.js | 148 +++++++++ 11 files changed, 1058 insertions(+) create mode 100644 collaborative-ethics-consent-guard/README.md create mode 100644 collaborative-ethics-consent-guard/data/sample_editor_packets.json create mode 100644 collaborative-ethics-consent-guard/package.json create mode 100644 collaborative-ethics-consent-guard/reports/demo.mp4 create mode 100644 collaborative-ethics-consent-guard/reports/reviewer-packet.md create mode 100644 collaborative-ethics-consent-guard/reports/summary.json create mode 100644 collaborative-ethics-consent-guard/reports/summary.svg create mode 100644 collaborative-ethics-consent-guard/scripts/demo.js create mode 100644 collaborative-ethics-consent-guard/scripts/render-demo-video.js create mode 100644 collaborative-ethics-consent-guard/src/index.js create mode 100644 collaborative-ethics-consent-guard/test/collaborative-ethics-consent-guard.test.js 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 0000000000000000000000000000000000000000..2818b1b3c576371970abb593ed61bde97a00e462 GIT binary patch literal 52889 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%0Dgx)&}NRddF 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 literal 0 HcmV?d00001 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"); +});