diff --git a/reputation-appeal-evidence-guard/package.json b/reputation-appeal-evidence-guard/package.json
new file mode 100644
index 00000000..b2d0f0b4
--- /dev/null
+++ b/reputation-appeal-evidence-guard/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "scibase-reputation-appeal-evidence-guard",
+ "version": "1.0.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "test": "node --test test/*.test.js",
+ "demo": "node scripts/demo.js"
+ }
+}
diff --git a/reputation-appeal-evidence-guard/readme.md b/reputation-appeal-evidence-guard/readme.md
new file mode 100644
index 00000000..3a5dcdd9
--- /dev/null
+++ b/reputation-appeal-evidence-guard/readme.md
@@ -0,0 +1,29 @@
+# Reputation Appeal Evidence Guard
+
+This module contributes to SCIBASE issue #15, the Community & User Reputation System.
+
+It evaluates appeals against reputation penalties or reinstatement requests before they can alter public trust signals. The guard checks that each appeal has enough evidence, independent reviewers, an on-time decision path, a clear requested outcome, and bounded probation when reputation is restored.
+
+## What It Produces
+
+- A deterministic appeal evaluation packet.
+- Hold/release decisions for each appeal.
+- Reviewer checklist text for the pull request.
+- Demo report artifacts under `reports/`.
+
+## Local Verification
+
+```bash
+npm test
+npm run demo
+```
+
+## Demo Evidence
+
+The demo transcript is captured in `reports/demo-transcript.md`. The demo generates these reviewer artifacts:
+
+- `reports/appeal-evidence-report.md`
+- `reports/appeal-evidence-packet.json`
+- `reports/summary.svg`
+
+The demo data is synthetic and does not include private user records, institutional credentials, or external services.
diff --git a/reputation-appeal-evidence-guard/reports/appeal-evidence-packet.json b/reputation-appeal-evidence-guard/reports/appeal-evidence-packet.json
new file mode 100644
index 00000000..93425d87
--- /dev/null
+++ b/reputation-appeal-evidence-guard/reports/appeal-evidence-packet.json
@@ -0,0 +1,108 @@
+{
+ "title": "SCIBASE Reputation Appeal Evidence Guard",
+ "issue": "SCIBASE.AI#15",
+ "claim": "/claim #15",
+ "evaluation": {
+ "status": "hold_reputation_change",
+ "generatedAt": "2026-06-13T18:00:00.000Z",
+ "packetId": "scibase-reputation-appeal-demo",
+ "digest": "63127cd902d761c84395f0c2c5bf2491a6b6011195e74a7259d94411fb0ceb2c",
+ "counts": {
+ "users": 3,
+ "sanctions": 2,
+ "appeals": 2,
+ "blockers": 5,
+ "warnings": 1,
+ "heldAppeals": 1
+ },
+ "blockers": [
+ {
+ "code": "insufficient_evidence",
+ "appealId": "app-review-abuse",
+ "message": "Appeal does not include enough evidence for peer validation.",
+ "evidenceItems": 1,
+ "requiredEvidenceItems": 2
+ },
+ {
+ "code": "insufficient_independent_review",
+ "appealId": "app-review-abuse",
+ "message": "Appeal does not have enough independent conflict-free reviewers.",
+ "independentReviewers": 1,
+ "requiredIndependentReviewers": 2
+ },
+ {
+ "code": "same_actor_reappeal",
+ "appealId": "app-review-abuse",
+ "message": "Original decision actor cannot also decide the appeal."
+ },
+ {
+ "code": "appeal_window_expired",
+ "appealId": "app-review-abuse",
+ "message": "Appeal was submitted outside the configured appeal window.",
+ "appealAgeDays": 104,
+ "maxAppealAgeDays": 45
+ },
+ {
+ "code": "probation_too_long",
+ "appealId": "app-review-abuse",
+ "message": "Reinstatement probation exceeds the configured maximum.",
+ "probationDays": 180,
+ "maxProbationDays": 90
+ }
+ ],
+ "warnings": [
+ {
+ "code": "appeal_sla_too_long",
+ "appealId": "app-review-abuse",
+ "message": "Decision due date exceeds the configured appeal SLA.",
+ "slaHours": 2664,
+ "maxSlaHours": 168
+ }
+ ],
+ "decisions": [
+ {
+ "appealId": "app-duplicate-credit",
+ "userId": "usr-dataset-curator",
+ "sanctionId": "san-duplicate-credit",
+ "decision": "release",
+ "reasons": [],
+ "requiredActions": []
+ },
+ {
+ "appealId": "app-review-abuse",
+ "userId": "usr-protocol-author",
+ "sanctionId": "san-review-abuse",
+ "decision": "hold",
+ "reasons": [
+ "insufficient_evidence",
+ "insufficient_independent_review",
+ "same_actor_reappeal",
+ "appeal_window_expired",
+ "probation_too_long"
+ ],
+ "requiredActions": [
+ "Attach at least 2 evidence items.",
+ "Add 2 independent reviewer decisions with no conflicts.",
+ "Assign a separate appeal decision actor.",
+ "Escalate expired appeals to a governance exception review.",
+ "Reduce reinstatement probation to the policy maximum."
+ ]
+ }
+ ],
+ "policy": {
+ "maxAppealAgeDays": 45,
+ "slaHours": 168,
+ "minIndependentReviewers": 2,
+ "minEvidenceItems": 2,
+ "maxProbationDays": 90,
+ "requireActorSeparation": true
+ }
+ },
+ "reviewerChecklist": [
+ "Appeals reference an existing reputation profile and sanction.",
+ "Each appeal has the configured evidence threshold.",
+ "Independent reviewers disclose no conflicts.",
+ "Original penalty actors do not decide their own appeals.",
+ "Reinstatement probation stays bounded by policy."
+ ]
+}
diff --git a/reputation-appeal-evidence-guard/reports/appeal-evidence-report.md b/reputation-appeal-evidence-guard/reports/appeal-evidence-report.md
new file mode 100644
index 00000000..673a3072
--- /dev/null
+++ b/reputation-appeal-evidence-guard/reports/appeal-evidence-report.md
@@ -0,0 +1,24 @@
+# Reputation Appeal Evidence Guard Report
+
+Issue: SCIBASE.AI#15
+Claim marker: `/claim #15`
+Status: `hold_reputation_change`
+Digest: `63127cd902d761c84395f0c2c5bf2491a6b6011195e74a7259d94411fb0ceb2c`
+
+## Reviewer Checklist
+- Appeals reference an existing reputation profile and sanction.
+- Each appeal has the configured evidence threshold.
+- Independent reviewers disclose no conflicts.
+- Original penalty actors do not decide their own appeals.
+- Reinstatement probation stays bounded by policy.
+
+## Blockers
+- insufficient_evidence: Appeal does not include enough evidence for peer validation.
+- insufficient_independent_review: Appeal does not have enough independent conflict-free reviewers.
+- same_actor_reappeal: Original decision actor cannot also decide the appeal.
+- appeal_window_expired: Appeal was submitted outside the configured appeal window.
+- probation_too_long: Reinstatement probation exceeds the configured maximum.
+
+## Appeal Decisions
+- app-duplicate-credit: release
+- app-review-abuse: hold (insufficient_evidence, insufficient_independent_review, same_actor_reappeal, appeal_window_expired, probation_too_long)
diff --git a/reputation-appeal-evidence-guard/reports/demo-transcript.md b/reputation-appeal-evidence-guard/reports/demo-transcript.md
new file mode 100644
index 00000000..a33c1294
--- /dev/null
+++ b/reputation-appeal-evidence-guard/reports/demo-transcript.md
@@ -0,0 +1,35 @@
+# Reputation Appeal Evidence Guard Demo Transcript
+
+Date verified: 2026-06-15
+
+Commands run from `reputation-appeal-evidence-guard`:
+
+```bash
+npm test
+npm run demo
+```
+
+Test result:
+
+```text
+5 tests passed
+0 tests failed
+```
+
+Demo output:
+
+```json
+{
+ "status": "hold_reputation_change",
+ "digest": "63127cd902d761c84395f0c2c5bf2491a6b6011195e74a7259d94411fb0ceb2c",
+ "blockers": 5,
+ "heldAppeals": 1,
+ "reportsDir": "reputation-appeal-evidence-guard/reports"
+}
+```
+
+Generated reviewer artifacts:
+
+- `reports/appeal-evidence-report.md`
+- `reports/appeal-evidence-packet.json`
+- `reports/summary.svg`
diff --git a/reputation-appeal-evidence-guard/reports/summary.svg b/reputation-appeal-evidence-guard/reports/summary.svg
new file mode 100644
index 00000000..3c5c4ff2
--- /dev/null
+++ b/reputation-appeal-evidence-guard/reports/summary.svg
@@ -0,0 +1 @@
+
diff --git a/reputation-appeal-evidence-guard/scripts/demo.js b/reputation-appeal-evidence-guard/scripts/demo.js
new file mode 100644
index 00000000..c746505b
--- /dev/null
+++ b/reputation-appeal-evidence-guard/scripts/demo.js
@@ -0,0 +1,29 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ buildReviewerPacket,
+ demoPacket,
+ renderMarkdownReport,
+ renderSvgSummary
+} from "../src/index.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const moduleRoot = path.resolve(__dirname, "..");
+const reportsDir = path.join(moduleRoot, "reports");
+const packet = demoPacket();
+const reviewerPacket = buildReviewerPacket(packet, { now: packet.generatedAt });
+
+fs.mkdirSync(reportsDir, { recursive: true });
+fs.writeFileSync(path.join(reportsDir, "appeal-evidence-packet.json"), `${JSON.stringify(reviewerPacket, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, "appeal-evidence-report.md"), renderMarkdownReport(packet, { now: packet.generatedAt }));
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(packet, { now: packet.generatedAt }));
+
+console.log(JSON.stringify({
+ status: reviewerPacket.evaluation.status,
+ digest: reviewerPacket.evaluation.digest,
+ blockers: reviewerPacket.evaluation.counts.blockers,
+ heldAppeals: reviewerPacket.evaluation.counts.heldAppeals,
+ reportsDir
+}, null, 2));
diff --git a/reputation-appeal-evidence-guard/src/index.js b/reputation-appeal-evidence-guard/src/index.js
new file mode 100644
index 00000000..9569185e
--- /dev/null
+++ b/reputation-appeal-evidence-guard/src/index.js
@@ -0,0 +1,324 @@
+import crypto from "node:crypto";
+
+const DEFAULT_POLICY = {
+ maxAppealAgeDays: 45,
+ slaHours: 168,
+ minIndependentReviewers: 2,
+ minEvidenceItems: 2,
+ maxProbationDays: 90,
+ requireActorSeparation: true
+};
+
+export function evaluateReputationAppeals(packet, options = {}) {
+ const normalized = normalizePacket(packet);
+ const policy = { ...DEFAULT_POLICY, ...normalized.policy, ...(options.policy ?? {}) };
+ const now = new Date(options.now ?? normalized.generatedAt);
+ const userById = new Map(normalized.users.map((user) => [user.id, user]));
+ const sanctionById = new Map(normalized.sanctions.map((sanction) => [sanction.id, sanction]));
+ const blockers = [];
+ const warnings = [];
+ const decisions = [];
+
+ for (const appeal of normalized.appeals) {
+ const user = userById.get(appeal.userId);
+ const sanction = sanctionById.get(appeal.sanctionId);
+ const decision = {
+ appealId: appeal.id,
+ userId: appeal.userId,
+ sanctionId: appeal.sanctionId,
+ decision: "release",
+ reasons: [],
+ requiredActions: []
+ };
+
+ if (!user) {
+ hold(decision, "unknown_user", "Link the appeal to an existing reputation profile.");
+ blockers.push(finding("unknown_user", appeal.id, "Appeal references a missing user profile."));
+ }
+
+ if (!sanction) {
+ hold(decision, "unknown_sanction", "Link the appeal to the sanction or reputation event being challenged.");
+ blockers.push(finding("unknown_sanction", appeal.id, "Appeal references a missing sanction."));
+ } else {
+ if (sanction.userId !== appeal.userId) {
+ hold(decision, "sanction_user_mismatch", "Resolve the appealed sanction against the correct user profile.");
+ blockers.push(finding("sanction_user_mismatch", appeal.id, "Appeal user does not match the sanction user."));
+ }
+
+ if (sanction.status === "rescinded" && appeal.requestedOutcome !== "record_correction") {
+ warnings.push(finding("already_rescinded", appeal.id, "Sanction is already rescinded; only record-correction appeals should continue."));
+ }
+ }
+
+ if (!appeal.requestedOutcome) {
+ hold(decision, "missing_requested_outcome", "Choose revoke, reduce, reinstate, or record_correction before review.");
+ blockers.push(finding("missing_requested_outcome", appeal.id, "Appeal lacks a requested outcome."));
+ }
+
+ if (appeal.evidence.length < policy.minEvidenceItems) {
+ hold(decision, "insufficient_evidence", `Attach at least ${policy.minEvidenceItems} evidence items.`);
+ blockers.push({
+ ...finding("insufficient_evidence", appeal.id, "Appeal does not include enough evidence for peer validation."),
+ evidenceItems: appeal.evidence.length,
+ requiredEvidenceItems: policy.minEvidenceItems
+ });
+ }
+
+ const independentReviewers = appeal.reviewers.filter((reviewer) => reviewer.independent && reviewer.conflictDisclosure === "none");
+ if (independentReviewers.length < policy.minIndependentReviewers) {
+ hold(decision, "insufficient_independent_review", `Add ${policy.minIndependentReviewers} independent reviewer decisions with no conflicts.`);
+ blockers.push({
+ ...finding("insufficient_independent_review", appeal.id, "Appeal does not have enough independent conflict-free reviewers."),
+ independentReviewers: independentReviewers.length,
+ requiredIndependentReviewers: policy.minIndependentReviewers
+ });
+ }
+
+ if (policy.requireActorSeparation && appeal.originalDecisionActor === appeal.appealDecisionActor) {
+ hold(decision, "same_actor_reappeal", "Assign a separate appeal decision actor.");
+ blockers.push(finding("same_actor_reappeal", appeal.id, "Original decision actor cannot also decide the appeal."));
+ }
+
+ const appealAgeDays = daysBetween(new Date(appeal.submittedAt), now);
+ if (appealAgeDays > policy.maxAppealAgeDays) {
+ hold(decision, "appeal_window_expired", "Escalate expired appeals to a governance exception review.");
+ blockers.push({
+ ...finding("appeal_window_expired", appeal.id, "Appeal was submitted outside the configured appeal window."),
+ appealAgeDays,
+ maxAppealAgeDays: policy.maxAppealAgeDays
+ });
+ }
+
+ const slaHours = hoursBetween(new Date(appeal.submittedAt), new Date(appeal.decisionDueAt));
+ if (slaHours > policy.slaHours) {
+ warnings.push({
+ ...finding("appeal_sla_too_long", appeal.id, "Decision due date exceeds the configured appeal SLA."),
+ slaHours,
+ maxSlaHours: policy.slaHours
+ });
+ }
+
+ if (appeal.requestedOutcome === "reinstate" && appeal.probationDays > policy.maxProbationDays) {
+ hold(decision, "probation_too_long", "Reduce reinstatement probation to the policy maximum.");
+ blockers.push({
+ ...finding("probation_too_long", appeal.id, "Reinstatement probation exceeds the configured maximum."),
+ probationDays: appeal.probationDays,
+ maxProbationDays: policy.maxProbationDays
+ });
+ }
+
+ decisions.push(decision);
+ }
+
+ const status = blockers.length ? "hold_reputation_change" : warnings.length ? "needs_governance_review" : "ready_for_reputation_update";
+ return {
+ status,
+ generatedAt: now.toISOString(),
+ packetId: normalized.packetId,
+ digest: digest({ packetId: normalized.packetId, blockers, warnings, decisions }),
+ counts: {
+ users: normalized.users.length,
+ sanctions: normalized.sanctions.length,
+ appeals: normalized.appeals.length,
+ blockers: blockers.length,
+ warnings: warnings.length,
+ heldAppeals: decisions.filter((item) => item.decision === "hold").length
+ },
+ blockers,
+ warnings,
+ decisions,
+ policy
+ };
+}
+
+export function buildReviewerPacket(packet, options = {}) {
+ return {
+ title: "SCIBASE Reputation Appeal Evidence Guard",
+ issue: "SCIBASE.AI#15",
+ claim: "/claim #15",
+ evaluation: evaluateReputationAppeals(packet, options),
+ reviewerChecklist: [
+ "Appeals reference an existing reputation profile and sanction.",
+ "Each appeal has the configured evidence threshold.",
+ "Independent reviewers disclose no conflicts.",
+ "Original penalty actors do not decide their own appeals.",
+ "Reinstatement probation stays bounded by policy."
+ ]
+ };
+}
+
+export function renderMarkdownReport(packet, options = {}) {
+ const review = buildReviewerPacket(packet, options);
+ const { evaluation } = review;
+ return [
+ "# Reputation Appeal Evidence Guard Report",
+ "",
+ `Issue: ${review.issue}`,
+ `Claim marker: \`${review.claim}\``,
+ `Status: \`${evaluation.status}\``,
+ `Digest: \`${evaluation.digest}\``,
+ "",
+ "## Reviewer Checklist",
+ ...review.reviewerChecklist.map((item) => `- ${item}`),
+ "",
+ "## Blockers",
+ ...(evaluation.blockers.length ? evaluation.blockers.map((item) => `- ${item.code}: ${item.message}`) : ["- None."]),
+ "",
+ "## Appeal Decisions",
+ ...evaluation.decisions.map((item) => `- ${item.appealId}: ${item.decision}${item.reasons.length ? ` (${item.reasons.join(", ")})` : ""}`)
+ ].join("\n") + "\n";
+}
+
+export function renderSvgSummary(packet, options = {}) {
+ const review = buildReviewerPacket(packet, options);
+ const { evaluation } = review;
+ return `\n`;
+}
+
+export function demoPacket() {
+ return {
+ packetId: "scibase-reputation-appeal-demo",
+ generatedAt: "2026-06-13T18:00:00.000Z",
+ users: [
+ { id: "usr-reviewer-ada", displayName: "Ada R.", reputationScore: 82 },
+ { id: "usr-dataset-curator", displayName: "Dataset Curator", reputationScore: 41 },
+ { id: "usr-protocol-author", displayName: "Protocol Author", reputationScore: 64 }
+ ],
+ sanctions: [
+ { id: "san-duplicate-credit", userId: "usr-dataset-curator", reason: "duplicate_credit_claim", status: "active", createdAt: "2026-06-01T10:00:00.000Z", createdBy: "mod-reputation-1" },
+ { id: "san-review-abuse", userId: "usr-protocol-author", reason: "coercive_review_request", status: "active", createdAt: "2026-06-02T12:00:00.000Z", createdBy: "mod-reputation-2" }
+ ],
+ appeals: [
+ {
+ id: "app-duplicate-credit",
+ userId: "usr-dataset-curator",
+ sanctionId: "san-duplicate-credit",
+ submittedAt: "2026-06-06T10:00:00.000Z",
+ decisionDueAt: "2026-06-11T10:00:00.000Z",
+ requestedOutcome: "reduce",
+ evidence: [
+ { id: "ev-lab-note", type: "lab_note", digest: "sha256:lab-note" },
+ { id: "ev-commit-link", type: "repository_commit", digest: "sha256:commit" }
+ ],
+ reviewers: [
+ { id: "rev-1", independent: true, conflictDisclosure: "none" },
+ { id: "rev-2", independent: true, conflictDisclosure: "none" }
+ ],
+ originalDecisionActor: "mod-reputation-1",
+ appealDecisionActor: "appeal-panel-a",
+ probationDays: 30
+ },
+ {
+ id: "app-review-abuse",
+ userId: "usr-protocol-author",
+ sanctionId: "san-review-abuse",
+ submittedAt: "2026-03-01T08:00:00.000Z",
+ decisionDueAt: "2026-06-20T08:00:00.000Z",
+ requestedOutcome: "reinstate",
+ evidence: [
+ { id: "ev-email", type: "correspondence_digest", digest: "sha256:email" }
+ ],
+ reviewers: [
+ { id: "rev-3", independent: true, conflictDisclosure: "none" },
+ { id: "rev-4", independent: false, conflictDisclosure: "collaborator" }
+ ],
+ originalDecisionActor: "mod-reputation-2",
+ appealDecisionActor: "mod-reputation-2",
+ probationDays: 180
+ }
+ ]
+ };
+}
+
+export function normalizePacket(packet) {
+ if (!packet || typeof packet !== "object") throw new TypeError("A reputation appeal packet object is required.");
+ return {
+ packetId: text(packet.packetId, "packetId"),
+ generatedAt: text(packet.generatedAt, "generatedAt"),
+ policy: packet.policy ?? {},
+ users: asArray(packet.users, "users").map((user) => ({
+ id: text(user.id, "user.id"),
+ displayName: text(user.displayName, "user.displayName"),
+ reputationScore: number(user.reputationScore, "user.reputationScore")
+ })),
+ sanctions: asArray(packet.sanctions, "sanctions").map((sanction) => ({
+ id: text(sanction.id, "sanction.id"),
+ userId: text(sanction.userId, "sanction.userId"),
+ reason: text(sanction.reason, "sanction.reason"),
+ status: text(sanction.status, "sanction.status"),
+ createdAt: text(sanction.createdAt, "sanction.createdAt"),
+ createdBy: text(sanction.createdBy, "sanction.createdBy")
+ })),
+ appeals: asArray(packet.appeals, "appeals").map(normalizeAppeal)
+ };
+}
+
+function normalizeAppeal(appeal) {
+ return {
+ id: text(appeal.id, "appeal.id"),
+ userId: text(appeal.userId, "appeal.userId"),
+ sanctionId: text(appeal.sanctionId, "appeal.sanctionId"),
+ submittedAt: text(appeal.submittedAt, "appeal.submittedAt"),
+ decisionDueAt: text(appeal.decisionDueAt, "appeal.decisionDueAt"),
+ requestedOutcome: String(appeal.requestedOutcome ?? "").trim(),
+ evidence: asArray(appeal.evidence, "appeal.evidence").map((evidence) => ({
+ id: text(evidence.id, "evidence.id"),
+ type: text(evidence.type, "evidence.type"),
+ digest: text(evidence.digest, "evidence.digest")
+ })),
+ reviewers: asArray(appeal.reviewers, "appeal.reviewers").map((reviewer) => ({
+ id: text(reviewer.id, "reviewer.id"),
+ independent: Boolean(reviewer.independent),
+ conflictDisclosure: text(reviewer.conflictDisclosure, "reviewer.conflictDisclosure")
+ })),
+ originalDecisionActor: text(appeal.originalDecisionActor, "appeal.originalDecisionActor"),
+ appealDecisionActor: text(appeal.appealDecisionActor, "appeal.appealDecisionActor"),
+ probationDays: number(appeal.probationDays ?? 0, "appeal.probationDays")
+ };
+}
+
+function hold(decision, reason, requiredAction) {
+ decision.decision = "hold";
+ decision.reasons.push(reason);
+ decision.requiredActions.push(requiredAction);
+}
+
+function finding(code, appealId, message) {
+ return { code, appealId, message };
+}
+
+function daysBetween(start, end) {
+ return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86400000));
+}
+
+function hoursBetween(start, end) {
+ return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 3600000));
+}
+
+function text(value, name) {
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string.`);
+ return value.trim();
+}
+
+function number(value, name) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) throw new TypeError(`${name} must be finite.`);
+ return parsed;
+}
+
+function asArray(value, name) {
+ if (!Array.isArray(value)) throw new TypeError(`${name} must be an array.`);
+ return value;
+}
+
+function digest(value) {
+ return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("\"", """);
+}
diff --git a/reputation-appeal-evidence-guard/test/index.test.js b/reputation-appeal-evidence-guard/test/index.test.js
new file mode 100644
index 00000000..7c7a34a5
--- /dev/null
+++ b/reputation-appeal-evidence-guard/test/index.test.js
@@ -0,0 +1,53 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ buildReviewerPacket,
+ demoPacket,
+ evaluateReputationAppeals,
+ normalizePacket,
+ renderMarkdownReport,
+ renderSvgSummary
+} from "../src/index.js";
+
+test("holds appeals with weak evidence and conflicted review", () => {
+ const result = evaluateReputationAppeals(demoPacket(), { now: "2026-06-13T18:00:00.000Z" });
+ assert.equal(result.status, "hold_reputation_change");
+ assert.ok(result.blockers.some((blocker) => blocker.code === "insufficient_evidence"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "insufficient_independent_review"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "same_actor_reappeal"));
+});
+
+test("flags expired appeal windows and long reinstatement probation", () => {
+ const result = evaluateReputationAppeals(demoPacket(), { now: "2026-06-13T18:00:00.000Z" });
+ assert.ok(result.blockers.some((blocker) => blocker.code === "appeal_window_expired"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "probation_too_long"));
+});
+
+test("releases clean appeal packets", () => {
+ const packet = demoPacket();
+ packet.appeals = [packet.appeals[0]];
+ const result = evaluateReputationAppeals(packet, { now: "2026-06-13T18:00:00.000Z" });
+ assert.equal(result.status, "ready_for_reputation_update");
+ assert.equal(result.counts.blockers, 0);
+ assert.equal(result.counts.heldAppeals, 0);
+});
+
+test("rejects malformed packets", () => {
+ assert.equal(normalizePacket(demoPacket()).appeals.length, 2);
+ const broken = demoPacket();
+ broken.appeals[0].evidence = null;
+ assert.throws(() => normalizePacket(broken), /appeal\.evidence must be an array/);
+});
+
+test("renders reviewer artifacts with issue and claim marker", () => {
+ const packet = demoPacket();
+ const review = buildReviewerPacket(packet, { now: packet.generatedAt });
+ const markdown = renderMarkdownReport(packet, { now: packet.generatedAt });
+ const svg = renderSvgSummary(packet, { now: packet.generatedAt });
+ assert.equal(review.issue, "SCIBASE.AI#15");
+ assert.equal(review.claim, "/claim #15");
+ assert.match(markdown, /Reputation Appeal Evidence Guard Report/);
+ assert.match(markdown, /`\/claim #15`/);
+ assert.match(svg, /