diff --git a/reputation-probation-reinstatement-guard/.gitignore b/reputation-probation-reinstatement-guard/.gitignore new file mode 100644 index 00000000..2bf074d6 --- /dev/null +++ b/reputation-probation-reinstatement-guard/.gitignore @@ -0,0 +1 @@ +reports/frames/ diff --git a/reputation-probation-reinstatement-guard/README.md b/reputation-probation-reinstatement-guard/README.md new file mode 100644 index 00000000..1590cf5c --- /dev/null +++ b/reputation-probation-reinstatement-guard/README.md @@ -0,0 +1,39 @@ +# Reputation Probation Reinstatement Guard + +Self-contained reviewer artifact for SCIBASE issue #15, focused on safe reputation re-entry after moderation probation. + +This slice is intentionally narrow. It does not duplicate broad community ledgers, endorsement rings, review calibration, badge renewal/decay, leaderboard eligibility, civility checks, correction impact, peer-review recertification, workload equity, profile credit consent, or abuse detection. It evaluates whether reputation accrual can restart after a moderation outcome is already known. + +## What It Checks + +- Minimum probation windows before profile reputation deltas resume. +- Open appeals that should block reinstatement. +- Required remediation evidence and reviewer quorum. +- Public-redacted reinstatement receipts instead of private moderation-note exposure. +- Reputation deltas accidentally applied during probation. +- Capped re-entry so badges and leaderboards do not jump immediately after reinstatement. + +## Local Verification + +```sh +npm run check +npm test +npm run demo +npm run verify-video +``` + +`npm run demo` writes reviewer artifacts to `reports/`: + +- `clean-audit.json` +- `risky-audit.json` +- `risky-review.md` +- `summary.svg` +- `manifest.json` +- `demo.mp4` + +## Requirement Mapping + +- Community reputation: protects score, badge, and leaderboard updates during probation. +- Transparency: emits redacted reinstatement decisions and remediation actions. +- Fairness: requires remediation evidence and independent moderator quorum before trust re-entry. +- Reviewer demonstration: synthetic clean/risky packets produce JSON, Markdown, SVG, and MP4 artifacts without credentials or external services. diff --git a/reputation-probation-reinstatement-guard/demo.js b/reputation-probation-reinstatement-guard/demo.js new file mode 100644 index 00000000..12444bea --- /dev/null +++ b/reputation-probation-reinstatement-guard/demo.js @@ -0,0 +1,42 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + evaluateProbationReinstatementPacket, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const clean = evaluateProbationReinstatementPacket(cleanPacket, { now: "2026-06-01T11:00:00.000Z" }); +const risky = evaluateProbationReinstatementPacket(riskyPacket, { now: "2026-06-01T11:00:00.000Z" }); +const manifest = { + module: "reputation-probation-reinstatement-guard", + issue: 15, + generatedAt: "2026-06-01T11:00:00.000Z", + scenarios: [ + { name: "clean", status: clean.status, fingerprint: clean.fingerprint, findings: clean.findings.length }, + { name: "risky", status: risky.status, fingerprint: risky.fingerprint, findings: risky.findings.length } + ], + artifacts: [ + "reports/clean-audit.json", + "reports/risky-audit.json", + "reports/risky-review.md", + "reports/summary.svg", + "reports/demo.mp4" + ] +}; + +fs.writeFileSync(path.join(reportsDir, "clean-audit.json"), `${JSON.stringify(clean, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-audit.json"), `${JSON.stringify(risky, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(risky, riskyPacket)); +fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(risky)); +fs.writeFileSync(path.join(reportsDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + +console.log(`Clean status: ${clean.status} (${clean.fingerprint})`); +console.log(`Risky status: ${risky.status} (${risky.fingerprint})`); +console.log(`Wrote reviewer artifacts to ${reportsDir}`); diff --git a/reputation-probation-reinstatement-guard/index.js b/reputation-probation-reinstatement-guard/index.js new file mode 100644 index 00000000..dcf8ecab --- /dev/null +++ b/reputation-probation-reinstatement-guard/index.js @@ -0,0 +1,373 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const SEVERITY_ORDER = ["critical", "high", "warning", "info"]; + +function evaluateProbationReinstatementPacket(packet, options = {}) { + if (!isPlainObject(packet)) { + throw new TypeError("evaluateProbationReinstatementPacket expects a packet object"); + } + + const now = options.now ?? new Date().toISOString(); + const policy = isPlainObject(packet.policy) ? packet.policy : {}; + const users = asArray(packet.users); + const findings = []; + + if (users.length === 0) { + findings.push( + finding( + "PACKET_SCHEMA_MISSING_USERS", + "high", + "Probation packet has no users to inspect.", + "Reputation reinstatement decisions need user moderation records.", + "users", + "Attach user probation and reinstatement records.", + "moderation lead" + ) + ); + } + + users.forEach((user, index) => inspectUser(user, index, policy, now, findings)); + + const sortedFindings = sortFindings(findings); + const status = determineStatus(sortedFindings); + const summary = summarize(status, sortedFindings, users.length); + const reinstatementDecisions = users.map((user) => { + const userFindings = sortedFindings.filter((item) => item.userId === user.id); + return { + userId: user.id, + decision: userFindings.some((item) => ["critical", "high"].includes(item.severity)) ? "HOLD" : "ALLOW", + reasonCodes: userFindings.map((item) => item.code), + reputationAccrual: userFindings.some((item) => item.code === "PROBATION_WINDOW_ACTIVE") ? "paused" : "eligible" + }; + }); + const remediationActions = sortedFindings.map((item) => ({ + code: item.code, + userId: item.userId ?? null, + owner: item.owner, + action: item.remediation + })); + const fingerprint = crypto + .createHash("sha256") + .update( + JSON.stringify({ + policy, + users: users.map((user) => ({ + id: user.id, + probation: user.probation, + reinstatement: user.reinstatement, + reputationEvents: user.reputationEvents + })), + codes: sortedFindings.map((item) => item.code) + }) + ) + .digest("hex") + .slice(0, 16); + + return { + generatedAt: now, + status, + summary, + findingCounts: countBySeverity(sortedFindings), + findings: sortedFindings, + reinstatementDecisions, + remediationActions, + fingerprint + }; +} + +function renderMarkdownReport(result, packet) { + const lines = [ + "# Reputation Probation Reinstatement Guard", + "", + `Policy: ${packet.policy?.id ?? "unknown"}`, + `Status: ${result.status}`, + `Fingerprint: ${result.fingerprint}`, + "", + "## Summary", + "", + result.summary, + "", + "## Decisions", + "" + ]; + + result.reinstatementDecisions.forEach((decision) => { + lines.push(`- ${decision.userId}: ${decision.decision}, reputation accrual ${decision.reputationAccrual}`); + }); + + lines.push("", "## Findings", ""); + if (result.findings.length === 0) { + lines.push("- No probation or reinstatement blockers found."); + } else { + result.findings.forEach((item) => { + lines.push(`- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`); + lines.push(` - Remediation: ${item.remediation}`); + }); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(result) { + const counts = result.findingCounts; + const critical = counts.critical ?? 0; + const high = counts.high ?? 0; + const warning = counts.warning ?? 0; + const ready = result.status === "READY"; + const statusColor = ready ? "#16794c" : result.status === "REVIEW" ? "#a15c00" : "#a11b32"; + const holdWidth = Math.min(320, (critical + high) * 60); + const warningWidth = Math.min(220, warning * 55); + const readyWidth = ready ? 280 : Math.max(80, 280 - holdWidth); + + return [ + ``, + ``, + ``, + `Probation reinstatement guard`, + `Status ${escapeXml(result.status)} - fingerprint ${escapeXml(result.fingerprint)}`, + ``, + ``, + ``, + `TRUST`, + `Held reinstatements: ${result.reinstatementDecisions.filter((item) => item.decision === "HOLD").length}`, + `Critical/high blockers: ${critical + high}`, + `Users checked: ${result.reinstatementDecisions.length}`, + `` + ].join("\n"); +} + +function inspectUser(user, index, policy, now, findings) { + const userId = user.id ?? `user-${index}`; + const path = `users[${index}]`; + const probation = isPlainObject(user.probation) ? user.probation : {}; + const reinstatement = isPlainObject(user.reinstatement) ? user.reinstatement : {}; + const reputationEvents = asArray(user.reputationEvents); + const nowTime = timeValue(now); + + if (!user.id) { + findings.push( + finding( + "USER_MISSING_ID", + "high", + `User at index ${index} has no stable id.`, + "Reputation moderation decisions need stable profile identifiers.", + `${path}.id`, + "Attach a stable user id before evaluating reinstatement.", + "moderation lead", + userId + ) + ); + } + + if (!probation.reasonCode || !probation.startedAt) { + findings.push( + finding( + "PROBATION_RECORD_INCOMPLETE", + "high", + `${userId} has an incomplete probation record.`, + "Probation holds should explain the moderation basis and start time.", + `${path}.probation`, + "Attach reason code, start timestamp, and moderation receipt.", + "moderation lead", + userId + ) + ); + } + + const minimumDays = Number(policy.minimumProbationDays ?? 30); + const probationEnd = probation.startedAt ? timeValue(probation.startedAt) + minimumDays * 86400000 : null; + if (probationEnd && nowTime < probationEnd) { + findings.push( + finding( + "PROBATION_WINDOW_ACTIVE", + "critical", + `${userId} is still inside the minimum probation window.`, + "Reputation accrual and badge/leaderboard re-entry should remain paused until the cooling-off period ends.", + `${path}.probation.startedAt`, + `Wait until ${new Date(probationEnd).toISOString()} before reinstatement review.`, + "moderation lead", + userId + ) + ); + } + + if (probation.appealStatus === "open" || probation.appealStatus === "pending") { + findings.push( + finding( + "OPEN_APPEAL_BLOCKS_REINSTATEMENT", + "high", + `${userId} has an unresolved appeal tied to the probation record.`, + "Profiles should not receive reputation deltas while the moderation outcome is still contested.", + `${path}.probation.appealStatus`, + "Resolve the appeal or keep reputation accrual paused.", + "appeals moderator", + userId + ) + ); + } + + if (!reinstatement.requestedAt) { + findings.push( + finding( + "REINSTATEMENT_REQUEST_MISSING", + "high", + `${userId} has no reinstatement request timestamp.`, + "The system cannot prove that a contributor asked to re-enter reputation accrual.", + `${path}.reinstatement.requestedAt`, + "Record a reinstatement request and reviewer packet.", + "community manager", + userId + ) + ); + } + + const requiredActions = new Set(asArray(policy.requiredRemediationActions).map(String)); + const completedActions = new Set(asArray(reinstatement.completedActions).map(String)); + const missingActions = [...requiredActions].filter((action) => !completedActions.has(action)); + if (missingActions.length > 0) { + findings.push( + finding( + "REMEDIATION_ACTIONS_INCOMPLETE", + "high", + `${userId} has not completed required remediation action(s): ${missingActions.join(", ")}.`, + "Reinstatement should require verifiable remediation before reputation signals resume.", + `${path}.reinstatement.completedActions`, + "Complete and attach evidence for all required remediation actions.", + "community manager", + userId + ) + ); + } + + if (asArray(reinstatement.reviewers).length < Number(policy.requiredReviewerQuorum ?? 2)) { + findings.push( + finding( + "REINSTATEMENT_REVIEW_QUORUM_MISSING", + "high", + `${userId} lacks the required reinstatement reviewer quorum.`, + "Single-reviewer reinstatement can undermine moderation consistency.", + `${path}.reinstatement.reviewers`, + "Add independent moderator approvals before re-entry.", + "moderation lead", + userId + ) + ); + } + + if (reinstatement.summaryVisibility !== "public-redacted") { + findings.push( + finding( + "REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE", + "warning", + `${userId} reinstatement summary is not configured for public-redacted visibility.`, + "Community timelines need transparency without exposing private moderation notes.", + `${path}.reinstatement.summaryVisibility`, + "Publish a redacted reinstatement receipt and keep private evidence internal.", + "community manager", + userId + ) + ); + } + + const blockedEvents = reputationEvents.filter((event) => { + const eventTime = timeValue(event.at); + return eventTime >= timeValue(probation.startedAt) && eventTime <= nowTime && event.applied === true; + }); + if (blockedEvents.length > 0) { + findings.push( + finding( + "REPUTATION_DELTA_DURING_PROBATION", + "critical", + `${userId} received ${blockedEvents.length} reputation delta(s) during probation.`, + "Probation should pause reputation accrual, badges, and leaderboard effects.", + `${path}.reputationEvents`, + "Reverse or quarantine probation-window reputation deltas before reinstatement.", + "reputation admin", + userId + ) + ); + } + + if (Number(reinstatement.scoreCapPercent ?? 100) > Number(policy.maxReentryScoreCapPercent ?? 50)) { + findings.push( + finding( + "REENTRY_SCORE_CAP_TOO_HIGH", + "warning", + `${userId} re-entry score cap is above policy.`, + "Gradual re-entry protects leaderboards and badges from immediate trust jumps.", + `${path}.reinstatement.scoreCapPercent`, + "Apply the policy score cap until the post-reinstatement review window closes.", + "reputation admin", + userId + ) + ); + } +} + +function determineStatus(findings) { + if (findings.some((item) => item.severity === "critical" || item.severity === "high")) { + return "HOLD"; + } + if (findings.some((item) => item.severity === "warning")) { + return "REVIEW"; + } + return "READY"; +} + +function summarize(status, findings, userCount) { + if (status === "READY") { + return `All ${userCount} user probation record(s) are eligible for transparent, capped reputation re-entry.`; + } + const counts = countBySeverity(findings); + return `Reputation reinstatement is ${status.toLowerCase()} with ${counts.critical ?? 0} critical, ${counts.high ?? 0} high, and ${counts.warning ?? 0} warning finding(s).`; +} + +function finding(code, severity, message, impact, path, remediation, owner = "moderation lead", userId = null) { + return { code, severity, message, impact, path, remediation, owner, userId }; +} + +function sortFindings(findings) { + return findings.sort((left, right) => { + const severityDelta = SEVERITY_ORDER.indexOf(left.severity) - SEVERITY_ORDER.indexOf(right.severity); + if (severityDelta !== 0) { + return severityDelta; + } + return left.code.localeCompare(right.code); + }); +} + +function countBySeverity(findings) { + return findings.reduce((counts, item) => { + counts[item.severity] = (counts[item.severity] ?? 0) + 1; + return counts; + }, {}); +} + +function timeValue(value) { + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +module.exports = { + evaluateProbationReinstatementPacket, + renderMarkdownReport, + renderSvgSummary +}; diff --git a/reputation-probation-reinstatement-guard/make-demo-video.js b/reputation-probation-reinstatement-guard/make-demo-video.js new file mode 100644 index 00000000..3f4438d7 --- /dev/null +++ b/reputation-probation-reinstatement-guard/make-demo-video.js @@ -0,0 +1,127 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const WIDTH = 960; +const HEIGHT = 540; +const FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + V: ["10001", "10001", "10001", "10001", "01010", "01010", "00100"], + X: ["10001", "01010", "00100", "00100", "00100", "01010", "10001"], + Y: ["10001", "01010", "00100", "00100", "00100", "00100", "00100"] +}; + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +for (const file of fs.readdirSync(framesDir)) { + fs.unlinkSync(path.join(framesDir, file)); +} + +const slides = [ + { label: "PROBATION", color: [161, 92, 0], fill: 0.55 }, + { label: "REINSTATE", color: [22, 121, 76], fill: 0.82 }, + { label: "TRUST CAP", color: [22, 121, 76], fill: 0.74 } +]; + +let frameIndex = 0; +for (const slide of slides) { + for (let i = 0; i < 8; i += 1) { + const progress = (i + 1) / 8; + const buffer = createFrame(slide, progress); + fs.writeFileSync(path.join(framesDir, `frame-${String(frameIndex).padStart(3, "0")}.ppm`), buffer); + frameIndex += 1; + } +} + +const output = path.join(reportsDir, "demo.mp4"); +execFileSync( + "ffmpeg", + [ + "-y", + "-framerate", + "8", + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output + ], + { stdio: "ignore" } +); + +const stats = fs.statSync(output); +console.log(`Wrote ${output} (${stats.size} bytes)`); + +function createFrame(slide, progress) { + const pixels = Buffer.alloc(WIDTH * HEIGHT * 3); + fillRect(pixels, 0, 0, WIDTH, HEIGHT, [17, 24, 39]); + fillRect(pixels, 48, 48, 864, 444, [248, 250, 252]); + fillRect(pixels, 80, 190, 800, 88, [226, 232, 240]); + fillRect(pixels, 80, 190, Math.round(800 * slide.fill * progress), 88, slide.color); + fillRect(pixels, 80, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 344, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 608, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 80, 322, 70, 42, [161, 27, 50]); + fillRect(pixels, 344, 322, 120, 42, [161, 92, 0]); + fillRect(pixels, 608, 322, 220, 42, [22, 121, 76]); + drawText(pixels, "REPUTATION GUARD", 82, 104, 5, [17, 24, 39]); + drawText(pixels, slide.label, 108, 214, 7, [255, 255, 255]); + drawText(pixels, "SAFE REENTRY", 82, 414, 4, [51, 65, 85]); + return Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`, "ascii"), pixels]); +} + +function fillRect(pixels, x, y, width, height, color) { + const x2 = Math.min(WIDTH, x + width); + const y2 = Math.min(HEIGHT, y + height); + for (let row = Math.max(0, y); row < y2; row += 1) { + for (let col = Math.max(0, x); col < x2; col += 1) { + const offset = (row * WIDTH + col) * 3; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + } + } +} + +function drawText(pixels, text, x, y, scale, color) { + let cursor = x; + for (const rawChar of text) { + const char = rawChar.toUpperCase(); + if (char === " ") { + cursor += 4 * scale; + continue; + } + const glyph = FONT[char]; + if (!glyph) { + cursor += 6 * scale; + continue; + } + glyph.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < row.length; colIndex += 1) { + if (row[colIndex] === "1") { + fillRect(pixels, cursor + colIndex * scale, y + rowIndex * scale, scale, scale, color); + } + } + }); + cursor += 6 * scale; + } +} diff --git a/reputation-probation-reinstatement-guard/package.json b/reputation-probation-reinstatement-guard/package.json new file mode 100644 index 00000000..e654ea9c --- /dev/null +++ b/reputation-probation-reinstatement-guard/package.json @@ -0,0 +1,21 @@ +{ + "name": "reputation-probation-reinstatement-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic reputation probation and reinstatement guard for community trust systems.", + "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 && node make-demo-video.js", + "verify-video": "ffprobe -v error -show_entries stream=codec_name,width,height,duration -of default=nokey=1:noprint_wrappers=1 reports/demo.mp4" + }, + "keywords": [ + "community-reputation", + "probation", + "reinstatement", + "moderation", + "synthetic" + ], + "license": "MIT" +} diff --git a/reputation-probation-reinstatement-guard/reports/clean-audit.json b/reputation-probation-reinstatement-guard/reports/clean-audit.json new file mode 100644 index 00000000..6594fd5b --- /dev/null +++ b/reputation-probation-reinstatement-guard/reports/clean-audit.json @@ -0,0 +1,17 @@ +{ + "generatedAt": "2026-06-01T11:00:00.000Z", + "status": "READY", + "summary": "All 1 user probation record(s) are eligible for transparent, capped reputation re-entry.", + "findingCounts": {}, + "findings": [], + "reinstatementDecisions": [ + { + "userId": "researcher-alpha", + "decision": "ALLOW", + "reasonCodes": [], + "reputationAccrual": "eligible" + } + ], + "remediationActions": [], + "fingerprint": "a64ef993078090a8" +} diff --git a/reputation-probation-reinstatement-guard/reports/demo.mp4 b/reputation-probation-reinstatement-guard/reports/demo.mp4 new file mode 100644 index 00000000..bc1c8a96 Binary files /dev/null and b/reputation-probation-reinstatement-guard/reports/demo.mp4 differ diff --git a/reputation-probation-reinstatement-guard/reports/manifest.json b/reputation-probation-reinstatement-guard/reports/manifest.json new file mode 100644 index 00000000..d498d56e --- /dev/null +++ b/reputation-probation-reinstatement-guard/reports/manifest.json @@ -0,0 +1,26 @@ +{ + "module": "reputation-probation-reinstatement-guard", + "issue": 15, + "generatedAt": "2026-06-01T11:00:00.000Z", + "scenarios": [ + { + "name": "clean", + "status": "READY", + "fingerprint": "a64ef993078090a8", + "findings": 0 + }, + { + "name": "risky", + "status": "HOLD", + "fingerprint": "efef8995221f8246", + "findings": 13 + } + ], + "artifacts": [ + "reports/clean-audit.json", + "reports/risky-audit.json", + "reports/risky-review.md", + "reports/summary.svg", + "reports/demo.mp4" + ] +} diff --git a/reputation-probation-reinstatement-guard/reports/risky-audit.json b/reputation-probation-reinstatement-guard/reports/risky-audit.json new file mode 100644 index 00000000..cacf3a9e --- /dev/null +++ b/reputation-probation-reinstatement-guard/reports/risky-audit.json @@ -0,0 +1,252 @@ +{ + "generatedAt": "2026-06-01T11:00:00.000Z", + "status": "HOLD", + "summary": "Reputation reinstatement is hold with 2 critical, 7 high, and 4 warning finding(s).", + "findingCounts": { + "critical": 2, + "high": 7, + "warning": 4 + }, + "findings": [ + { + "code": "PROBATION_WINDOW_ACTIVE", + "severity": "critical", + "message": "researcher-beta is still inside the minimum probation window.", + "impact": "Reputation accrual and badge/leaderboard re-entry should remain paused until the cooling-off period ends.", + "path": "users[0].probation.startedAt", + "remediation": "Wait until 2026-06-19T12:00:00.000Z before reinstatement review.", + "owner": "moderation lead", + "userId": "researcher-beta" + }, + { + "code": "REPUTATION_DELTA_DURING_PROBATION", + "severity": "critical", + "message": "researcher-beta received 2 reputation delta(s) during probation.", + "impact": "Probation should pause reputation accrual, badges, and leaderboard effects.", + "path": "users[0].reputationEvents", + "remediation": "Reverse or quarantine probation-window reputation deltas before reinstatement.", + "owner": "reputation admin", + "userId": "researcher-beta" + }, + { + "code": "OPEN_APPEAL_BLOCKS_REINSTATEMENT", + "severity": "high", + "message": "researcher-beta has an unresolved appeal tied to the probation record.", + "impact": "Profiles should not receive reputation deltas while the moderation outcome is still contested.", + "path": "users[0].probation.appealStatus", + "remediation": "Resolve the appeal or keep reputation accrual paused.", + "owner": "appeals moderator", + "userId": "researcher-beta" + }, + { + "code": "PROBATION_RECORD_INCOMPLETE", + "severity": "high", + "message": "researcher-gamma has an incomplete probation record.", + "impact": "Probation holds should explain the moderation basis and start time.", + "path": "users[1].probation", + "remediation": "Attach reason code, start timestamp, and moderation receipt.", + "owner": "moderation lead", + "userId": "researcher-gamma" + }, + { + "code": "REINSTATEMENT_REQUEST_MISSING", + "severity": "high", + "message": "researcher-gamma has no reinstatement request timestamp.", + "impact": "The system cannot prove that a contributor asked to re-enter reputation accrual.", + "path": "users[1].reinstatement.requestedAt", + "remediation": "Record a reinstatement request and reviewer packet.", + "owner": "community manager", + "userId": "researcher-gamma" + }, + { + "code": "REINSTATEMENT_REVIEW_QUORUM_MISSING", + "severity": "high", + "message": "researcher-beta lacks the required reinstatement reviewer quorum.", + "impact": "Single-reviewer reinstatement can undermine moderation consistency.", + "path": "users[0].reinstatement.reviewers", + "remediation": "Add independent moderator approvals before re-entry.", + "owner": "moderation lead", + "userId": "researcher-beta" + }, + { + "code": "REINSTATEMENT_REVIEW_QUORUM_MISSING", + "severity": "high", + "message": "researcher-gamma lacks the required reinstatement reviewer quorum.", + "impact": "Single-reviewer reinstatement can undermine moderation consistency.", + "path": "users[1].reinstatement.reviewers", + "remediation": "Add independent moderator approvals before re-entry.", + "owner": "moderation lead", + "userId": "researcher-gamma" + }, + { + "code": "REMEDIATION_ACTIONS_INCOMPLETE", + "severity": "high", + "message": "researcher-beta has not completed required remediation action(s): updated-profile-attestation.", + "impact": "Reinstatement should require verifiable remediation before reputation signals resume.", + "path": "users[0].reinstatement.completedActions", + "remediation": "Complete and attach evidence for all required remediation actions.", + "owner": "community manager", + "userId": "researcher-beta" + }, + { + "code": "REMEDIATION_ACTIONS_INCOMPLETE", + "severity": "high", + "message": "researcher-gamma has not completed required remediation action(s): moderation-acknowledgement, updated-profile-attestation.", + "impact": "Reinstatement should require verifiable remediation before reputation signals resume.", + "path": "users[1].reinstatement.completedActions", + "remediation": "Complete and attach evidence for all required remediation actions.", + "owner": "community manager", + "userId": "researcher-gamma" + }, + { + "code": "REENTRY_SCORE_CAP_TOO_HIGH", + "severity": "warning", + "message": "researcher-beta re-entry score cap is above policy.", + "impact": "Gradual re-entry protects leaderboards and badges from immediate trust jumps.", + "path": "users[0].reinstatement.scoreCapPercent", + "remediation": "Apply the policy score cap until the post-reinstatement review window closes.", + "owner": "reputation admin", + "userId": "researcher-beta" + }, + { + "code": "REENTRY_SCORE_CAP_TOO_HIGH", + "severity": "warning", + "message": "researcher-gamma re-entry score cap is above policy.", + "impact": "Gradual re-entry protects leaderboards and badges from immediate trust jumps.", + "path": "users[1].reinstatement.scoreCapPercent", + "remediation": "Apply the policy score cap until the post-reinstatement review window closes.", + "owner": "reputation admin", + "userId": "researcher-gamma" + }, + { + "code": "REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE", + "severity": "warning", + "message": "researcher-beta reinstatement summary is not configured for public-redacted visibility.", + "impact": "Community timelines need transparency without exposing private moderation notes.", + "path": "users[0].reinstatement.summaryVisibility", + "remediation": "Publish a redacted reinstatement receipt and keep private evidence internal.", + "owner": "community manager", + "userId": "researcher-beta" + }, + { + "code": "REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE", + "severity": "warning", + "message": "researcher-gamma reinstatement summary is not configured for public-redacted visibility.", + "impact": "Community timelines need transparency without exposing private moderation notes.", + "path": "users[1].reinstatement.summaryVisibility", + "remediation": "Publish a redacted reinstatement receipt and keep private evidence internal.", + "owner": "community manager", + "userId": "researcher-gamma" + } + ], + "reinstatementDecisions": [ + { + "userId": "researcher-beta", + "decision": "HOLD", + "reasonCodes": [ + "PROBATION_WINDOW_ACTIVE", + "REPUTATION_DELTA_DURING_PROBATION", + "OPEN_APPEAL_BLOCKS_REINSTATEMENT", + "REINSTATEMENT_REVIEW_QUORUM_MISSING", + "REMEDIATION_ACTIONS_INCOMPLETE", + "REENTRY_SCORE_CAP_TOO_HIGH", + "REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE" + ], + "reputationAccrual": "paused" + }, + { + "userId": "researcher-gamma", + "decision": "HOLD", + "reasonCodes": [ + "PROBATION_RECORD_INCOMPLETE", + "REINSTATEMENT_REQUEST_MISSING", + "REINSTATEMENT_REVIEW_QUORUM_MISSING", + "REMEDIATION_ACTIONS_INCOMPLETE", + "REENTRY_SCORE_CAP_TOO_HIGH", + "REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE" + ], + "reputationAccrual": "eligible" + } + ], + "remediationActions": [ + { + "code": "PROBATION_WINDOW_ACTIVE", + "userId": "researcher-beta", + "owner": "moderation lead", + "action": "Wait until 2026-06-19T12:00:00.000Z before reinstatement review." + }, + { + "code": "REPUTATION_DELTA_DURING_PROBATION", + "userId": "researcher-beta", + "owner": "reputation admin", + "action": "Reverse or quarantine probation-window reputation deltas before reinstatement." + }, + { + "code": "OPEN_APPEAL_BLOCKS_REINSTATEMENT", + "userId": "researcher-beta", + "owner": "appeals moderator", + "action": "Resolve the appeal or keep reputation accrual paused." + }, + { + "code": "PROBATION_RECORD_INCOMPLETE", + "userId": "researcher-gamma", + "owner": "moderation lead", + "action": "Attach reason code, start timestamp, and moderation receipt." + }, + { + "code": "REINSTATEMENT_REQUEST_MISSING", + "userId": "researcher-gamma", + "owner": "community manager", + "action": "Record a reinstatement request and reviewer packet." + }, + { + "code": "REINSTATEMENT_REVIEW_QUORUM_MISSING", + "userId": "researcher-beta", + "owner": "moderation lead", + "action": "Add independent moderator approvals before re-entry." + }, + { + "code": "REINSTATEMENT_REVIEW_QUORUM_MISSING", + "userId": "researcher-gamma", + "owner": "moderation lead", + "action": "Add independent moderator approvals before re-entry." + }, + { + "code": "REMEDIATION_ACTIONS_INCOMPLETE", + "userId": "researcher-beta", + "owner": "community manager", + "action": "Complete and attach evidence for all required remediation actions." + }, + { + "code": "REMEDIATION_ACTIONS_INCOMPLETE", + "userId": "researcher-gamma", + "owner": "community manager", + "action": "Complete and attach evidence for all required remediation actions." + }, + { + "code": "REENTRY_SCORE_CAP_TOO_HIGH", + "userId": "researcher-beta", + "owner": "reputation admin", + "action": "Apply the policy score cap until the post-reinstatement review window closes." + }, + { + "code": "REENTRY_SCORE_CAP_TOO_HIGH", + "userId": "researcher-gamma", + "owner": "reputation admin", + "action": "Apply the policy score cap until the post-reinstatement review window closes." + }, + { + "code": "REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE", + "userId": "researcher-beta", + "owner": "community manager", + "action": "Publish a redacted reinstatement receipt and keep private evidence internal." + }, + { + "code": "REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE", + "userId": "researcher-gamma", + "owner": "community manager", + "action": "Publish a redacted reinstatement receipt and keep private evidence internal." + } + ], + "fingerprint": "efef8995221f8246" +} diff --git a/reputation-probation-reinstatement-guard/reports/risky-review.md b/reputation-probation-reinstatement-guard/reports/risky-review.md new file mode 100644 index 00000000..d17a7125 --- /dev/null +++ b/reputation-probation-reinstatement-guard/reports/risky-review.md @@ -0,0 +1,43 @@ +# Reputation Probation Reinstatement Guard + +Policy: community-reputation-probation-2026 +Status: HOLD +Fingerprint: efef8995221f8246 + +## Summary + +Reputation reinstatement is hold with 2 critical, 7 high, and 4 warning finding(s). + +## Decisions + +- researcher-beta: HOLD, reputation accrual paused +- researcher-gamma: HOLD, reputation accrual eligible + +## Findings + +- CRITICAL PROBATION_WINDOW_ACTIVE: researcher-beta is still inside the minimum probation window. + - Remediation: Wait until 2026-06-19T12:00:00.000Z before reinstatement review. +- CRITICAL REPUTATION_DELTA_DURING_PROBATION: researcher-beta received 2 reputation delta(s) during probation. + - Remediation: Reverse or quarantine probation-window reputation deltas before reinstatement. +- HIGH OPEN_APPEAL_BLOCKS_REINSTATEMENT: researcher-beta has an unresolved appeal tied to the probation record. + - Remediation: Resolve the appeal or keep reputation accrual paused. +- HIGH PROBATION_RECORD_INCOMPLETE: researcher-gamma has an incomplete probation record. + - Remediation: Attach reason code, start timestamp, and moderation receipt. +- HIGH REINSTATEMENT_REQUEST_MISSING: researcher-gamma has no reinstatement request timestamp. + - Remediation: Record a reinstatement request and reviewer packet. +- HIGH REINSTATEMENT_REVIEW_QUORUM_MISSING: researcher-beta lacks the required reinstatement reviewer quorum. + - Remediation: Add independent moderator approvals before re-entry. +- HIGH REINSTATEMENT_REVIEW_QUORUM_MISSING: researcher-gamma lacks the required reinstatement reviewer quorum. + - Remediation: Add independent moderator approvals before re-entry. +- HIGH REMEDIATION_ACTIONS_INCOMPLETE: researcher-beta has not completed required remediation action(s): updated-profile-attestation. + - Remediation: Complete and attach evidence for all required remediation actions. +- HIGH REMEDIATION_ACTIONS_INCOMPLETE: researcher-gamma has not completed required remediation action(s): moderation-acknowledgement, updated-profile-attestation. + - Remediation: Complete and attach evidence for all required remediation actions. +- WARNING REENTRY_SCORE_CAP_TOO_HIGH: researcher-beta re-entry score cap is above policy. + - Remediation: Apply the policy score cap until the post-reinstatement review window closes. +- WARNING REENTRY_SCORE_CAP_TOO_HIGH: researcher-gamma re-entry score cap is above policy. + - Remediation: Apply the policy score cap until the post-reinstatement review window closes. +- WARNING REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE: researcher-beta reinstatement summary is not configured for public-redacted visibility. + - Remediation: Publish a redacted reinstatement receipt and keep private evidence internal. +- WARNING REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE: researcher-gamma reinstatement summary is not configured for public-redacted visibility. + - Remediation: Publish a redacted reinstatement receipt and keep private evidence internal. diff --git a/reputation-probation-reinstatement-guard/reports/summary.svg b/reputation-probation-reinstatement-guard/reports/summary.svg new file mode 100644 index 00000000..d37e8d3a --- /dev/null +++ b/reputation-probation-reinstatement-guard/reports/summary.svg @@ -0,0 +1,13 @@ + + + +Probation reinstatement guard +Status HOLD - fingerprint efef8995221f8246 + + + +TRUST +Held reinstatements: 2 +Critical/high blockers: 9 +Users checked: 2 + \ No newline at end of file diff --git a/reputation-probation-reinstatement-guard/sample-data.js b/reputation-probation-reinstatement-guard/sample-data.js new file mode 100644 index 00000000..096f1377 --- /dev/null +++ b/reputation-probation-reinstatement-guard/sample-data.js @@ -0,0 +1,77 @@ +"use strict"; + +const cleanPacket = { + policy: { + id: "community-reputation-probation-2026", + minimumProbationDays: 30, + requiredRemediationActions: ["moderation-acknowledgement", "updated-profile-attestation"], + requiredReviewerQuorum: 2, + maxReentryScoreCapPercent: 50 + }, + users: [ + { + id: "researcher-alpha", + probation: { + reasonCode: "unsupported-review-claim", + startedAt: "2026-04-20T12:00:00.000Z", + appealStatus: "resolved", + receiptHash: "sha256:probation-alpha" + }, + reinstatement: { + requestedAt: "2026-05-25T12:00:00.000Z", + completedActions: ["moderation-acknowledgement", "updated-profile-attestation"], + reviewers: ["moderator-a", "moderator-b"], + summaryVisibility: "public-redacted", + scoreCapPercent: 40 + }, + reputationEvents: [ + { at: "2026-04-18T12:00:00.000Z", delta: 4, applied: true, source: "accepted-review" }, + { at: "2026-05-26T12:00:00.000Z", delta: 0, applied: false, source: "probation-review" } + ] + } + ] +}; + +const riskyPacket = { + policy: cleanPacket.policy, + users: [ + { + id: "researcher-beta", + probation: { + reasonCode: "review-abuse", + startedAt: "2026-05-20T12:00:00.000Z", + appealStatus: "open", + receiptHash: "sha256:probation-beta" + }, + reinstatement: { + requestedAt: "2026-05-25T12:00:00.000Z", + completedActions: ["moderation-acknowledgement"], + reviewers: ["moderator-a"], + summaryVisibility: "private", + scoreCapPercent: 80 + }, + reputationEvents: [ + { at: "2026-05-22T12:00:00.000Z", delta: 6, applied: true, source: "reviewer-badge" }, + { at: "2026-05-23T12:00:00.000Z", delta: 2, applied: true, source: "leaderboard-credit" } + ] + }, + { + id: "researcher-gamma", + probation: { + startedAt: "2026-04-10T12:00:00.000Z", + appealStatus: "resolved" + }, + reinstatement: { + completedActions: [], + reviewers: [], + summaryVisibility: "public" + }, + reputationEvents: [] + } + ] +}; + +module.exports = { + cleanPacket, + riskyPacket +}; diff --git a/reputation-probation-reinstatement-guard/test.js b/reputation-probation-reinstatement-guard/test.js new file mode 100644 index 00000000..a78303d4 --- /dev/null +++ b/reputation-probation-reinstatement-guard/test.js @@ -0,0 +1,44 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + evaluateProbationReinstatementPacket, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const clean = evaluateProbationReinstatementPacket(cleanPacket, { now: "2026-06-01T11:00:00.000Z" }); +assert.equal(clean.status, "READY"); +assert.equal(clean.findings.length, 0); +assert.equal(clean.reinstatementDecisions[0].decision, "ALLOW"); + +const risky = evaluateProbationReinstatementPacket(riskyPacket, { now: "2026-06-01T11:00:00.000Z" }); +const codes = new Set(risky.findings.map((finding) => finding.code)); +assert.equal(risky.status, "HOLD"); +assert.ok(codes.has("PROBATION_WINDOW_ACTIVE")); +assert.ok(codes.has("OPEN_APPEAL_BLOCKS_REINSTATEMENT")); +assert.ok(codes.has("REMEDIATION_ACTIONS_INCOMPLETE")); +assert.ok(codes.has("REINSTATEMENT_REVIEW_QUORUM_MISSING")); +assert.ok(codes.has("REINSTATEMENT_SUMMARY_VISIBILITY_UNSAFE")); +assert.ok(codes.has("REPUTATION_DELTA_DURING_PROBATION")); +assert.ok(codes.has("REENTRY_SCORE_CAP_TOO_HIGH")); +assert.ok(codes.has("PROBATION_RECORD_INCOMPLETE")); +assert.ok(codes.has("REINSTATEMENT_REQUEST_MISSING")); +assert.equal(risky.reinstatementDecisions.filter((decision) => decision.decision === "HOLD").length, 2); + +const repeatedRisky = evaluateProbationReinstatementPacket(riskyPacket, { now: "2026-06-01T11:05:00.000Z" }); +assert.equal(risky.fingerprint, repeatedRisky.fingerprint); + +const markdown = renderMarkdownReport(risky, riskyPacket); +assert.match(markdown, /Reputation Probation Reinstatement Guard/); +assert.match(markdown, /PROBATION_WINDOW_ACTIVE/); + +const svg = renderSvgSummary(risky); +assert.match(svg, / evaluateProbationReinstatementPacket(null), /expects a packet object/); + +console.log("All reputation probation reinstatement guard tests passed.");