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 @@
+
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 = `
+`;
+
+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 });
+});