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 @@ +Reputation Appeal Evidence GuardStatus: hold_reputation_change | Blockers: 5Appeals inspected: 2Held appeals: 1Digest: 63127cd902d761c84395f0c2Synthetic reputation records only. No private identities, credentials, or external services. 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 `Reputation Appeal Evidence GuardStatus: ${escapeXml(evaluation.status)} | Blockers: ${evaluation.counts.blockers}Appeals inspected: ${evaluation.counts.appeals}Held appeals: ${evaluation.counts.heldAppeals}Digest: ${escapeXml(evaluation.digest.slice(0, 24))}Synthetic reputation records only. No private identities, credentials, or external services.\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, /