diff --git a/researcher-reputation-anomaly-guard/README.md b/researcher-reputation-anomaly-guard/README.md
new file mode 100644
index 00000000..7382588b
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/README.md
@@ -0,0 +1,32 @@
+# Researcher Reputation Anomaly Guard
+
+Synthetic, dependency-free reviewer module for SCIBASE issue #11, User & Project Management.
+
+The guard evaluates whether public researcher reputation badges should be shown, held, or sent to manual review. It focuses on metric abuse rather than profile sync:
+
+- download bursts from concentrated network clusters
+- self-controlled or weakly verified fork rings
+- reciprocal endorsement clusters and new same-affiliation endorsers
+- reproducibility-score inflation from self or lab-controlled runs
+- private or anonymous-review profile exposure risks
+- stale metric evidence windows
+
+## Run
+
+```bash
+npm run check
+npm test
+npm run demo
+npm run demo:video
+```
+
+The demo writes reviewer artifacts under `reports/`:
+
+- `reputation-anomaly-packet.json`
+- `reputation-anomaly-report.md`
+- `summary.svg`
+- `demo.avi`
+
+## Safety
+
+All scenarios are synthetic. The module does not call ORCID, SAML, OAuth, analytics providers, profile services, GitHub, identity providers, payment providers, or production project data.
diff --git a/researcher-reputation-anomaly-guard/acceptance-notes.md b/researcher-reputation-anomaly-guard/acceptance-notes.md
new file mode 100644
index 00000000..c4c40b7c
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/acceptance-notes.md
@@ -0,0 +1,18 @@
+# Acceptance Notes
+
+Reviewer acceptance checklist:
+
+- The risky sample holds public reputation badges for concentrated download bursts, fork rings, reciprocal endorsements, weak reproducibility evidence, and private-profile exposure.
+- The clean sample remains ready and keeps reputation badges visible.
+- The stale sample triggers manual review without blocking the profile.
+- Audit digests are deterministic for repeated runs.
+- Demo artifacts are generated from synthetic data only.
+
+Local validation commands:
+
+```bash
+npm run check
+npm test
+npm run demo
+npm run demo:video
+```
diff --git a/researcher-reputation-anomaly-guard/demo.js b/researcher-reputation-anomaly-guard/demo.js
new file mode 100644
index 00000000..811dea37
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/demo.js
@@ -0,0 +1,87 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { assessResearcherReputation } = require("./index");
+const { riskySnapshot, cleanSnapshot, staleSnapshot } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const packet = {
+ risky: assessResearcherReputation(riskySnapshot),
+ clean: assessResearcherReputation(cleanSnapshot),
+ stale: assessResearcherReputation(staleSnapshot)
+};
+
+fs.writeFileSync(
+ path.join(reportsDir, "reputation-anomaly-packet.json"),
+ `${JSON.stringify(packet, null, 2)}\n`
+);
+fs.writeFileSync(path.join(reportsDir, "reputation-anomaly-report.md"), renderMarkdown(packet));
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvg(packet));
+
+console.log(`Wrote ${path.relative(process.cwd(), reportsDir)}`);
+console.log(`Risky status: ${packet.risky.status}`);
+console.log(`Audit digest: ${packet.risky.auditDigest}`);
+
+function renderMarkdown(packet) {
+ const lines = [
+ "# Researcher Reputation Anomaly Guard Report",
+ "",
+ "Synthetic reviewer packet for SCIBASE issue #11.",
+ "",
+ "## Scenario Summary",
+ "",
+ "| Scenario | Status | Held Researchers | Blockers | Warnings | Digest |",
+ "| --- | --- | ---: | ---: | ---: | --- |"
+ ];
+
+ for (const [name, result] of Object.entries(packet)) {
+ lines.push(
+ `| ${name} | ${result.status} | ${result.summary.heldResearchers} | ${result.summary.blockerCount} | ${result.summary.warningCount} | ${result.auditDigest.slice(0, 12)} |`
+ );
+ }
+
+ lines.push("", "## Risky Profile Actions", "");
+ for (const action of packet.risky.researchers[0].actions) {
+ lines.push(`- ${action}`);
+ }
+
+ lines.push("", "## Non-Overlap Notes", "");
+ lines.push(
+ "This module evaluates metric abuse and public reputation badge safety. It does not sync ORCID/publication state, implement broad RBAC, issue automation credentials, manage anonymous review escrow, provision projects, or change live identity-provider state."
+ );
+
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvg(packet) {
+ const risky = packet.risky.summary;
+ const clean = packet.clean.summary;
+ const stale = packet.stale.summary;
+ return `
+
+`;
+}
+
+function bar(x, y, label, blockers, warnings, color) {
+ const blockerWidth = blockers * 42;
+ const warningWidth = warnings * 42;
+ return [
+ ` ${label}`,
+ ` `,
+ ` `,
+ ` `,
+ ` ${blockers} blockers, ${warnings} warnings`
+ ].join("\n");
+}
diff --git a/researcher-reputation-anomaly-guard/index.js b/researcher-reputation-anomaly-guard/index.js
new file mode 100644
index 00000000..673d0745
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/index.js
@@ -0,0 +1,291 @@
+"use strict";
+
+const crypto = require("crypto");
+
+const DEFAULTS = {
+ burstWindowDays: 7,
+ baselineWindowDays: 30,
+ downloadBurstMultiplier: 3,
+ maxSameNetworkRatio: 0.55,
+ minInstitutionDiversity: 3,
+ maxMetricAgeDays: 45,
+ maxSelfControlledForkRatio: 0.35,
+ maxReciprocalEndorsementRatio: 0.4,
+ maxLabControlledRunRatio: 0.35
+};
+
+function assessResearcherReputation(snapshot, options = {}) {
+ const settings = { ...DEFAULTS, ...options };
+ const now = new Date(snapshot.generatedAt || "2026-05-27T00:00:00Z");
+ const researchers = (snapshot.researchers || []).map((researcher) =>
+ assessResearcher(researcher, now, settings)
+ );
+ const summary = summarize(researchers);
+ const payload = {
+ generatedAt: snapshot.generatedAt || now.toISOString(),
+ status: summary.status,
+ summary,
+ researchers
+ };
+
+ return {
+ ...payload,
+ auditDigest: digestFor(payload)
+ };
+}
+
+function assessResearcher(researcher, now, settings) {
+ const blockers = [];
+ const warnings = [];
+ const actions = [];
+
+ checkDownloadAnomalies(researcher, now, settings, blockers, warnings, actions);
+ checkForkAnomalies(researcher, settings, blockers, warnings, actions);
+ checkEndorsementAnomalies(researcher, settings, blockers, warnings, actions);
+ checkReproducibilityInflation(researcher, settings, blockers, warnings, actions);
+ checkPrivacyExposure(researcher, blockers, warnings, actions);
+ checkMetricFreshness(researcher, now, settings, blockers, warnings, actions);
+
+ const reputationScore = Math.max(0, 100 - blockers.length * 18 - warnings.length * 7);
+ const status = blockers.length > 0 ? "hold_reputation_badges" : warnings.length > 0 ? "manual_review" : "ready";
+
+ return {
+ researcherId: researcher.id,
+ displayName: researcher.displayName,
+ status,
+ reputationScore,
+ blockers,
+ warnings,
+ actions: uniqueActions(actions)
+ };
+}
+
+function checkDownloadAnomalies(researcher, now, settings, blockers, warnings, actions) {
+ const events = researcher.metrics?.downloads || [];
+ const recentCutoff = daysAgo(now, settings.burstWindowDays);
+ const baselineCutoff = daysAgo(now, settings.burstWindowDays + settings.baselineWindowDays);
+ const recent = events.filter((event) => new Date(event.date) >= recentCutoff);
+ const baseline = events.filter((event) => {
+ const eventDate = new Date(event.date);
+ return eventDate >= baselineCutoff && eventDate < recentCutoff;
+ });
+ const recentTotal = sum(recent, "count");
+ const baselineDaily = Math.max(1, sum(baseline, "count") / Math.max(1, settings.baselineWindowDays));
+ const recentDaily = recentTotal / Math.max(1, settings.burstWindowDays);
+ const maxSameNetworkRatio = Math.max(0, ...recent.map((event) => event.sameNetworkRatio || 0));
+ const recentInstitutions = new Set(recent.flatMap((event) => event.institutions || [])).size;
+
+ if (recent.length > 0 && recentDaily >= baselineDaily * settings.downloadBurstMultiplier) {
+ blockers.push({
+ code: "download_burst",
+ message: `Recent downloads are ${round(recentDaily / baselineDaily)}x above baseline.`
+ });
+ actions.push("Hold download-derived badges until traffic provenance is reviewed.");
+ }
+
+ if (recentTotal >= 50 && maxSameNetworkRatio > settings.maxSameNetworkRatio) {
+ blockers.push({
+ code: "concentrated_download_source",
+ message: `${Math.round(maxSameNetworkRatio * 100)}% of recent downloads came from one network cluster.`
+ });
+ actions.push("Require bot and same-network traffic review before publishing metrics.");
+ }
+
+ if (recentTotal >= 50 && recentInstitutions < settings.minInstitutionDiversity) {
+ warnings.push({
+ code: "low_download_diversity",
+ message: `Only ${recentInstitutions} institutions appear in the recent download window.`
+ });
+ actions.push("Show metric as provisional until institution diversity improves.");
+ }
+}
+
+function checkForkAnomalies(researcher, settings, blockers, warnings, actions) {
+ const forks = researcher.metrics?.forks || [];
+ if (forks.length === 0) return;
+
+ const selfControlled = forks.filter((fork) => fork.selfControlled || fork.samePaymentAdmin || fork.identityConfidence < 0.6);
+ const noDownstreamWork = forks.filter((fork) => !fork.hasDownstreamCommit);
+ const selfRatio = selfControlled.length / forks.length;
+
+ if (selfRatio > settings.maxSelfControlledForkRatio) {
+ blockers.push({
+ code: "fork_ring_suspected",
+ message: `${selfControlled.length} of ${forks.length} forks look self-controlled or weakly verified.`
+ });
+ actions.push("Exclude suspect forks from reputation scoring and queue identity review.");
+ }
+
+ if (noDownstreamWork.length / forks.length > 0.6) {
+ warnings.push({
+ code: "thin_fork_activity",
+ message: `${noDownstreamWork.length} forks have no downstream commit evidence.`
+ });
+ actions.push("Down-rank fork-count badges until reuse evidence is available.");
+ }
+}
+
+function checkEndorsementAnomalies(researcher, settings, blockers, warnings, actions) {
+ const endorsements = researcher.metrics?.endorsements || [];
+ if (endorsements.length === 0) return;
+
+ const reciprocal = endorsements.filter((endorsement) => endorsement.reciprocal);
+ const newSharedAffiliation = endorsements.filter(
+ (endorsement) => endorsement.sharedAffiliation && endorsement.endorserAccountAgeDays < 30
+ );
+ const unverified = endorsements.filter((endorsement) => !endorsement.verifiedIdentity);
+
+ if (reciprocal.length / endorsements.length > settings.maxReciprocalEndorsementRatio) {
+ blockers.push({
+ code: "reciprocal_endorsement_cluster",
+ message: `${reciprocal.length} of ${endorsements.length} endorsements are reciprocal.`
+ });
+ actions.push("Hold endorsement badges and require independent reviewer validation.");
+ }
+
+ if (newSharedAffiliation.length >= 2) {
+ warnings.push({
+ code: "new_affiliation_endorsements",
+ message: `${newSharedAffiliation.length} endorsements came from new accounts at the same affiliation.`
+ });
+ actions.push("Mark endorsement signal as provisional.");
+ }
+
+ if (unverified.length > 0) {
+ warnings.push({
+ code: "unverified_endorsers",
+ message: `${unverified.length} endorsers lack verified identity evidence.`
+ });
+ }
+}
+
+function checkReproducibilityInflation(researcher, settings, blockers, warnings, actions) {
+ const runs = researcher.metrics?.reproducibilityRuns || [];
+ if (runs.length === 0) return;
+
+ const highScoreRuns = runs.filter((run) => run.score >= 0.95);
+ const labControlledRuns = highScoreRuns.filter((run) => run.runnerRelation === "self" || run.runnerRelation === "same_lab");
+ const weakEvidenceRuns = highScoreRuns.filter(
+ (run) => !run.environmentPinned || !run.datasetHashMatch || !run.notebookOutputFresh || run.rerunStatus !== "passed"
+ );
+
+ if (highScoreRuns.length > 0 && labControlledRuns.length / highScoreRuns.length > settings.maxLabControlledRunRatio) {
+ blockers.push({
+ code: "reproducibility_score_inflation",
+ message: `${labControlledRuns.length} high-score reproducibility runs are self or lab controlled.`
+ });
+ actions.push("Require independent reproducibility evidence before showing the score badge.");
+ }
+
+ if (weakEvidenceRuns.length > 0) {
+ blockers.push({
+ code: "weak_reproducibility_evidence",
+ message: `${weakEvidenceRuns.length} high-score runs have missing environment, data, or output freshness evidence.`
+ });
+ actions.push("Remove weak runs from score calculation.");
+ }
+}
+
+function checkPrivacyExposure(researcher, blockers, warnings, actions) {
+ const profileMode = researcher.profileMode || "public";
+ const publicBadges = researcher.publicBadges || [];
+ const exposesMetricBadge = publicBadges.some((badge) =>
+ ["downloads", "forks", "endorsements", "reproducibility"].includes(badge)
+ );
+
+ if (profileMode !== "public" && exposesMetricBadge) {
+ blockers.push({
+ code: "private_profile_metric_exposure",
+ message: `Profile mode is ${profileMode}, but public metric badges are enabled.`
+ });
+ actions.push("Hide reputation badges until profile visibility is reconciled.");
+ }
+
+ if (researcher.anonymousReviewParticipant && publicBadges.includes("endorsements")) {
+ warnings.push({
+ code: "anonymous_review_linkage_risk",
+ message: "Endorsement badges could reveal anonymous-review participation patterns."
+ });
+ actions.push("Aggregate endorsement counts before showing them on anonymous-review profiles.");
+ }
+}
+
+function checkMetricFreshness(researcher, now, settings, blockers, warnings, actions) {
+ const checkedAt = researcher.metrics?.checkedAt;
+ if (!checkedAt) {
+ warnings.push({
+ code: "missing_metric_check_time",
+ message: "Metric snapshot does not include a checkedAt timestamp."
+ });
+ actions.push("Refresh metric evidence before publication.");
+ return;
+ }
+
+ const ageDays = (now - new Date(checkedAt)) / (1000 * 60 * 60 * 24);
+ if (ageDays > settings.maxMetricAgeDays) {
+ warnings.push({
+ code: "stale_metric_window",
+ message: `Metric evidence is ${Math.round(ageDays)} days old.`
+ });
+ actions.push("Refresh metric windows before rendering public badges.");
+ }
+}
+
+function summarize(researchers) {
+ const blockerCount = sum(researchers, "blockers.length");
+ const warningCount = sum(researchers, "warnings.length");
+ const heldResearchers = researchers.filter((researcher) => researcher.status === "hold_reputation_badges").length;
+ const reviewResearchers = researchers.filter((researcher) => researcher.status === "manual_review").length;
+ const status = heldResearchers > 0 ? "hold_reputation_badges" : reviewResearchers > 0 ? "manual_review" : "ready";
+
+ return {
+ status,
+ researcherCount: researchers.length,
+ heldResearchers,
+ reviewResearchers,
+ blockerCount,
+ warningCount
+ };
+}
+
+function digestFor(payload) {
+ return crypto.createHash("sha256").update(stableStringify(payload)).digest("hex");
+}
+
+function stableStringify(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(",")}]`;
+ }
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
+ .join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function sum(items, path) {
+ return items.reduce((total, item) => total + Number(getPath(item, path) || 0), 0);
+}
+
+function getPath(item, path) {
+ return path.split(".").reduce((current, part) => (current == null ? undefined : current[part]), item);
+}
+
+function daysAgo(now, days) {
+ return new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
+}
+
+function uniqueActions(actions) {
+ return [...new Set(actions)];
+}
+
+function round(value) {
+ return Math.round(value * 10) / 10;
+}
+
+module.exports = {
+ assessResearcherReputation,
+ DEFAULTS
+};
diff --git a/researcher-reputation-anomaly-guard/make-demo-video.js b/researcher-reputation-anomaly-guard/make-demo-video.js
new file mode 100644
index 00000000..f3a36f84
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/make-demo-video.js
@@ -0,0 +1,158 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+
+const width = 320;
+const height = 180;
+const fps = 2;
+const frameCount = 16;
+const rowSize = Math.ceil((width * 3) / 4) * 4;
+const frameSize = rowSize * height;
+const reportsDir = path.join(__dirname, "reports");
+const outputPath = path.join(reportsDir, "demo.avi");
+
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const frames = [];
+for (let i = 0; i < frameCount; i += 1) {
+ frames.push(makeFrame(i));
+}
+
+const moviChunks = [];
+const indexEntries = [];
+let offset = 4;
+for (const frame of frames) {
+ const chunk = riffChunk("00db", frame);
+ moviChunks.push(chunk);
+ indexEntries.push(indexEntry("00db", 0x10, offset, frame.length));
+ offset += chunk.length;
+}
+
+const hdrl = listChunk("hdrl", [
+ riffChunk("avih", aviHeader()),
+ listChunk("strl", [
+ riffChunk("strh", streamHeader()),
+ riffChunk("strf", bitmapInfoHeader())
+ ])
+]);
+const movi = listChunk("movi", moviChunks);
+const idx1 = riffChunk("idx1", Buffer.concat(indexEntries));
+const riff = riffChunk("RIFF", Buffer.concat([Buffer.from("AVI "), hdrl, movi, idx1]));
+
+fs.writeFileSync(outputPath, riff);
+console.log(`Wrote ${path.relative(process.cwd(), outputPath)} (${riff.length} bytes)`);
+
+function makeFrame(frameIndex) {
+ const frame = Buffer.alloc(frameSize, 0xff);
+ const progress = (frameIndex + 1) / frameCount;
+ const holdWidth = Math.round(240 * progress);
+ const reviewWidth = Math.round(120 * Math.max(0, 1 - progress / 1.2));
+
+ fillRect(frame, 0, 0, width, height, [248, 250, 252]);
+ fillRect(frame, 18, 18, 284, 144, [255, 255, 255]);
+ strokeRect(frame, 18, 18, 284, 144, [210, 218, 228]);
+ fillRect(frame, 38, 58, 240, 18, [232, 237, 244]);
+ fillRect(frame, 38, 58, holdWidth, 18, [194, 65, 12]);
+ fillRect(frame, 38, 98, 240, 18, [232, 237, 244]);
+ fillRect(frame, 38, 98, reviewWidth, 18, [246, 196, 83]);
+ fillRect(frame, 38, 136, 240, 8, [21, 128, 61]);
+ fillRect(frame, 38, 136, Math.round(240 * progress), 8, [21, 128, 61]);
+ return frame;
+}
+
+function fillRect(frame, x, y, w, h, rgb) {
+ for (let py = y; py < y + h; py += 1) {
+ if (py < 0 || py >= height) continue;
+ for (let px = x; px < x + w; px += 1) {
+ if (px < 0 || px >= width) continue;
+ setPixel(frame, px, py, rgb);
+ }
+ }
+}
+
+function strokeRect(frame, x, y, w, h, rgb) {
+ fillRect(frame, x, y, w, 1, rgb);
+ fillRect(frame, x, y + h - 1, w, 1, rgb);
+ fillRect(frame, x, y, 1, h, rgb);
+ fillRect(frame, x + w - 1, y, 1, h, rgb);
+}
+
+function setPixel(frame, x, y, rgb) {
+ const bottomUpY = height - y - 1;
+ const index = bottomUpY * rowSize + x * 3;
+ frame[index] = rgb[2];
+ frame[index + 1] = rgb[1];
+ frame[index + 2] = rgb[0];
+}
+
+function aviHeader() {
+ const buffer = Buffer.alloc(56);
+ buffer.writeUInt32LE(Math.round(1000000 / fps), 0);
+ buffer.writeUInt32LE(frameSize * fps, 4);
+ buffer.writeUInt32LE(0, 8);
+ buffer.writeUInt32LE(0x10, 12);
+ buffer.writeUInt32LE(frameCount, 16);
+ buffer.writeUInt32LE(0, 20);
+ buffer.writeUInt32LE(1, 24);
+ buffer.writeUInt32LE(frameSize, 28);
+ buffer.writeUInt32LE(width, 32);
+ buffer.writeUInt32LE(height, 36);
+ return buffer;
+}
+
+function streamHeader() {
+ const buffer = Buffer.alloc(56);
+ buffer.write("vids", 0, 4, "ascii");
+ buffer.write("DIB ", 4, 4, "ascii");
+ buffer.writeUInt32LE(0, 8);
+ buffer.writeUInt32LE(0, 12);
+ buffer.writeUInt32LE(0, 16);
+ buffer.writeUInt32LE(1, 20);
+ buffer.writeUInt32LE(fps, 24);
+ buffer.writeUInt32LE(0, 28);
+ buffer.writeUInt32LE(frameCount, 32);
+ buffer.writeUInt32LE(frameSize, 36);
+ buffer.writeInt32LE(-1, 40);
+ buffer.writeUInt32LE(0, 44);
+ buffer.writeInt16LE(0, 48);
+ buffer.writeInt16LE(0, 50);
+ buffer.writeInt16LE(width, 52);
+ buffer.writeInt16LE(height, 54);
+ return buffer;
+}
+
+function bitmapInfoHeader() {
+ const buffer = Buffer.alloc(40);
+ buffer.writeUInt32LE(40, 0);
+ buffer.writeInt32LE(width, 4);
+ buffer.writeInt32LE(height, 8);
+ buffer.writeUInt16LE(1, 12);
+ buffer.writeUInt16LE(24, 14);
+ buffer.writeUInt32LE(0, 16);
+ buffer.writeUInt32LE(frameSize, 20);
+ return buffer;
+}
+
+function indexEntry(id, flags, chunkOffset, size) {
+ const buffer = Buffer.alloc(16);
+ buffer.write(id, 0, 4, "ascii");
+ buffer.writeUInt32LE(flags, 4);
+ buffer.writeUInt32LE(chunkOffset, 8);
+ buffer.writeUInt32LE(size, 12);
+ return buffer;
+}
+
+function riffChunk(id, payload) {
+ const size = payload.length;
+ const pad = size % 2 === 1 ? 1 : 0;
+ const buffer = Buffer.alloc(8 + size + pad);
+ buffer.write(id, 0, 4, "ascii");
+ buffer.writeUInt32LE(size, 4);
+ payload.copy(buffer, 8);
+ return buffer;
+}
+
+function listChunk(type, chunks) {
+ return riffChunk("LIST", Buffer.concat([Buffer.from(type, "ascii"), ...chunks]));
+}
diff --git a/researcher-reputation-anomaly-guard/package.json b/researcher-reputation-anomaly-guard/package.json
new file mode 100644
index 00000000..bbfa062e
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "researcher-reputation-anomaly-guard",
+ "version": "1.0.0",
+ "description": "Synthetic reputation anomaly guard for SCIBASE user and project management.",
+ "main": "index.js",
+ "scripts": {
+ "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js",
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "node make-demo-video.js"
+ },
+ "license": "MIT"
+}
diff --git a/researcher-reputation-anomaly-guard/reports/demo.avi b/researcher-reputation-anomaly-guard/reports/demo.avi
new file mode 100644
index 00000000..5cd3b600
Binary files /dev/null and b/researcher-reputation-anomaly-guard/reports/demo.avi differ
diff --git a/researcher-reputation-anomaly-guard/reports/reputation-anomaly-packet.json b/researcher-reputation-anomaly-guard/reports/reputation-anomaly-packet.json
new file mode 100644
index 00000000..4b1ad16b
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/reports/reputation-anomaly-packet.json
@@ -0,0 +1,143 @@
+{
+ "risky": {
+ "generatedAt": "2026-05-27T22:00:00Z",
+ "status": "hold_reputation_badges",
+ "summary": {
+ "status": "hold_reputation_badges",
+ "researcherCount": 1,
+ "heldResearchers": 1,
+ "reviewResearchers": 0,
+ "blockerCount": 7,
+ "warningCount": 5
+ },
+ "researchers": [
+ {
+ "researcherId": "researcher-risky",
+ "displayName": "Dr. Mira Vale",
+ "status": "hold_reputation_badges",
+ "reputationScore": 0,
+ "blockers": [
+ {
+ "code": "download_burst",
+ "message": "Recent downloads are 35.7x above baseline."
+ },
+ {
+ "code": "concentrated_download_source",
+ "message": "72% of recent downloads came from one network cluster."
+ },
+ {
+ "code": "fork_ring_suspected",
+ "message": "2 of 3 forks look self-controlled or weakly verified."
+ },
+ {
+ "code": "reciprocal_endorsement_cluster",
+ "message": "2 of 3 endorsements are reciprocal."
+ },
+ {
+ "code": "reproducibility_score_inflation",
+ "message": "2 high-score reproducibility runs are self or lab controlled."
+ },
+ {
+ "code": "weak_reproducibility_evidence",
+ "message": "1 high-score runs have missing environment, data, or output freshness evidence."
+ },
+ {
+ "code": "private_profile_metric_exposure",
+ "message": "Profile mode is institutional, but public metric badges are enabled."
+ }
+ ],
+ "warnings": [
+ {
+ "code": "low_download_diversity",
+ "message": "Only 1 institutions appear in the recent download window."
+ },
+ {
+ "code": "thin_fork_activity",
+ "message": "2 forks have no downstream commit evidence."
+ },
+ {
+ "code": "new_affiliation_endorsements",
+ "message": "2 endorsements came from new accounts at the same affiliation."
+ },
+ {
+ "code": "unverified_endorsers",
+ "message": "2 endorsers lack verified identity evidence."
+ },
+ {
+ "code": "anonymous_review_linkage_risk",
+ "message": "Endorsement badges could reveal anonymous-review participation patterns."
+ }
+ ],
+ "actions": [
+ "Hold download-derived badges until traffic provenance is reviewed.",
+ "Require bot and same-network traffic review before publishing metrics.",
+ "Show metric as provisional until institution diversity improves.",
+ "Exclude suspect forks from reputation scoring and queue identity review.",
+ "Down-rank fork-count badges until reuse evidence is available.",
+ "Hold endorsement badges and require independent reviewer validation.",
+ "Mark endorsement signal as provisional.",
+ "Require independent reproducibility evidence before showing the score badge.",
+ "Remove weak runs from score calculation.",
+ "Hide reputation badges until profile visibility is reconciled.",
+ "Aggregate endorsement counts before showing them on anonymous-review profiles."
+ ]
+ }
+ ],
+ "auditDigest": "9e0a152011f4be2e67389f701d6b73e630a21a387f6e7d44bd73ac13c5c2a3a0"
+ },
+ "clean": {
+ "generatedAt": "2026-05-27T22:00:00Z",
+ "status": "ready",
+ "summary": {
+ "status": "ready",
+ "researcherCount": 1,
+ "heldResearchers": 0,
+ "reviewResearchers": 0,
+ "blockerCount": 0,
+ "warningCount": 0
+ },
+ "researchers": [
+ {
+ "researcherId": "researcher-clean",
+ "displayName": "Dr. Rowan Chen",
+ "status": "ready",
+ "reputationScore": 100,
+ "blockers": [],
+ "warnings": [],
+ "actions": []
+ }
+ ],
+ "auditDigest": "72e09f87a8cf9883952e870e4fe819a1e0966a579102ffda19fb9c79c09b1740"
+ },
+ "stale": {
+ "generatedAt": "2026-05-27T22:00:00Z",
+ "status": "manual_review",
+ "summary": {
+ "status": "manual_review",
+ "researcherCount": 1,
+ "heldResearchers": 0,
+ "reviewResearchers": 1,
+ "blockerCount": 0,
+ "warningCount": 1
+ },
+ "researchers": [
+ {
+ "researcherId": "researcher-stale",
+ "displayName": "Dr. Imani Stone",
+ "status": "manual_review",
+ "reputationScore": 93,
+ "blockers": [],
+ "warnings": [
+ {
+ "code": "stale_metric_window",
+ "message": "Metric evidence is 96 days old."
+ }
+ ],
+ "actions": [
+ "Refresh metric windows before rendering public badges."
+ ]
+ }
+ ],
+ "auditDigest": "db3aaba107f9589a3f89ca0927dfbac3df3a912b36570a7148d86f7e8b3acda5"
+ }
+}
diff --git a/researcher-reputation-anomaly-guard/reports/reputation-anomaly-report.md b/researcher-reputation-anomaly-guard/reports/reputation-anomaly-report.md
new file mode 100644
index 00000000..66b1b7ca
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/reports/reputation-anomaly-report.md
@@ -0,0 +1,29 @@
+# Researcher Reputation Anomaly Guard Report
+
+Synthetic reviewer packet for SCIBASE issue #11.
+
+## Scenario Summary
+
+| Scenario | Status | Held Researchers | Blockers | Warnings | Digest |
+| --- | --- | ---: | ---: | ---: | --- |
+| risky | hold_reputation_badges | 1 | 7 | 5 | 9e0a152011f4 |
+| clean | ready | 0 | 0 | 0 | 72e09f87a8cf |
+| stale | manual_review | 0 | 0 | 1 | db3aaba107f9 |
+
+## Risky Profile Actions
+
+- Hold download-derived badges until traffic provenance is reviewed.
+- Require bot and same-network traffic review before publishing metrics.
+- Show metric as provisional until institution diversity improves.
+- Exclude suspect forks from reputation scoring and queue identity review.
+- Down-rank fork-count badges until reuse evidence is available.
+- Hold endorsement badges and require independent reviewer validation.
+- Mark endorsement signal as provisional.
+- Require independent reproducibility evidence before showing the score badge.
+- Remove weak runs from score calculation.
+- Hide reputation badges until profile visibility is reconciled.
+- Aggregate endorsement counts before showing them on anonymous-review profiles.
+
+## Non-Overlap Notes
+
+This module evaluates metric abuse and public reputation badge safety. It does not sync ORCID/publication state, implement broad RBAC, issue automation credentials, manage anonymous review escrow, provision projects, or change live identity-provider state.
diff --git a/researcher-reputation-anomaly-guard/reports/summary.svg b/researcher-reputation-anomaly-guard/reports/summary.svg
new file mode 100644
index 00000000..6bb9b8b1
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/reports/summary.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/researcher-reputation-anomaly-guard/requirements-map.md b/researcher-reputation-anomaly-guard/requirements-map.md
new file mode 100644
index 00000000..c42cc09c
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/requirements-map.md
@@ -0,0 +1,13 @@
+# Requirements Map
+
+| Issue #11 requirement | Coverage |
+| --- | --- |
+| Researcher profile citation and reputation metrics | Evaluates whether downloads, forks, endorsements, and reproducibility metrics can safely contribute to public profile scores. |
+| Public vs private profile modes | Blocks public badges when profile visibility or anonymous-review participation would expose private activity. |
+| Activity feed and recent project signals | Detects bursty or self-controlled activity before it appears as public reputation. |
+| Role and object-level access context | Includes protected profile modes and anonymous-review contexts in badge decisions. |
+| Project-level audit log | Emits deterministic reviewer actions and an audit digest for profile-metric review packets. |
+
+## Non-Overlap
+
+This is not a broad RBAC ledger, project provisioning module, profile sync/freshness module, anonymous-review escrow, data-room consent ledger, automation credential guard, visibility transition guard, or permission inheritance drift guard. It focuses narrowly on reputation metric abuse and badge-publication safety.
diff --git a/researcher-reputation-anomaly-guard/sample-data.js b/researcher-reputation-anomaly-guard/sample-data.js
new file mode 100644
index 00000000..037d6cc7
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/sample-data.js
@@ -0,0 +1,221 @@
+"use strict";
+
+const riskySnapshot = {
+ generatedAt: "2026-05-27T22:00:00Z",
+ researchers: [
+ {
+ id: "researcher-risky",
+ displayName: "Dr. Mira Vale",
+ profileMode: "institutional",
+ publicBadges: ["downloads", "forks", "endorsements", "reproducibility"],
+ anonymousReviewParticipant: true,
+ metrics: {
+ checkedAt: "2026-05-20T12:00:00Z",
+ downloads: [
+ {
+ date: "2026-05-26",
+ count: 140,
+ institutions: ["North Valley Lab"],
+ sameNetworkRatio: 0.72
+ },
+ {
+ date: "2026-05-25",
+ count: 110,
+ institutions: ["North Valley Lab", "North Valley Lab"],
+ sameNetworkRatio: 0.68
+ },
+ {
+ date: "2026-04-30",
+ count: 12,
+ institutions: ["State Biohub", "Open Climate Lab"],
+ sameNetworkRatio: 0.18
+ },
+ {
+ date: "2026-04-22",
+ count: 18,
+ institutions: ["State Biohub", "Metro Genomics"],
+ sameNetworkRatio: 0.21
+ }
+ ],
+ forks: [
+ {
+ projectId: "single-cell-atlas",
+ forkedBy: "mvale-lab-bot",
+ hasDownstreamCommit: false,
+ selfControlled: true,
+ samePaymentAdmin: true,
+ identityConfidence: 0.35
+ },
+ {
+ projectId: "single-cell-atlas",
+ forkedBy: "mvale-student-1",
+ hasDownstreamCommit: false,
+ selfControlled: false,
+ samePaymentAdmin: false,
+ identityConfidence: 0.52
+ },
+ {
+ projectId: "single-cell-atlas",
+ forkedBy: "external-reuse-lab",
+ hasDownstreamCommit: true,
+ selfControlled: false,
+ samePaymentAdmin: false,
+ identityConfidence: 0.93
+ }
+ ],
+ endorsements: [
+ {
+ from: "mvale-student-1",
+ to: "researcher-risky",
+ reciprocal: true,
+ sharedAffiliation: true,
+ endorserAccountAgeDays: 12,
+ verifiedIdentity: false
+ },
+ {
+ from: "mvale-student-2",
+ to: "researcher-risky",
+ reciprocal: true,
+ sharedAffiliation: true,
+ endorserAccountAgeDays: 18,
+ verifiedIdentity: false
+ },
+ {
+ from: "external-reviewer",
+ to: "researcher-risky",
+ reciprocal: false,
+ sharedAffiliation: false,
+ endorserAccountAgeDays: 540,
+ verifiedIdentity: true
+ }
+ ],
+ reproducibilityRuns: [
+ {
+ projectId: "single-cell-atlas",
+ score: 0.98,
+ runnerId: "mvale-lab-runner",
+ runnerRelation: "same_lab",
+ rerunStatus: "passed",
+ environmentPinned: false,
+ datasetHashMatch: true,
+ notebookOutputFresh: false
+ },
+ {
+ projectId: "single-cell-atlas",
+ score: 0.97,
+ runnerId: "mvale",
+ runnerRelation: "self",
+ rerunStatus: "passed",
+ environmentPinned: true,
+ datasetHashMatch: true,
+ notebookOutputFresh: true
+ }
+ ]
+ }
+ }
+ ]
+};
+
+const cleanSnapshot = {
+ generatedAt: "2026-05-27T22:00:00Z",
+ researchers: [
+ {
+ id: "researcher-clean",
+ displayName: "Dr. Rowan Chen",
+ profileMode: "public",
+ publicBadges: ["downloads", "forks", "reproducibility"],
+ anonymousReviewParticipant: false,
+ metrics: {
+ checkedAt: "2026-05-25T12:00:00Z",
+ downloads: [
+ {
+ date: "2026-05-26",
+ count: 24,
+ institutions: ["Metro Genomics", "Coastal Lab", "North Valley Lab", "Open Climate Lab"],
+ sameNetworkRatio: 0.18
+ },
+ {
+ date: "2026-04-25",
+ count: 95,
+ institutions: ["Metro Genomics", "Coastal Lab", "State Biohub", "Open Climate Lab"],
+ sameNetworkRatio: 0.2
+ }
+ ],
+ forks: [
+ {
+ projectId: "climate-assay",
+ forkedBy: "coastal-lab",
+ hasDownstreamCommit: true,
+ selfControlled: false,
+ samePaymentAdmin: false,
+ identityConfidence: 0.91
+ },
+ {
+ projectId: "climate-assay",
+ forkedBy: "state-biohub",
+ hasDownstreamCommit: true,
+ selfControlled: false,
+ samePaymentAdmin: false,
+ identityConfidence: 0.88
+ }
+ ],
+ endorsements: [
+ {
+ from: "external-reviewer-a",
+ to: "researcher-clean",
+ reciprocal: false,
+ sharedAffiliation: false,
+ endorserAccountAgeDays: 440,
+ verifiedIdentity: true
+ },
+ {
+ from: "external-reviewer-b",
+ to: "researcher-clean",
+ reciprocal: false,
+ sharedAffiliation: false,
+ endorserAccountAgeDays: 620,
+ verifiedIdentity: true
+ }
+ ],
+ reproducibilityRuns: [
+ {
+ projectId: "climate-assay",
+ score: 0.96,
+ runnerId: "external-runner",
+ runnerRelation: "independent",
+ rerunStatus: "passed",
+ environmentPinned: true,
+ datasetHashMatch: true,
+ notebookOutputFresh: true
+ }
+ ]
+ }
+ }
+ ]
+};
+
+const staleSnapshot = {
+ generatedAt: "2026-05-27T22:00:00Z",
+ researchers: [
+ {
+ id: "researcher-stale",
+ displayName: "Dr. Imani Stone",
+ profileMode: "public",
+ publicBadges: ["downloads"],
+ anonymousReviewParticipant: false,
+ metrics: {
+ checkedAt: "2026-02-20T12:00:00Z",
+ downloads: [],
+ forks: [],
+ endorsements: [],
+ reproducibilityRuns: []
+ }
+ }
+ ]
+};
+
+module.exports = {
+ riskySnapshot,
+ cleanSnapshot,
+ staleSnapshot
+};
diff --git a/researcher-reputation-anomaly-guard/test.js b/researcher-reputation-anomaly-guard/test.js
new file mode 100644
index 00000000..4938d1f5
--- /dev/null
+++ b/researcher-reputation-anomaly-guard/test.js
@@ -0,0 +1,60 @@
+"use strict";
+
+const assert = require("assert");
+const { assessResearcherReputation } = require("./index");
+const { riskySnapshot, cleanSnapshot, staleSnapshot } = require("./sample-data");
+
+function testRiskySnapshotHoldsBadges() {
+ const result = assessResearcherReputation(riskySnapshot);
+ const researcher = result.researchers[0];
+ const blockerCodes = researcher.blockers.map((blocker) => blocker.code);
+
+ assert.strictEqual(result.status, "hold_reputation_badges");
+ assert.strictEqual(researcher.status, "hold_reputation_badges");
+ assert(blockerCodes.includes("download_burst"));
+ assert(blockerCodes.includes("concentrated_download_source"));
+ assert(blockerCodes.includes("fork_ring_suspected"));
+ assert(blockerCodes.includes("reciprocal_endorsement_cluster"));
+ assert(blockerCodes.includes("reproducibility_score_inflation"));
+ assert(blockerCodes.includes("weak_reproducibility_evidence"));
+ assert(blockerCodes.includes("private_profile_metric_exposure"));
+}
+
+function testCleanSnapshotIsReady() {
+ const result = assessResearcherReputation(cleanSnapshot);
+ const researcher = result.researchers[0];
+
+ assert.strictEqual(result.status, "ready");
+ assert.strictEqual(researcher.status, "ready");
+ assert.strictEqual(researcher.blockers.length, 0);
+ assert.strictEqual(researcher.warnings.length, 0);
+ assert(researcher.reputationScore >= 95);
+}
+
+function testStaleSnapshotNeedsReviewOnly() {
+ const result = assessResearcherReputation(staleSnapshot);
+ const researcher = result.researchers[0];
+
+ assert.strictEqual(result.status, "manual_review");
+ assert.strictEqual(researcher.status, "manual_review");
+ assert.strictEqual(researcher.blockers.length, 0);
+ assert.strictEqual(researcher.warnings[0].code, "stale_metric_window");
+}
+
+function testDigestIsDeterministic() {
+ const first = assessResearcherReputation(riskySnapshot);
+ const second = assessResearcherReputation(riskySnapshot);
+
+ assert.match(first.auditDigest, /^[a-f0-9]{64}$/);
+ assert.strictEqual(first.auditDigest, second.auditDigest);
+}
+
+function run() {
+ testRiskySnapshotHoldsBadges();
+ testCleanSnapshotIsReady();
+ testStaleSnapshotNeedsReviewOnly();
+ testDigestIsDeterministic();
+ console.log("4 tests passed");
+}
+
+run();