diff --git a/review-retaliation-guard/README.md b/review-retaliation-guard/README.md new file mode 100644 index 00000000..03d522ea --- /dev/null +++ b/review-retaliation-guard/README.md @@ -0,0 +1,47 @@ +# Review Retaliation Guard + +This contribution adds a focused guardrail for SCIBASE's Community & User Reputation System. It detects reputation actions that may punish a reviewer after an unfavorable manuscript review, freezes unsafe public reputation exports, and produces an evidence packet for independent moderation. + +The slice is intentionally narrow: it does not build a full reputation system. It covers the trust gap between peer-review outcomes and reputation changes, where author-affiliated actors could downvote, remove badges, withdraw endorsements, or reduce profile visibility after a critical review. + +## What It Checks + +- Whether the underlying peer review is critical through recommendation, rating, tone, or criticality score. +- Whether a punitive reputation event targets the reviewer inside a configurable retaliation window. +- Whether the actor is affiliated with the manuscript, lab, institution, funder, or study team. +- Whether multiple affiliated actors create a clustered penalty burst. +- Whether messages contain coercive language asking the reviewer to soften or revise criticism. +- Whether public profile or leaderboard exports are still enabled while unresolved retaliation risk exists. +- Whether the reviewer has independent moderation and a meaningful appeal window before reputation deltas apply. + +## Running Locally + +```bash +npm test +npm run demo +npm run check +npm run video +``` + +The demo uses only synthetic sample packets in `data/sample_reputation_events.json`. + +Generated files: + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +The MP4 is generated from an FFmpeg color source and drawtext slate. It is not a screen recording and contains no desktop capture or private information. + +## Why This Matters + +Reputation systems can unintentionally chill rigorous peer review when negative reputation changes are allowed to follow critical reviews without context. A reviewer who recommends rejection should not lose badges, endorsements, public visibility, or leaderboard standing because authors or affiliated collaborators dislike the review outcome. + +This guard gives SCIBASE an auditable path: + +1. Freeze reputation deltas tied to the flagged review. +2. Preserve event evidence and coercive messages. +3. Require independent moderator review. +4. Notify the affected reviewer of appeal options. +5. Resume public reputation export only after resolution. diff --git a/review-retaliation-guard/data/sample_reputation_events.json b/review-retaliation-guard/data/sample_reputation_events.json new file mode 100644 index 00000000..f3dce3a1 --- /dev/null +++ b/review-retaliation-guard/data/sample_reputation_events.json @@ -0,0 +1,156 @@ +[ + { + "id": "rep-clear-001", + "title": "Critical review with independent reputation hold", + "reviewDate": "2026-08-01", + "review": { + "reviewerId": "reviewer-17", + "recommendation": "major_revision", + "rating": 2, + "criticalityScore": 0.82 + }, + "manuscript": { + "authors": ["author-a", "author-b"], + "institutions": ["North Ridge Lab"] + }, + "reputationEvents": [ + { + "id": "evt-001", + "createdAt": "2026-08-04", + "actor": "moderator-3", + "actorRelation": "independent_moderator", + "targetReviewer": "reviewer-17", + "action": "audit_hold", + "reputationDelta": 0, + "reason": "Automatic freeze while author response is reviewed." + } + ], + "safeguards": { + "retaliationWindowDays": 35, + "independentModeratorReview": true, + "appealWindowDays": 21, + "freezePublicExport": true + }, + "exportTarget": { + "publicProfileExport": false + } + }, + { + "id": "rep-hold-002", + "title": "Author-affiliated penalties after rejection recommendation", + "reviewDate": "2026-08-02", + "review": { + "reviewerId": "reviewer-88", + "recommendation": "reject", + "rating": 1, + "criticalityScore": 0.95 + }, + "manuscript": { + "authors": ["author-c", "author-d"], + "institutions": ["Cedar Clinical Center"] + }, + "reputationEvents": [ + { + "id": "evt-011", + "createdAt": "2026-08-05", + "actor": "author-c", + "actorRelation": "author", + "targetReviewer": "reviewer-88", + "action": "downvote", + "reputationDelta": -18, + "reason": "Low quality review." + }, + { + "id": "evt-012", + "createdAt": "2026-08-06", + "actor": "lab-manager-c", + "actorRelation": "same_lab", + "targetReviewer": "reviewer-88", + "action": "badge_removed", + "reputationDelta": -7, + "reason": "Community confidence issue." + } + ], + "safeguards": { + "retaliationWindowDays": 35, + "independentModeratorReview": false, + "appealWindowDays": 7, + "freezePublicExport": false + }, + "exportTarget": { + "publicProfileExport": true + } + }, + { + "id": "rep-hold-003", + "title": "Coercive endorsement withdrawal tied to review revision", + "reviewDate": "2026-08-03", + "review": { + "reviewerId": "reviewer-42", + "recommendation": "major revisions", + "rating": 2, + "tone": "critical" + }, + "manuscript": { + "authors": ["author-q"], + "institutions": ["East Harbor University"] + }, + "reputationEvents": [ + { + "id": "evt-021", + "createdAt": "2026-08-04", + "actor": "author-q", + "actorRelation": "author", + "targetReviewer": "reviewer-42", + "action": "endorsement_withdrawn", + "reputationDelta": -12, + "message": "Unless you revise the review and remove the negative comment, your reputation will drop." + } + ], + "safeguards": { + "retaliationWindowDays": 35, + "independentModeratorReview": false, + "appealWindowDays": 0, + "freezePublicExport": false + }, + "exportTarget": { + "publicProfileExport": true + } + }, + { + "id": "rep-clear-004", + "title": "Unrelated old moderation action outside review window", + "reviewDate": "2026-08-10", + "review": { + "reviewerId": "reviewer-9", + "recommendation": "accept", + "rating": 5, + "criticalityScore": 0.1 + }, + "manuscript": { + "authors": ["author-z"], + "institutions": ["Lakeview Bioinformatics"] + }, + "reputationEvents": [ + { + "id": "evt-031", + "createdAt": "2026-07-01", + "actor": "trust-and-safety", + "actorRelation": "independent_moderator", + "targetReviewer": "reviewer-9", + "action": "account_flag", + "reputationDelta": -3, + "reason": "Old spam duplicate unrelated to manuscript review." + } + ], + "safeguards": { + "retaliationWindowDays": 35, + "independentModeratorReview": true, + "appealWindowDays": 21, + "freezePublicExport": true + }, + "exportTarget": { + "publicProfileExport": false + } + } +] diff --git a/review-retaliation-guard/package.json b/review-retaliation-guard/package.json new file mode 100644 index 00000000..c9e96d0e --- /dev/null +++ b/review-retaliation-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "review-retaliation-guard", + "version": "1.0.0", + "description": "Dependency-free reputation guard for detecting retaliation after critical peer review.", + "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/review-retaliation-guard/reports/demo.mp4 b/review-retaliation-guard/reports/demo.mp4 new file mode 100644 index 00000000..7270fc5d Binary files /dev/null and b/review-retaliation-guard/reports/demo.mp4 differ diff --git a/review-retaliation-guard/reports/reviewer-packet.md b/review-retaliation-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..9beef837 --- /dev/null +++ b/review-retaliation-guard/reports/reviewer-packet.md @@ -0,0 +1,72 @@ +# Review Retaliation Guard Report + +Generated: 2026-08-15T21:37:04.968Z +Packets analyzed: 4 +Decision counts: CLEAR 2, REVIEW 0, HOLD 2 + +## Findings + +### rep-clear-001: Critical review with independent reputation hold + +Decision: CLEAR +Risk score: 0 +Critical review: yes +Suspicious events: 0 + +- No retaliation risk detected. + +Recommended actions: +- Apply reputation changes with normal audit logging. + +### rep-hold-002: Author-affiliated penalties after rejection recommendation + +Decision: HOLD +Risk score: 100 +Critical review: yes +Suspicious events: 2 + +- CRITICAL AFFILIATED_PUNITIVE_ACTION_AFTER_CRITICAL_REVIEW: An author-affiliated actor applied a punitive reputation action shortly after a critical review. +- CRITICAL AFFILIATED_PUNITIVE_ACTION_AFTER_CRITICAL_REVIEW: An author-affiliated actor applied a punitive reputation action shortly after a critical review. +- CRITICAL RECIPROCAL_OR_CLUSTERED_RETALIATION_BURST: Multiple affiliated actors targeted the same reviewer within the retaliation window. +- MAJOR PROFILE_CREDIT_REMOVED_AFTER_UNFAVORABLE_REVIEW: A public-facing badge, endorsement, role, or visibility signal was reduced after an unfavorable review. +- MAJOR INDEPENDENT_REVIEW_MISSING_FOR_RETALIATION_RISK: The packet has retaliation risk but no independent moderator review recorded. +- MAJOR APPEAL_WINDOW_TOO_SHORT_FOR_REPUTATION_DELTA: The reviewer has insufficient time to appeal a reputation-impacting action. +- CRITICAL PUBLIC_REPUTATION_EXPORT_NOT_FROZEN: Public reputation export is enabled while unresolved retaliation risk exists. + +Recommended actions: +- Disable leaderboard and public profile export for the affected reviewer until resolution. +- Freeze reputation deltas tied to the flagged review. +- Generate an evidence packet for an independent moderator. +- Notify the affected reviewer of the appeal pathway. + +### rep-hold-003: Coercive endorsement withdrawal tied to review revision + +Decision: HOLD +Risk score: 100 +Critical review: yes +Suspicious events: 1 + +- CRITICAL AFFILIATED_PUNITIVE_ACTION_AFTER_CRITICAL_REVIEW: An author-affiliated actor applied a punitive reputation action shortly after a critical review. +- CRITICAL COERCIVE_REVIEW_REVISION_SIGNAL: A reputation event contains language that appears to pressure the reviewer to soften or alter criticism. +- MAJOR PROFILE_CREDIT_REMOVED_AFTER_UNFAVORABLE_REVIEW: A public-facing badge, endorsement, role, or visibility signal was reduced after an unfavorable review. +- MAJOR INDEPENDENT_REVIEW_MISSING_FOR_RETALIATION_RISK: The packet has retaliation risk but no independent moderator review recorded. +- MAJOR APPEAL_WINDOW_TOO_SHORT_FOR_REPUTATION_DELTA: The reviewer has insufficient time to appeal a reputation-impacting action. +- CRITICAL PUBLIC_REPUTATION_EXPORT_NOT_FROZEN: Public reputation export is enabled while unresolved retaliation risk exists. + +Recommended actions: +- Disable leaderboard and public profile export for the affected reviewer until resolution. +- Freeze reputation deltas tied to the flagged review. +- Generate an evidence packet for an independent moderator. +- Notify the affected reviewer of the appeal pathway. + +### rep-clear-004: Unrelated old moderation action outside review window + +Decision: CLEAR +Risk score: 0 +Critical review: no +Suspicious events: 0 + +- No retaliation risk detected. + +Recommended actions: +- Apply reputation changes with normal audit logging. diff --git a/review-retaliation-guard/reports/summary.json b/review-retaliation-guard/reports/summary.json new file mode 100644 index 00000000..de06478f --- /dev/null +++ b/review-retaliation-guard/reports/summary.json @@ -0,0 +1,241 @@ +{ + "generatedAt": "2026-08-15T21:37:04.968Z", + "totalPackets": 4, + "counts": { + "CLEAR": 2, + "REVIEW": 0, + "HOLD": 2 + }, + "results": [ + { + "id": "rep-clear-001", + "title": "Critical review with independent reputation hold", + "reviewerId": "reviewer-17", + "criticalReview": true, + "retaliationWindowDays": 35, + "decision": "CLEAR", + "riskScore": 0, + "findings": [], + "suspiciousEventCount": 0, + "relevantEventCount": 1, + "recommendedActions": [ + "Apply reputation changes with normal audit logging." + ] + }, + { + "id": "rep-hold-002", + "title": "Author-affiliated penalties after rejection recommendation", + "reviewerId": "reviewer-88", + "criticalReview": true, + "retaliationWindowDays": 35, + "decision": "HOLD", + "riskScore": 100, + "findings": [ + { + "code": "AFFILIATED_PUNITIVE_ACTION_AFTER_CRITICAL_REVIEW", + "severity": "critical", + "message": "An author-affiliated actor applied a punitive reputation action shortly after a critical review.", + "evidence": { + "action": "downvote", + "actor": "author-c", + "relation": "author", + "targetReviewer": "reviewer-88", + "daysAfterReview": 3, + "reputationDelta": -18 + }, + "remediation": "Freeze the reputation delta and route the action to an independent moderator before publication or profile export." + }, + { + "code": "AFFILIATED_PUNITIVE_ACTION_AFTER_CRITICAL_REVIEW", + "severity": "critical", + "message": "An author-affiliated actor applied a punitive reputation action shortly after a critical review.", + "evidence": { + "action": "badge_removed", + "actor": "lab-manager-c", + "relation": "same_lab", + "targetReviewer": "reviewer-88", + "daysAfterReview": 4, + "reputationDelta": -7 + }, + "remediation": "Freeze the reputation delta and route the action to an independent moderator before publication or profile export." + }, + { + "code": "RECIPROCAL_OR_CLUSTERED_RETALIATION_BURST", + "severity": "critical", + "message": "Multiple affiliated actors targeted the same reviewer within the retaliation window.", + "evidence": { + "affectedReviewer": "reviewer-88", + "distinctActors": 2, + "events": [ + { + "actor": "author-c", + "action": "downvote", + "daysAfterReview": 3 + }, + { + "actor": "lab-manager-c", + "action": "badge_removed", + "daysAfterReview": 4 + } + ] + }, + "remediation": "Escalate as a retaliation cluster, suspend automated scoring, and require independent adjudication." + }, + { + "code": "PROFILE_CREDIT_REMOVED_AFTER_UNFAVORABLE_REVIEW", + "severity": "major", + "message": "A public-facing badge, endorsement, role, or visibility signal was reduced after an unfavorable review.", + "evidence": { + "events": [ + { + "actor": "lab-manager-c", + "action": "badge_removed", + "daysAfterReview": 4 + } + ] + }, + "remediation": "Hold public profile updates until the action is justified by evidence unrelated to the review outcome." + }, + { + "code": "INDEPENDENT_REVIEW_MISSING_FOR_RETALIATION_RISK", + "severity": "major", + "message": "The packet has retaliation risk but no independent moderator review recorded.", + "evidence": { + "independentModeratorReview": false + }, + "remediation": "Add independent moderator signoff before applying the reputation change." + }, + { + "code": "APPEAL_WINDOW_TOO_SHORT_FOR_REPUTATION_DELTA", + "severity": "major", + "message": "The reviewer has insufficient time to appeal a reputation-impacting action.", + "evidence": { + "appealWindowDays": 7 + }, + "remediation": "Provide at least a 14-day appeal window and show the evidence packet to the affected reviewer." + }, + { + "code": "PUBLIC_REPUTATION_EXPORT_NOT_FROZEN", + "severity": "critical", + "message": "Public reputation export is enabled while unresolved retaliation risk exists.", + "evidence": { + "publicProfileExport": true, + "freezePublicExport": false + }, + "remediation": "Freeze the public export and leaderboard deltas until the retaliation review is resolved." + } + ], + "suspiciousEventCount": 2, + "relevantEventCount": 2, + "recommendedActions": [ + "Disable leaderboard and public profile export for the affected reviewer until resolution.", + "Freeze reputation deltas tied to the flagged review.", + "Generate an evidence packet for an independent moderator.", + "Notify the affected reviewer of the appeal pathway." + ] + }, + { + "id": "rep-hold-003", + "title": "Coercive endorsement withdrawal tied to review revision", + "reviewerId": "reviewer-42", + "criticalReview": true, + "retaliationWindowDays": 35, + "decision": "HOLD", + "riskScore": 100, + "findings": [ + { + "code": "AFFILIATED_PUNITIVE_ACTION_AFTER_CRITICAL_REVIEW", + "severity": "critical", + "message": "An author-affiliated actor applied a punitive reputation action shortly after a critical review.", + "evidence": { + "action": "endorsement_withdrawn", + "actor": "author-q", + "relation": "author", + "targetReviewer": "reviewer-42", + "daysAfterReview": 1, + "reputationDelta": -12 + }, + "remediation": "Freeze the reputation delta and route the action to an independent moderator before publication or profile export." + }, + { + "code": "COERCIVE_REVIEW_REVISION_SIGNAL", + "severity": "critical", + "message": "A reputation event contains language that appears to pressure the reviewer to soften or alter criticism.", + "evidence": { + "actor": "author-q", + "action": "endorsement_withdrawn", + "daysAfterReview": 1, + "excerpt": "Unless you revise the review and remove the negative comment, your reputation will drop." + }, + "remediation": "Separate reputation handling from manuscript-review negotiation and preserve the message for moderator review." + }, + { + "code": "PROFILE_CREDIT_REMOVED_AFTER_UNFAVORABLE_REVIEW", + "severity": "major", + "message": "A public-facing badge, endorsement, role, or visibility signal was reduced after an unfavorable review.", + "evidence": { + "events": [ + { + "actor": "author-q", + "action": "endorsement_withdrawn", + "daysAfterReview": 1 + } + ] + }, + "remediation": "Hold public profile updates until the action is justified by evidence unrelated to the review outcome." + }, + { + "code": "INDEPENDENT_REVIEW_MISSING_FOR_RETALIATION_RISK", + "severity": "major", + "message": "The packet has retaliation risk but no independent moderator review recorded.", + "evidence": { + "independentModeratorReview": false + }, + "remediation": "Add independent moderator signoff before applying the reputation change." + }, + { + "code": "APPEAL_WINDOW_TOO_SHORT_FOR_REPUTATION_DELTA", + "severity": "major", + "message": "The reviewer has insufficient time to appeal a reputation-impacting action.", + "evidence": { + "appealWindowDays": 0 + }, + "remediation": "Provide at least a 14-day appeal window and show the evidence packet to the affected reviewer." + }, + { + "code": "PUBLIC_REPUTATION_EXPORT_NOT_FROZEN", + "severity": "critical", + "message": "Public reputation export is enabled while unresolved retaliation risk exists.", + "evidence": { + "publicProfileExport": true, + "freezePublicExport": false + }, + "remediation": "Freeze the public export and leaderboard deltas until the retaliation review is resolved." + } + ], + "suspiciousEventCount": 1, + "relevantEventCount": 1, + "recommendedActions": [ + "Disable leaderboard and public profile export for the affected reviewer until resolution.", + "Freeze reputation deltas tied to the flagged review.", + "Generate an evidence packet for an independent moderator.", + "Notify the affected reviewer of the appeal pathway." + ] + }, + { + "id": "rep-clear-004", + "title": "Unrelated old moderation action outside review window", + "reviewerId": "reviewer-9", + "criticalReview": false, + "retaliationWindowDays": 35, + "decision": "CLEAR", + "riskScore": 0, + "findings": [], + "suspiciousEventCount": 0, + "relevantEventCount": 1, + "recommendedActions": [ + "Apply reputation changes with normal audit logging." + ] + } + ] +} diff --git a/review-retaliation-guard/reports/summary.svg b/review-retaliation-guard/reports/summary.svg new file mode 100644 index 00000000..bee23f6b --- /dev/null +++ b/review-retaliation-guard/reports/summary.svg @@ -0,0 +1,17 @@ + + + Review Retaliation Guard + Synthetic reputation-event packet audit for SCIBASE community trust. + Decision distribution + HOLD + + 2 + REVIEW + + 0 + CLEAR + + 2 + Flags affiliated punitive actions, coercive messages, clustered retaliation, and unsafe public profile export. + Synthetic generated artifact only. No desktop capture or private data. + diff --git a/review-retaliation-guard/scripts/demo.js b/review-retaliation-guard/scripts/demo.js new file mode 100644 index 00000000..d0b2ab07 --- /dev/null +++ b/review-retaliation-guard/scripts/demo.js @@ -0,0 +1,82 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzeReputationPackets } = require("../src"); + +const root = path.resolve(__dirname, ".."); +const dataPath = path.join(root, "data", "sample_reputation_events.json"); +const outDir = path.join(root, "reports"); +const packets = JSON.parse(fs.readFileSync(dataPath, "utf8")); +const report = analyzeReputationPackets(packets); + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(report, null, 2)}\n`); + +const markdown = [ + "# Review Retaliation Guard Report", + "", + `Generated: ${report.generatedAt}`, + `Packets analyzed: ${report.totalPackets}`, + `Decision counts: CLEAR ${report.counts.CLEAR}, REVIEW ${report.counts.REVIEW}, HOLD ${report.counts.HOLD}`, + "", + "## Findings", + "", + ...report.results.flatMap((item) => [ + `### ${item.id}: ${item.title}`, + "", + `Decision: ${item.decision}`, + `Risk score: ${item.riskScore}`, + `Critical review: ${item.criticalReview ? "yes" : "no"}`, + `Suspicious events: ${item.suspiciousEventCount}`, + "", + item.findings.length === 0 + ? "- No retaliation risk detected." + : item.findings.map((finding) => `- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`).join("\n"), + "", + "Recommended actions:", + ...item.recommendedActions.map((action) => `- ${action}`), + "" + ]) +].join("\n"); + +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), markdown); + +const maxBar = 620; +const hold = report.counts.HOLD; +const review = report.counts.REVIEW; +const clear = report.counts.CLEAR; +const total = Math.max(1, report.totalPackets); +const holdWidth = Math.round((hold / total) * maxBar); +const reviewWidth = Math.round((review / total) * maxBar); +const clearWidth = Math.round((clear / total) * maxBar); +const svg = ` + + Review Retaliation Guard + Synthetic reputation-event packet audit for SCIBASE community trust. + Decision distribution + HOLD + + ${hold} + REVIEW + + ${review} + CLEAR + + ${clear} + Flags affiliated punitive actions, coercive messages, clustered retaliation, and unsafe public profile export. + Synthetic generated artifact only. No desktop capture or private data. + +`; + +fs.writeFileSync(path.join(outDir, "summary.svg"), svg); + +console.log(JSON.stringify({ + packets: report.totalPackets, + counts: report.counts, + outputs: [ + path.join(outDir, "summary.json"), + path.join(outDir, "reviewer-packet.md"), + path.join(outDir, "summary.svg") + ] +}, null, 2)); diff --git a/review-retaliation-guard/scripts/render-demo-video.js b/review-retaliation-guard/scripts/render-demo-video.js new file mode 100644 index 00000000..e42952ff --- /dev/null +++ b/review-retaliation-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='Review Retaliation Guard':x=64:y=56:fontsize=44:fontcolor=black`, + `drawtext=fontfile=${font}:text='Community reputation protection after critical peer review':x=64:y=122:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic reputation packets analyzed 4':x=64:y=214:fontsize=34:fontcolor=black`, + `drawtext=fontfile=${font}:text='HOLD 2 REVIEW 0 CLEAR 2':x=64:y=274:fontsize=40:fontcolor=0x245fd6`, + `drawtext=fontfile=${font}:text='Detects author-affiliated penalties and clustered retaliation':x=64:y=366:fontsize=27:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Freezes public reputation exports until independent review':x=64:y=428:fontsize=27:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic generated slate only no desktop capture or private data':x=64:y=492: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/review-retaliation-guard/src/index.js b/review-retaliation-guard/src/index.js new file mode 100644 index 00000000..42c70ed4 --- /dev/null +++ b/review-retaliation-guard/src/index.js @@ -0,0 +1,328 @@ +"use strict"; + +const CRITICAL_RECOMMENDATIONS = new Set([ + "reject", + "major_revision", + "major revisions", + "not_recommended", + "do_not_accept" +]); + +const PUNITIVE_ACTIONS = new Set([ + "downvote", + "reputation_penalty", + "badge_removed", + "endorsement_withdrawn", + "role_revoked", + "account_flag", + "reviewer_blocked", + "profile_visibility_reduced" +]); + +const COERCION_PATTERNS = [ + /\bchange\s+your\s+review\b/i, + /\bwithdraw\s+your\s+criticism\b/i, + /\bremove\s+the\s+negative\s+comment\b/i, + /\bunless\s+you\s+revise\b/i, + /\breputation\s+will\s+drop\b/i, + /\bwe\s+will\s+penalize\b/i +]; + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeText(value) { + return String(value || "").trim().toLowerCase(); +} + +function parseDate(value) { + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time) : null; +} + +function daysBetween(startValue, endValue) { + const start = parseDate(startValue); + const end = parseDate(endValue); + if (!start || !end) return null; + return Math.round((end.getTime() - start.getTime()) / 86400000); +} + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function normalizePacket(packet) { + return { + id: packet.id, + title: packet.title || "Untitled reputation packet", + reviewDate: packet.reviewDate || packet.review?.submittedAt || "2026-08-15", + review: packet.review || {}, + reputationEvents: list(packet.reputationEvents), + manuscript: packet.manuscript || {}, + safeguards: packet.safeguards || {}, + exportTarget: packet.exportTarget || {} + }; +} + +function isCriticalReview(review) { + const recommendation = normalizeText(review.recommendation).replace(/\s+/g, "_"); + if (CRITICAL_RECOMMENDATIONS.has(recommendation)) return true; + if (review.rating !== undefined && Number(review.rating) <= 2) return true; + if (Number(review.criticalityScore || 0) >= 0.7) return true; + return normalizeText(review.tone) === "critical"; +} + +function actorRelation(event, packet) { + const relation = normalizeText(event.actorRelation); + if (relation) return relation; + + const authors = new Set(list(packet.manuscript.authors).map(normalizeText)); + const institutions = new Set(list(packet.manuscript.institutions).map(normalizeText)); + const actor = normalizeText(event.actor); + const institution = normalizeText(event.actorInstitution); + + if (authors.has(actor)) return "author"; + if (institution && institutions.has(institution)) return "same_institution"; + return "unaffiliated"; +} + +function isAffiliatedRelation(relation) { + return [ + "author", + "coauthor", + "same_institution", + "same_lab", + "project_collaborator", + "funder", + "study_team" + ].includes(relation); +} + +function isPunitiveEvent(event) { + if (PUNITIVE_ACTIONS.has(normalizeText(event.action))) return true; + return Number(event.reputationDelta || 0) < 0; +} + +function targetsReviewer(event, reviewerId) { + const reviewer = normalizeText(reviewerId); + if (!reviewer) return false; + return [ + event.targetReviewer, + event.targetReviewerId, + event.targetUser, + event.targetContributionOwner + ].some((value) => normalizeText(value) === reviewer); +} + +function hasCoerciveLanguage(event) { + const text = `${event.note || ""} ${event.message || ""} ${event.reason || ""}`; + return COERCION_PATTERNS.some((pattern) => pattern.test(text)); +} + +function scoreFindings(findings) { + const score = findings.reduce((total, item) => { + if (item.severity === "critical") return total + 35; + if (item.severity === "major") return total + 22; + return total + 10; + }, 0); + return Math.min(100, score); +} + +function decisionFor(findings) { + if (findings.some((item) => item.severity === "critical")) return "HOLD"; + if (findings.some((item) => item.severity === "major")) return "REVIEW"; + return "CLEAR"; +} + +function analyzePacket(input) { + const packet = normalizePacket(input); + const findings = []; + const reviewerId = packet.review.reviewerId || packet.review.reviewer; + const criticalReview = isCriticalReview(packet.review); + const retaliationWindowDays = Number(packet.safeguards.retaliationWindowDays || 35); + + const relevantEvents = packet.reputationEvents.map((event) => { + const relation = actorRelation(event, packet); + const daysAfterReview = daysBetween(packet.reviewDate, event.createdAt); + return { + ...event, + relation, + daysAfterReview, + punitive: isPunitiveEvent(event), + targetsReviewer: targetsReviewer(event, reviewerId), + coerciveLanguage: hasCoerciveLanguage(event), + inRetaliationWindow: daysAfterReview !== null && daysAfterReview >= 0 && daysAfterReview <= retaliationWindowDays + }; + }); + + const suspiciousEvents = relevantEvents.filter((event) => ( + criticalReview && + event.punitive && + event.targetsReviewer && + event.inRetaliationWindow && + isAffiliatedRelation(event.relation) + )); + + for (const event of suspiciousEvents) { + findings.push(finding( + "AFFILIATED_PUNITIVE_ACTION_AFTER_CRITICAL_REVIEW", + "critical", + "An author-affiliated actor applied a punitive reputation action shortly after a critical review.", + { + action: event.action, + actor: event.actor, + relation: event.relation, + targetReviewer: reviewerId, + daysAfterReview: event.daysAfterReview, + reputationDelta: event.reputationDelta || 0 + }, + "Freeze the reputation delta and route the action to an independent moderator before publication or profile export." + )); + } + + const coerciveEvents = relevantEvents.filter((event) => ( + event.targetsReviewer && + event.inRetaliationWindow && + event.coerciveLanguage + )); + + for (const event of coerciveEvents) { + findings.push(finding( + "COERCIVE_REVIEW_REVISION_SIGNAL", + "critical", + "A reputation event contains language that appears to pressure the reviewer to soften or alter criticism.", + { + actor: event.actor, + action: event.action, + daysAfterReview: event.daysAfterReview, + excerpt: String(event.message || event.note || event.reason || "").slice(0, 160) + }, + "Separate reputation handling from manuscript-review negotiation and preserve the message for moderator review." + )); + } + + const punitiveActorCount = new Set(suspiciousEvents.map((event) => normalizeText(event.actor))).size; + if (punitiveActorCount >= 2) { + findings.push(finding( + "RECIPROCAL_OR_CLUSTERED_RETALIATION_BURST", + "critical", + "Multiple affiliated actors targeted the same reviewer within the retaliation window.", + { + affectedReviewer: reviewerId, + distinctActors: punitiveActorCount, + events: suspiciousEvents.map((event) => ({ actor: event.actor, action: event.action, daysAfterReview: event.daysAfterReview })) + }, + "Escalate as a retaliation cluster, suspend automated scoring, and require independent adjudication." + )); + } + + const badgeWithdrawals = suspiciousEvents.filter((event) => [ + "badge_removed", + "endorsement_withdrawn", + "role_revoked", + "profile_visibility_reduced" + ].includes(normalizeText(event.action))); + + if (badgeWithdrawals.length > 0) { + findings.push(finding( + "PROFILE_CREDIT_REMOVED_AFTER_UNFAVORABLE_REVIEW", + "major", + "A public-facing badge, endorsement, role, or visibility signal was reduced after an unfavorable review.", + { events: badgeWithdrawals.map((event) => ({ actor: event.actor, action: event.action, daysAfterReview: event.daysAfterReview })) }, + "Hold public profile updates until the action is justified by evidence unrelated to the review outcome." + )); + } + + if (criticalReview && suspiciousEvents.length > 0 && packet.safeguards.independentModeratorReview !== true) { + findings.push(finding( + "INDEPENDENT_REVIEW_MISSING_FOR_RETALIATION_RISK", + "major", + "The packet has retaliation risk but no independent moderator review recorded.", + { independentModeratorReview: packet.safeguards.independentModeratorReview || false }, + "Add independent moderator signoff before applying the reputation change." + )); + } + + const appealWindowDays = Number(packet.safeguards.appealWindowDays || 0); + if (suspiciousEvents.length > 0 && appealWindowDays < 14) { + findings.push(finding( + "APPEAL_WINDOW_TOO_SHORT_FOR_REPUTATION_DELTA", + "major", + "The reviewer has insufficient time to appeal a reputation-impacting action.", + { appealWindowDays }, + "Provide at least a 14-day appeal window and show the evidence packet to the affected reviewer." + )); + } + + if ( + findings.length > 0 && + packet.exportTarget.publicProfileExport === true && + packet.safeguards.freezePublicExport !== true + ) { + findings.push(finding( + "PUBLIC_REPUTATION_EXPORT_NOT_FROZEN", + "critical", + "Public reputation export is enabled while unresolved retaliation risk exists.", + { publicProfileExport: packet.exportTarget.publicProfileExport, freezePublicExport: packet.safeguards.freezePublicExport || false }, + "Freeze the public export and leaderboard deltas until the retaliation review is resolved." + )); + } + + const riskScore = scoreFindings(findings); + const decision = decisionFor(findings); + + return { + id: packet.id, + title: packet.title, + reviewerId, + criticalReview, + retaliationWindowDays, + decision, + riskScore, + findings, + suspiciousEventCount: suspiciousEvents.length, + relevantEventCount: relevantEvents.length, + recommendedActions: buildRecommendedActions(decision, findings) + }; +} + +function buildRecommendedActions(decision, findings) { + if (decision === "CLEAR") { + return ["Apply reputation changes with normal audit logging."]; + } + + const actions = [ + "Freeze reputation deltas tied to the flagged review.", + "Generate an evidence packet for an independent moderator.", + "Notify the affected reviewer of the appeal pathway." + ]; + + if (findings.some((item) => item.code === "PUBLIC_REPUTATION_EXPORT_NOT_FROZEN")) { + actions.unshift("Disable leaderboard and public profile export for the affected reviewer until resolution."); + } + + return actions; +} + +function analyzeReputationPackets(packets) { + const results = list(packets).map(analyzePacket); + const counts = results.reduce((acc, item) => { + acc[item.decision] = (acc[item.decision] || 0) + 1; + return acc; + }, { CLEAR: 0, REVIEW: 0, HOLD: 0 }); + + return { + generatedAt: new Date().toISOString(), + totalPackets: results.length, + counts, + results + }; +} + +module.exports = { + analyzePacket, + analyzeReputationPackets, + isCriticalReview, + daysBetween +}; diff --git a/review-retaliation-guard/test/review-retaliation-guard.test.js b/review-retaliation-guard/test/review-retaliation-guard.test.js new file mode 100644 index 00000000..e957acf1 --- /dev/null +++ b/review-retaliation-guard/test/review-retaliation-guard.test.js @@ -0,0 +1,43 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { analyzePacket, analyzeReputationPackets, daysBetween, isCriticalReview } = require("../src"); +const samples = require("../data/sample_reputation_events.json"); + +test("detects critical review signals", () => { + assert.equal(isCriticalReview({ recommendation: "reject", rating: 5 }), true); + assert.equal(isCriticalReview({ recommendation: "accept", rating: 1 }), true); + assert.equal(isCriticalReview({ recommendation: "accept", rating: 5 }), false); +}); + +test("computes days between review and reputation event", () => { + assert.equal(daysBetween("2026-08-01", "2026-08-04"), 3); + assert.equal(daysBetween("not-a-date", "2026-08-04"), null); +}); + +test("holds affiliated punitive actions after a critical review", () => { + const result = analyzePacket(samples.find((item) => item.id === "rep-hold-002")); + assert.equal(result.decision, "HOLD"); + assert.equal(result.suspiciousEventCount, 2); + assert.ok(result.findings.some((item) => item.code === "AFFILIATED_PUNITIVE_ACTION_AFTER_CRITICAL_REVIEW")); + assert.ok(result.findings.some((item) => item.code === "RECIPROCAL_OR_CLUSTERED_RETALIATION_BURST")); + assert.ok(result.findings.some((item) => item.code === "PUBLIC_REPUTATION_EXPORT_NOT_FROZEN")); +}); + +test("detects coercive language in a reputation event", () => { + const result = analyzePacket(samples.find((item) => item.id === "rep-hold-003")); + assert.equal(result.decision, "HOLD"); + assert.ok(result.findings.some((item) => item.code === "COERCIVE_REVIEW_REVISION_SIGNAL")); +}); + +test("does not flag independent freezes or unrelated old moderation", () => { + assert.equal(analyzePacket(samples.find((item) => item.id === "rep-clear-001")).decision, "CLEAR"); + assert.equal(analyzePacket(samples.find((item) => item.id === "rep-clear-004")).decision, "CLEAR"); +}); + +test("aggregates packet decisions for reviewer-facing reports", () => { + const report = analyzeReputationPackets(samples); + assert.equal(report.totalPackets, 4); + assert.deepEqual(report.counts, { CLEAR: 2, REVIEW: 0, HOLD: 2 }); +});