diff --git a/challenge-withdrawal-reimbursement-guard/README.md b/challenge-withdrawal-reimbursement-guard/README.md
new file mode 100644
index 00000000..e80a3c5b
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/README.md
@@ -0,0 +1,44 @@
+# Challenge Withdrawal Reimbursement Guard
+
+This self-contained module adds a deterministic closeout guard for sponsor withdrawals, cancellations, and material scope reductions in the SCIBASE Scientific Bounty System. It is scoped to issue #18 and focuses on solver fairness after teams have already started work.
+
+The guard does not call external APIs, payment processors, live payout systems, private data stores, or credentialed services. Fixtures are synthetic and every check runs with Node built-ins.
+
+## What It Checks
+
+- Sponsor cancellation authority and reviewer-ready withdrawal reasons.
+- Equal direct notice to every started solver.
+- Appeal-window and solver-cost claim instructions in each notice.
+- Dispute-hold duration before sponsor refund or challenge closeout.
+- Milestone-progress evidence and hashed work artifacts.
+- Cost claim evidence for documented solver spend.
+- Receipt hashes for non-refundable solver costs.
+- Sponsor-funded reimbursement reserve sufficiency.
+- Recorded reimbursement decisions against deterministic recommendations.
+- IP return and data-destruction attestations before closeout.
+
+## Local Validation
+
+```sh
+npm --prefix challenge-withdrawal-reimbursement-guard run check
+npm --prefix challenge-withdrawal-reimbursement-guard test
+npm --prefix challenge-withdrawal-reimbursement-guard run demo
+npm --prefix challenge-withdrawal-reimbursement-guard run make-demo-video
+npm --prefix challenge-withdrawal-reimbursement-guard run verify-video
+```
+
+## Generated Artifacts
+
+Running the demo writes:
+
+- `reports/clean-withdrawal-report.json`
+- `reports/risky-withdrawal-report.json`
+- `reports/risky-withdrawal-handoff.md`
+- `reports/withdrawal-dashboard.svg`
+- `reports/demo.mp4`
+
+The risky packet intentionally demonstrates release blockers: missing cancellation authority, thin withdrawal reason, unequal solver notice, missing appeal and cost-claim instructions, missing milestone evidence, missing cost evidence, missing non-refundable receipts, incomplete IP/data return attestations, short dispute hold, missing appeal deadline, reimbursement shortfalls, and an underfunded reimbursement reserve.
+
+## Issue Fit
+
+This is a distinct Scientific Bounty System slice. It complements the existing intake, rubric readiness, scoring, arbitration, award transparency, appeals, escrow, payout eligibility, sponsor compliance, debrief feedback, onboarding clock parity, communication parity, accessibility/localization, deadline fairness, and amendment-consent work by focusing specifically on cancellation closeout fairness after solvers have already incurred work and costs.
diff --git a/challenge-withdrawal-reimbursement-guard/demo.js b/challenge-withdrawal-reimbursement-guard/demo.js
new file mode 100644
index 00000000..51636e4b
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/demo.js
@@ -0,0 +1,110 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { evaluateChallengeWithdrawal } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const clean = evaluateChallengeWithdrawal(cleanPacket);
+const risky = evaluateChallengeWithdrawal(riskyPacket);
+
+function writeJson(name, value) {
+ fs.writeFileSync(path.join(reportsDir, name), `${JSON.stringify(value, null, 2)}\n`);
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function findingTable(report) {
+ return report.findings
+ .slice(0, 12)
+ .map((finding) => `| ${finding.severity} | ${finding.code} | ${finding.action} |`)
+ .join("\n");
+}
+
+function writeHandoff(report) {
+ const lines = [
+ "# Challenge Withdrawal Reimbursement Handoff",
+ "",
+ `Decision: ${report.summary.decision}`,
+ `Affected solvers: ${report.summary.affectedSolvers}`,
+ `Recommended reimbursement: $${report.summary.recommendedReimbursementUsd.toFixed(2)}`,
+ `Funded shortfall: $${report.summary.fundedShortfallUsd.toFixed(2)}`,
+ `Audit digest: ${report.summary.auditDigest}`,
+ "",
+ "## Priority Findings",
+ "",
+ "| Severity | Code | Remediation |",
+ "| --- | --- | --- |",
+ findingTable(report),
+ "",
+ "## Team Recommendations",
+ "",
+ ...report.challenges.flatMap((challenge) => [
+ `### ${challenge.id}`,
+ "",
+ "| Team | Recommended | Recorded | Shortfall | Action |",
+ "| --- | ---: | ---: | ---: | --- |",
+ ...challenge.recommendations.map((item) => (
+ `| ${item.teamId} | $${item.recommendedUsd.toFixed(2)} | $${item.recordedUsd.toFixed(2)} | $${item.shortfallUsd.toFixed(2)} | ${item.action} |`
+ )),
+ ""
+ ])
+ ];
+ fs.writeFileSync(path.join(reportsDir, "risky-withdrawal-handoff.md"), `${lines.join("\n")}\n`);
+}
+
+function writeSvg(cleanReport, riskyReport) {
+ const width = 960;
+ const height = 540;
+ const cleanBar = Math.round((cleanReport.summary.recommendedReimbursementUsd / 2600) * 300);
+ const riskyBar = Math.round((riskyReport.summary.recommendedReimbursementUsd / 2600) * 300);
+ const shortfallBar = Math.round((riskyReport.summary.fundedShortfallUsd / 2600) * 300);
+ const rows = riskyReport.findings.slice(0, 8).map((finding, index) => {
+ const y = 250 + index * 26;
+ const color = finding.severity === "critical" ? "#991b1b" : finding.severity === "high" ? "#dc2626" : "#d97706";
+ return `${escapeXml(finding.code)}`;
+ }).join("\n");
+
+ const svg = `
+`;
+ fs.writeFileSync(path.join(reportsDir, "withdrawal-dashboard.svg"), svg);
+}
+
+writeJson("clean-withdrawal-report.json", clean);
+writeJson("risky-withdrawal-report.json", risky);
+writeHandoff(risky);
+writeSvg(clean, risky);
+
+console.log("Wrote challenge withdrawal reimbursement guard reports:");
+console.log(`- ${path.join(reportsDir, "clean-withdrawal-report.json")}`);
+console.log(`- ${path.join(reportsDir, "risky-withdrawal-report.json")}`);
+console.log(`- ${path.join(reportsDir, "risky-withdrawal-handoff.md")}`);
+console.log(`- ${path.join(reportsDir, "withdrawal-dashboard.svg")}`);
diff --git a/challenge-withdrawal-reimbursement-guard/index.js b/challenge-withdrawal-reimbursement-guard/index.js
new file mode 100644
index 00000000..0987ae0b
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/index.js
@@ -0,0 +1,426 @@
+const crypto = require("node:crypto");
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function stableJson(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableJson).join(",")}]`;
+ }
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function sha256(value) {
+ return crypto.createHash("sha256").update(stableJson(value)).digest("hex");
+}
+
+function toDate(value) {
+ const parsed = new Date(value || "");
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function hoursBetween(laterValue, earlierValue) {
+ const later = toDate(laterValue);
+ const earlier = toDate(earlierValue);
+ if (!later || !earlier) {
+ return null;
+ }
+ return Math.round((later.getTime() - earlier.getTime()) / (60 * 60 * 1000));
+}
+
+function daysBetween(laterValue, earlierValue) {
+ const later = toDate(laterValue);
+ const earlier = toDate(earlierValue);
+ if (!later || !earlier) {
+ return null;
+ }
+ return Math.floor((later.getTime() - earlier.getTime()) / (24 * 60 * 60 * 1000));
+}
+
+function roundMoney(value) {
+ return Math.round((Number(value) || 0) * 100) / 100;
+}
+
+function severityRank(severity) {
+ return { critical: 4, high: 3, medium: 2, low: 1 }[severity] || 0;
+}
+
+function addFinding(findings, severity, code, message, refs, action) {
+ findings.push({
+ severity,
+ code,
+ message,
+ refs: asArray(refs),
+ action
+ });
+}
+
+function normalizeString(value) {
+ return String(value || "").trim().toLowerCase();
+}
+
+function noticeByTeam(challenge, teamId) {
+ return asArray(challenge.communications && challenge.communications.solverNotices)
+ .find((notice) => normalizeString(notice.teamId) === normalizeString(teamId));
+}
+
+function reimbursementByTeam(challenge, teamId) {
+ return asArray(challenge.reimbursements)
+ .find((payment) => normalizeString(payment.teamId) === normalizeString(teamId));
+}
+
+function solverStartedBeforeCancellation(solver, cancellation) {
+ const startedAt = toDate(solver.startedAt || solver.acceptedAt);
+ const requestedAt = toDate(cancellation && cancellation.requestedAt);
+ if (!startedAt || !requestedAt) {
+ return false;
+ }
+ return startedAt.getTime() <= requestedAt.getTime();
+}
+
+function evidenceMissing(record) {
+ return !record || !record.evidenceHash || String(record.evidenceHash).length < 12;
+}
+
+function receiptMissing(record) {
+ return !record || !record.receiptHash || String(record.receiptHash).length < 12;
+}
+
+function sumAmounts(records) {
+ return roundMoney(asArray(records).reduce((total, record) => total + (Number(record.amountUsd) || 0), 0));
+}
+
+function calculateTeamRecommendation(challenge, solver, policy) {
+ const bountyAmount = Number(challenge.bounty && challenge.bounty.amountUsd) || 0;
+ const reimbursementCap = Math.min(
+ Number(challenge.bounty && challenge.bounty.reimbursementCapUsd) || Number.POSITIVE_INFINITY,
+ bountyAmount * Number(policy.maxReimbursementPercent || 0.35)
+ );
+ const documentedCosts = sumAmounts(asArray(solver.costClaims).filter((claim) => !evidenceMissing(claim)));
+ const irreversibleCosts = sumAmounts(asArray(solver.costClaims).filter((claim) => claim.nonRefundable === true && !evidenceMissing(claim)));
+ const progressPercent = Math.max(0, Math.min(100, Number(solver.milestoneProgressPercent) || 0));
+ const milestoneCredit = roundMoney(bountyAmount * (progressPercent / 100) * Number(policy.milestoneCreditRate || 0.18));
+ const rawRecommendation = roundMoney(irreversibleCosts + milestoneCredit);
+ const recommendedUsd = roundMoney(Math.min(rawRecommendation, reimbursementCap));
+
+ return {
+ teamId: solver.teamId,
+ documentedCostsUsd: documentedCosts,
+ irreversibleCostsUsd: irreversibleCosts,
+ milestoneCreditUsd: milestoneCredit,
+ reimbursementCapUsd: roundMoney(reimbursementCap),
+ recommendedUsd
+ };
+}
+
+function evaluateChallenge(challenge, policy, reviewDate, findings) {
+ const cancellation = challenge.cancellation || {};
+ const bountyAmount = Number(challenge.bounty && challenge.bounty.amountUsd) || 0;
+ const materialReduction = Number(cancellation.materialReductionPercent) || 0;
+ const isWithdrawal = ["withdrawn", "cancelled", "canceled", "reduced"].includes(normalizeString(challenge.status))
+ || ["withdrawal", "cancellation", "material_reduction"].includes(normalizeString(cancellation.type))
+ || materialReduction >= Number(policy.materialReductionThresholdPercent || 25);
+ const startedSolvers = asArray(challenge.solvers)
+ .filter((solver) => solverStartedBeforeCancellation(solver, cancellation));
+ const recommendations = [];
+
+ if (!isWithdrawal) {
+ return {
+ id: challenge.id,
+ status: challenge.status || "active",
+ action: "not_a_withdrawal",
+ affectedSolvers: 0,
+ recommendedReimbursementUsd: 0,
+ fundedShortfallUsd: 0,
+ recommendations
+ };
+ }
+
+ if (!cancellation.authorizedBy || challenge.sponsor && challenge.sponsor.cancellationAuthority !== true) {
+ addFinding(
+ findings,
+ "critical",
+ "CANCELLATION_AUTHORITY_MISSING",
+ `${challenge.id || "Challenge"} does not show a sponsor-authorized cancellation decision.`,
+ [challenge.id || "challenge"],
+ "verify_sponsor_cancellation_authority_before_closeout"
+ );
+ }
+
+ if (!cancellation.reason || String(cancellation.reason).length < 20) {
+ addFinding(
+ findings,
+ "medium",
+ "WITHDRAWAL_REASON_TOO_THIN",
+ `${challenge.id || "Challenge"} has no reviewer-ready reason for withdrawal or material reduction.`,
+ [challenge.id || "challenge"],
+ "record_specific_withdrawal_reason_for_audit"
+ );
+ }
+
+ if (startedSolvers.length === 0 && materialReduction >= Number(policy.materialReductionThresholdPercent || 25)) {
+ addFinding(
+ findings,
+ "medium",
+ "MATERIAL_REDUCTION_WITHOUT_SOLVER_SCAN",
+ `${challenge.id || "Challenge"} is materially reduced but has no solver-start scan attached.`,
+ [challenge.id || "challenge"],
+ "attach_solver_start_scan_before_scope_reduction"
+ );
+ }
+
+ const directNotices = startedSolvers.map((solver) => noticeByTeam(challenge, solver.teamId)).filter(Boolean);
+ const earliestNotice = directNotices
+ .map((notice) => notice.sentAt)
+ .filter(Boolean)
+ .sort()[0] || (challenge.communications && challenge.communications.sponsorNoticeAt);
+
+ for (const solver of startedSolvers) {
+ const refs = [challenge.id || "challenge", solver.teamId || "solver"];
+ const notice = noticeByTeam(challenge, solver.teamId);
+
+ if (!notice || !notice.sentAt) {
+ addFinding(
+ findings,
+ "high",
+ "SOLVER_WITHDRAWAL_NOTICE_MISSING",
+ `${solver.teamId || "Solver"} has no direct withdrawal notice.`,
+ refs,
+ "send_equal_withdrawal_notice_to_all_started_solvers"
+ );
+ } else {
+ const lag = hoursBetween(notice.sentAt, earliestNotice);
+ if (lag !== null && lag > Number(policy.equalNoticeToleranceHours || 12)) {
+ addFinding(
+ findings,
+ "high",
+ "NOTICE_PARITY_GAP",
+ `${solver.teamId || "Solver"} received withdrawal notice ${lag} hours after the first notified solver.`,
+ refs,
+ "restart_closeout_clock_after_equal_solver_notice"
+ );
+ }
+ if (notice.includesAppealWindow !== true) {
+ addFinding(
+ findings,
+ "medium",
+ "APPEAL_WINDOW_NOT_IN_NOTICE",
+ `${solver.teamId || "Solver"} notice omits the appeal window.`,
+ refs,
+ "include_appeal_deadline_in_solver_notice"
+ );
+ }
+ if (notice.includesCostClaimLink !== true) {
+ addFinding(
+ findings,
+ "high",
+ "COST_CLAIM_PATH_NOT_IN_NOTICE",
+ `${solver.teamId || "Solver"} notice omits the cost-claim path.`,
+ refs,
+ "include_solver_cost_claim_path_in_notice"
+ );
+ }
+ }
+
+ if (!solver.milestoneProgressPercent && asArray(solver.submittedMilestones).length === 0) {
+ addFinding(
+ findings,
+ "medium",
+ "MILESTONE_PROGRESS_EVIDENCE_MISSING",
+ `${solver.teamId || "Solver"} has no milestone-progress evidence for reimbursement review.`,
+ refs,
+ "attach_milestone_progress_evidence_before_reimbursement_decision"
+ );
+ }
+
+ for (const milestone of asArray(solver.submittedMilestones)) {
+ if (evidenceMissing(milestone)) {
+ addFinding(
+ findings,
+ "high",
+ "MILESTONE_EVIDENCE_HASH_MISSING",
+ `${solver.teamId || "Solver"} milestone ${milestone.id || "unknown"} lacks an evidence hash.`,
+ refs,
+ "hash_milestone_evidence_before_solver_cost_review"
+ );
+ }
+ }
+
+ for (const claim of asArray(solver.costClaims)) {
+ if (evidenceMissing(claim)) {
+ addFinding(
+ findings,
+ "high",
+ "COST_CLAIM_EVIDENCE_MISSING",
+ `${solver.teamId || "Solver"} cost claim ${claim.id || "unknown"} lacks evidence.`,
+ refs,
+ "attach_cost_claim_evidence_before_reimbursement"
+ );
+ }
+ if (claim.nonRefundable === true && policy.irreversibleSpendRequiresReceipt !== false && receiptMissing(claim)) {
+ addFinding(
+ findings,
+ "high",
+ "NONREFUNDABLE_RECEIPT_MISSING",
+ `${solver.teamId || "Solver"} non-refundable cost claim ${claim.id || "unknown"} lacks a receipt hash.`,
+ refs,
+ "attach_receipt_hash_for_nonrefundable_solver_spend"
+ );
+ }
+ }
+
+ if (!solver.ipReturn || !solver.ipReturn.returnedAt || !solver.ipReturn.dataDestroyedAt || !solver.ipReturn.attestationHash) {
+ addFinding(
+ findings,
+ "medium",
+ "IP_DATA_RETURN_ATTESTATION_MISSING",
+ `${solver.teamId || "Solver"} has no complete IP return and data-destruction attestation.`,
+ refs,
+ "collect_ip_return_and_data_destruction_attestation"
+ );
+ }
+
+ const recommendation = calculateTeamRecommendation(challenge, solver, policy);
+ const recorded = reimbursementByTeam(challenge, solver.teamId);
+ const recordedAmount = Number(recorded && recorded.amountUsd) || 0;
+ recommendation.recordedUsd = roundMoney(recordedAmount);
+ recommendation.shortfallUsd = roundMoney(Math.max(0, recommendation.recommendedUsd - recordedAmount));
+ recommendation.action = recommendation.shortfallUsd > 0 ? "hold_and_reimburse_solver_costs" : "release_reimbursement_closeout";
+ recommendations.push(recommendation);
+
+ if (recommendation.recommendedUsd > 0 && !recorded) {
+ addFinding(
+ findings,
+ "high",
+ "REIMBURSEMENT_DECISION_MISSING",
+ `${solver.teamId || "Solver"} has an eligible reimbursement recommendation but no recorded decision.`,
+ refs,
+ "record_solver_reimbursement_decision_before_challenge_closeout"
+ );
+ } else if (recommendation.shortfallUsd > 0) {
+ addFinding(
+ findings,
+ "high",
+ "REIMBURSEMENT_SHORTFALL",
+ `${solver.teamId || "Solver"} reimbursement is short by $${recommendation.shortfallUsd.toFixed(2)}.`,
+ refs,
+ "fund_reimbursement_shortfall_or_escalate_dispute"
+ );
+ }
+ }
+
+ const holdDays = daysBetween(cancellation.disputeHoldUntil, cancellation.requestedAt);
+ if (holdDays === null || holdDays < Number(policy.disputeHoldDays || 7)) {
+ addFinding(
+ findings,
+ "critical",
+ "DISPUTE_HOLD_WINDOW_TOO_SHORT",
+ `${challenge.id || "Challenge"} has a dispute hold of ${holdDays === null ? "none" : `${holdDays} days`}.`,
+ [challenge.id || "challenge"],
+ "extend_dispute_hold_before_refund_or_closeout"
+ );
+ }
+
+ if (!cancellation.appealDeadline) {
+ addFinding(
+ findings,
+ "high",
+ "APPEAL_DEADLINE_MISSING",
+ `${challenge.id || "Challenge"} has no solver appeal deadline.`,
+ [challenge.id || "challenge"],
+ "set_solver_appeal_deadline_before_withdrawal_closeout"
+ );
+ }
+
+ const totalRecommended = roundMoney(recommendations.reduce((total, item) => total + item.recommendedUsd, 0));
+ const reserve = Number(challenge.sponsor && challenge.sponsor.reimbursementReserveUsd) || 0;
+ const fundedShortfall = roundMoney(Math.max(0, totalRecommended - reserve));
+
+ if (policy.requireSponsorFundedReserve !== false && fundedShortfall > 0) {
+ addFinding(
+ findings,
+ "critical",
+ "REIMBURSEMENT_RESERVE_SHORTFALL",
+ `${challenge.id || "Challenge"} reserve is short by $${fundedShortfall.toFixed(2)} for solver reimbursement.`,
+ [challenge.id || "challenge"],
+ "fund_solver_reimbursement_reserve_before_releasing_sponsor_refund"
+ );
+ }
+
+ const closeoutAction = recommendations.some((item) => item.shortfallUsd > 0) || fundedShortfall > 0
+ ? "hold_for_solver_reimbursement"
+ : "release_withdrawal_closeout";
+
+ return {
+ id: challenge.id,
+ status: challenge.status || "withdrawn",
+ bountyAmountUsd: roundMoney(bountyAmount),
+ action: closeoutAction,
+ affectedSolvers: startedSolvers.length,
+ recommendedReimbursementUsd: totalRecommended,
+ fundedShortfallUsd: fundedShortfall,
+ recommendations
+ };
+}
+
+function evaluateChallengeWithdrawal(packet) {
+ const findings = [];
+ const reviewDate = packet.reviewDate || new Date().toISOString().slice(0, 10);
+ const policy = {
+ equalNoticeToleranceHours: 12,
+ disputeHoldDays: 7,
+ maxReimbursementPercent: 0.35,
+ materialReductionThresholdPercent: 25,
+ milestoneCreditRate: 0.18,
+ irreversibleSpendRequiresReceipt: true,
+ requireSponsorFundedReserve: true,
+ ...(packet.policy || {})
+ };
+ const challengeSummaries = asArray(packet.challenges).map((challenge) => (
+ evaluateChallenge(challenge, policy, reviewDate, findings)
+ ));
+ const criticalFindings = findings.filter((finding) => finding.severity === "critical").length;
+ const highOrCriticalFindings = findings.filter((finding) => severityRank(finding.severity) >= severityRank("high")).length;
+ const recommendedReimbursementUsd = roundMoney(challengeSummaries.reduce((total, item) => total + item.recommendedReimbursementUsd, 0));
+ const fundedShortfallUsd = roundMoney(challengeSummaries.reduce((total, item) => total + item.fundedShortfallUsd, 0));
+ const affectedSolvers = challengeSummaries.reduce((total, item) => total + item.affectedSolvers, 0);
+ let decision = "release_withdrawal_closeout";
+
+ if (criticalFindings > 0 || fundedShortfallUsd > 0) {
+ decision = "escalate_withdrawal_dispute";
+ } else if (highOrCriticalFindings > 0) {
+ decision = "hold_for_reimbursement_review";
+ }
+
+ const auditSubject = {
+ reviewDate,
+ policy,
+ challengeSummaries,
+ findingCodes: findings.map((finding) => finding.code).sort()
+ };
+
+ return {
+ summary: {
+ decision,
+ challengeCount: challengeSummaries.length,
+ affectedSolvers,
+ recommendedReimbursementUsd,
+ fundedShortfallUsd,
+ findingCount: findings.length,
+ criticalFindings,
+ highOrCriticalFindings,
+ auditDigest: `sha256:${sha256(auditSubject)}`
+ },
+ challenges: challengeSummaries,
+ findings: findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.code.localeCompare(b.code))
+ };
+}
+
+module.exports = {
+ evaluateChallengeWithdrawal,
+ sha256
+};
diff --git a/challenge-withdrawal-reimbursement-guard/make-demo-video.js b/challenge-withdrawal-reimbursement-guard/make-demo-video.js
new file mode 100644
index 00000000..67f755db
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/make-demo-video.js
@@ -0,0 +1,98 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+const { evaluateChallengeWithdrawal } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+const framesDir = path.join(reportsDir, "frames");
+fs.mkdirSync(framesDir, { recursive: true });
+
+const clean = evaluateChallengeWithdrawal(cleanPacket);
+const risky = evaluateChallengeWithdrawal(riskyPacket);
+const width = 960;
+const height = 540;
+const frames = 72;
+const fps = 18;
+
+function setPixel(buffer, x, y, r, g, b) {
+ if (x < 0 || y < 0 || x >= width || y >= height) {
+ return;
+ }
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+}
+
+function fillRect(buffer, x, y, w, h, r, g, b) {
+ for (let row = y; row < y + h; row += 1) {
+ for (let col = x; col < x + w; col += 1) {
+ setPixel(buffer, col, row, r, g, b);
+ }
+ }
+}
+
+function drawColumns(buffer, x, baseline, count, color) {
+ for (let index = 0; index < count; index += 1) {
+ const barHeight = 26 + (index % 6) * 17;
+ fillRect(buffer, x + index * 26, baseline - barHeight, 18, barHeight, color[0], color[1], color[2]);
+ }
+}
+
+function writeFrame(index, progress) {
+ const buffer = Buffer.alloc(width * height * 3, 248);
+ fillRect(buffer, 0, 0, width, height, 248, 250, 252);
+ fillRect(buffer, 48, 44, 864, 452, 255, 255, 255);
+ fillRect(buffer, 48, 44, 864, 8, 15, 23, 42);
+
+ const cleanWidth = Math.floor(300 * Math.min(1, progress * 1.5) * (clean.summary.recommendedReimbursementUsd / 2600));
+ const riskyWidth = Math.floor(300 * Math.max(0, (progress - 0.08) * 1.4) * (risky.summary.recommendedReimbursementUsd / 2600));
+ const shortfallWidth = Math.floor(300 * Math.max(0, (progress - 0.18) * 1.3) * (risky.summary.fundedShortfallUsd / 2600));
+
+ fillRect(buffer, 96, 126, 300, 42, 226, 232, 240);
+ fillRect(buffer, 96, 126, cleanWidth, 42, 16, 185, 129);
+ fillRect(buffer, 96, 222, 300, 42, 226, 232, 240);
+ fillRect(buffer, 96, 222, riskyWidth, 42, 239, 68, 68);
+ fillRect(buffer, 96, 318, 300, 42, 226, 232, 240);
+ fillRect(buffer, 96, 318, shortfallWidth, 42, 245, 158, 11);
+
+ for (let i = 0; i < risky.summary.affectedSolvers; i += 1) {
+ fillRect(buffer, 112 + i * 78, 404, 52, 52, 99, 102, 241);
+ fillRect(buffer, 122 + i * 78, 416, 32, 8, 255, 255, 255);
+ fillRect(buffer, 122 + i * 78, 434, 32, 8, 255, 255, 255);
+ }
+
+ drawColumns(buffer, 536, 408, Math.min(12, risky.summary.findingCount), [220, 38, 38]);
+ drawColumns(buffer, 536, 210, clean.summary.findingCount, [16, 185, 129]);
+ fillRect(buffer, 536, 436, Math.floor(310 * progress), 14, 37, 99, 235);
+
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii");
+ fs.writeFileSync(path.join(framesDir, `frame-${String(index).padStart(3, "0")}.ppm`), Buffer.concat([header, buffer]));
+}
+
+for (let index = 0; index < frames; index += 1) {
+ writeFrame(index, index / (frames - 1));
+}
+
+const output = path.join(reportsDir, "demo.mp4");
+const result = spawnSync(process.env.FFMPEG_PATH || "ffmpeg", [
+ "-y",
+ "-framerate",
+ String(fps),
+ "-i",
+ path.join(framesDir, "frame-%03d.ppm"),
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ output
+], { stdio: "inherit" });
+
+fs.rmSync(framesDir, { recursive: true, force: true });
+
+if (result.status !== 0) {
+ process.exit(result.status || 1);
+}
+
+console.log(`Wrote ${output}`);
diff --git a/challenge-withdrawal-reimbursement-guard/package.json b/challenge-withdrawal-reimbursement-guard/package.json
new file mode 100644
index 00000000..45f377ee
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "challenge-withdrawal-reimbursement-guard",
+ "version": "1.0.0",
+ "description": "Dependency-free challenge withdrawal and solver-cost reimbursement guard for SCIBASE scientific bounty workflows.",
+ "main": "index.js",
+ "scripts": {
+ "check": "node test.js",
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "make-demo-video": "node make-demo-video.js",
+ "verify-video": "node verify-video.js"
+ },
+ "license": "MIT",
+ "private": true
+}
diff --git a/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json b/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json
new file mode 100644
index 00000000..a4a2adb7
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json
@@ -0,0 +1,49 @@
+{
+ "summary": {
+ "decision": "release_withdrawal_closeout",
+ "challengeCount": 1,
+ "affectedSolvers": 2,
+ "recommendedReimbursementUsd": 1076,
+ "fundedShortfallUsd": 0,
+ "findingCount": 0,
+ "criticalFindings": 0,
+ "highOrCriticalFindings": 0,
+ "auditDigest": "sha256:11141c6fc5ef8c4eb1259853baad8680c7925329d6be3f4dfa10c347fb95d4c4"
+ },
+ "challenges": [
+ {
+ "id": "BNTY-WITHDRAW-ALPHA",
+ "status": "withdrawn",
+ "bountyAmountUsd": 6000,
+ "action": "release_withdrawal_closeout",
+ "affectedSolvers": 2,
+ "recommendedReimbursementUsd": 1076,
+ "fundedShortfallUsd": 0,
+ "recommendations": [
+ {
+ "teamId": "team-curie",
+ "documentedCostsUsd": 180,
+ "irreversibleCostsUsd": 180,
+ "milestoneCreditUsd": 453.6,
+ "reimbursementCapUsd": 2100,
+ "recommendedUsd": 633.6,
+ "recordedUsd": 633.6,
+ "shortfallUsd": 0,
+ "action": "release_reimbursement_closeout"
+ },
+ {
+ "teamId": "team-noether",
+ "documentedCostsUsd": 140,
+ "irreversibleCostsUsd": 140,
+ "milestoneCreditUsd": 302.4,
+ "reimbursementCapUsd": 2100,
+ "recommendedUsd": 442.4,
+ "recordedUsd": 442.4,
+ "shortfallUsd": 0,
+ "action": "release_reimbursement_closeout"
+ }
+ ]
+ }
+ ],
+ "findings": []
+}
diff --git a/challenge-withdrawal-reimbursement-guard/reports/demo.mp4 b/challenge-withdrawal-reimbursement-guard/reports/demo.mp4
new file mode 100644
index 00000000..2711fe84
Binary files /dev/null and b/challenge-withdrawal-reimbursement-guard/reports/demo.mp4 differ
diff --git a/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md
new file mode 100644
index 00000000..3bd0796f
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md
@@ -0,0 +1,35 @@
+# Challenge Withdrawal Reimbursement Handoff
+
+Decision: escalate_withdrawal_dispute
+Affected solvers: 3
+Recommended reimbursement: $2586.00
+Funded shortfall: $2286.00
+Audit digest: sha256:af101fe0baa90544d8acc9069ccfa1524ccfc6c133806eeff1bb088088a48cb1
+
+## Priority Findings
+
+| Severity | Code | Remediation |
+| --- | --- | --- |
+| critical | CANCELLATION_AUTHORITY_MISSING | verify_sponsor_cancellation_authority_before_closeout |
+| critical | DISPUTE_HOLD_WINDOW_TOO_SHORT | extend_dispute_hold_before_refund_or_closeout |
+| critical | REIMBURSEMENT_RESERVE_SHORTFALL | fund_solver_reimbursement_reserve_before_releasing_sponsor_refund |
+| high | APPEAL_DEADLINE_MISSING | set_solver_appeal_deadline_before_withdrawal_closeout |
+| high | COST_CLAIM_EVIDENCE_MISSING | attach_cost_claim_evidence_before_reimbursement |
+| high | COST_CLAIM_PATH_NOT_IN_NOTICE | include_solver_cost_claim_path_in_notice |
+| high | MILESTONE_EVIDENCE_HASH_MISSING | hash_milestone_evidence_before_solver_cost_review |
+| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend |
+| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend |
+| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend |
+| high | NOTICE_PARITY_GAP | restart_closeout_clock_after_equal_solver_notice |
+| high | REIMBURSEMENT_DECISION_MISSING | record_solver_reimbursement_decision_before_challenge_closeout |
+
+## Team Recommendations
+
+### BNTY-WITHDRAW-RISK
+
+| Team | Recommended | Recorded | Shortfall | Action |
+| --- | ---: | ---: | ---: | --- |
+| team-lovelace | $1449.00 | $500.00 | $949.00 | hold_and_reimburse_solver_costs |
+| team-hopper | $634.00 | $0.00 | $634.00 | hold_and_reimburse_solver_costs |
+| team-johnson | $503.00 | $0.00 | $503.00 | hold_and_reimburse_solver_costs |
+
diff --git a/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json
new file mode 100644
index 00000000..1c304c62
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json
@@ -0,0 +1,266 @@
+{
+ "summary": {
+ "decision": "escalate_withdrawal_dispute",
+ "challengeCount": 1,
+ "affectedSolvers": 3,
+ "recommendedReimbursementUsd": 2586,
+ "fundedShortfallUsd": 2286,
+ "findingCount": 21,
+ "criticalFindings": 3,
+ "highOrCriticalFindings": 15,
+ "auditDigest": "sha256:af101fe0baa90544d8acc9069ccfa1524ccfc6c133806eeff1bb088088a48cb1"
+ },
+ "challenges": [
+ {
+ "id": "BNTY-WITHDRAW-RISK",
+ "status": "canceled",
+ "bountyAmountUsd": 9000,
+ "action": "hold_for_solver_reimbursement",
+ "affectedSolvers": 3,
+ "recommendedReimbursementUsd": 2586,
+ "fundedShortfallUsd": 2286,
+ "recommendations": [
+ {
+ "teamId": "team-lovelace",
+ "documentedCostsUsd": 720,
+ "irreversibleCostsUsd": 720,
+ "milestoneCreditUsd": 729,
+ "reimbursementCapUsd": 2600,
+ "recommendedUsd": 1449,
+ "recordedUsd": 500,
+ "shortfallUsd": 949,
+ "action": "hold_and_reimburse_solver_costs"
+ },
+ {
+ "teamId": "team-hopper",
+ "documentedCostsUsd": 310,
+ "irreversibleCostsUsd": 310,
+ "milestoneCreditUsd": 324,
+ "reimbursementCapUsd": 2600,
+ "recommendedUsd": 634,
+ "recordedUsd": 0,
+ "shortfallUsd": 634,
+ "action": "hold_and_reimburse_solver_costs"
+ },
+ {
+ "teamId": "team-johnson",
+ "documentedCostsUsd": 260,
+ "irreversibleCostsUsd": 260,
+ "milestoneCreditUsd": 243,
+ "reimbursementCapUsd": 2600,
+ "recommendedUsd": 503,
+ "recordedUsd": 0,
+ "shortfallUsd": 503,
+ "action": "hold_and_reimburse_solver_costs"
+ }
+ ]
+ }
+ ],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "CANCELLATION_AUTHORITY_MISSING",
+ "message": "BNTY-WITHDRAW-RISK does not show a sponsor-authorized cancellation decision.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK"
+ ],
+ "action": "verify_sponsor_cancellation_authority_before_closeout"
+ },
+ {
+ "severity": "critical",
+ "code": "DISPUTE_HOLD_WINDOW_TOO_SHORT",
+ "message": "BNTY-WITHDRAW-RISK has a dispute hold of 4 days.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK"
+ ],
+ "action": "extend_dispute_hold_before_refund_or_closeout"
+ },
+ {
+ "severity": "critical",
+ "code": "REIMBURSEMENT_RESERVE_SHORTFALL",
+ "message": "BNTY-WITHDRAW-RISK reserve is short by $2286.00 for solver reimbursement.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK"
+ ],
+ "action": "fund_solver_reimbursement_reserve_before_releasing_sponsor_refund"
+ },
+ {
+ "severity": "high",
+ "code": "APPEAL_DEADLINE_MISSING",
+ "message": "BNTY-WITHDRAW-RISK has no solver appeal deadline.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK"
+ ],
+ "action": "set_solver_appeal_deadline_before_withdrawal_closeout"
+ },
+ {
+ "severity": "high",
+ "code": "COST_CLAIM_EVIDENCE_MISSING",
+ "message": "team-lovelace cost claim annotation-vendor lacks evidence.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-lovelace"
+ ],
+ "action": "attach_cost_claim_evidence_before_reimbursement"
+ },
+ {
+ "severity": "high",
+ "code": "COST_CLAIM_PATH_NOT_IN_NOTICE",
+ "message": "team-lovelace notice omits the cost-claim path.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-lovelace"
+ ],
+ "action": "include_solver_cost_claim_path_in_notice"
+ },
+ {
+ "severity": "high",
+ "code": "MILESTONE_EVIDENCE_HASH_MISSING",
+ "message": "team-lovelace milestone baseline-model lacks an evidence hash.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-lovelace"
+ ],
+ "action": "hash_milestone_evidence_before_solver_cost_review"
+ },
+ {
+ "severity": "high",
+ "code": "NONREFUNDABLE_RECEIPT_MISSING",
+ "message": "team-lovelace non-refundable cost claim gpu-reservation lacks a receipt hash.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-lovelace"
+ ],
+ "action": "attach_receipt_hash_for_nonrefundable_solver_spend"
+ },
+ {
+ "severity": "high",
+ "code": "NONREFUNDABLE_RECEIPT_MISSING",
+ "message": "team-lovelace non-refundable cost claim annotation-vendor lacks a receipt hash.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-lovelace"
+ ],
+ "action": "attach_receipt_hash_for_nonrefundable_solver_spend"
+ },
+ {
+ "severity": "high",
+ "code": "NONREFUNDABLE_RECEIPT_MISSING",
+ "message": "team-hopper non-refundable cost claim secure-workspace lacks a receipt hash.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-hopper"
+ ],
+ "action": "attach_receipt_hash_for_nonrefundable_solver_spend"
+ },
+ {
+ "severity": "high",
+ "code": "NOTICE_PARITY_GAP",
+ "message": "team-hopper received withdrawal notice 20 hours after the first notified solver.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-hopper"
+ ],
+ "action": "restart_closeout_clock_after_equal_solver_notice"
+ },
+ {
+ "severity": "high",
+ "code": "REIMBURSEMENT_DECISION_MISSING",
+ "message": "team-hopper has an eligible reimbursement recommendation but no recorded decision.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-hopper"
+ ],
+ "action": "record_solver_reimbursement_decision_before_challenge_closeout"
+ },
+ {
+ "severity": "high",
+ "code": "REIMBURSEMENT_DECISION_MISSING",
+ "message": "team-johnson has an eligible reimbursement recommendation but no recorded decision.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-johnson"
+ ],
+ "action": "record_solver_reimbursement_decision_before_challenge_closeout"
+ },
+ {
+ "severity": "high",
+ "code": "REIMBURSEMENT_SHORTFALL",
+ "message": "team-lovelace reimbursement is short by $949.00.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-lovelace"
+ ],
+ "action": "fund_reimbursement_shortfall_or_escalate_dispute"
+ },
+ {
+ "severity": "high",
+ "code": "SOLVER_WITHDRAWAL_NOTICE_MISSING",
+ "message": "team-johnson has no direct withdrawal notice.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-johnson"
+ ],
+ "action": "send_equal_withdrawal_notice_to_all_started_solvers"
+ },
+ {
+ "severity": "medium",
+ "code": "APPEAL_WINDOW_NOT_IN_NOTICE",
+ "message": "team-lovelace notice omits the appeal window.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-lovelace"
+ ],
+ "action": "include_appeal_deadline_in_solver_notice"
+ },
+ {
+ "severity": "medium",
+ "code": "APPEAL_WINDOW_NOT_IN_NOTICE",
+ "message": "team-hopper notice omits the appeal window.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-hopper"
+ ],
+ "action": "include_appeal_deadline_in_solver_notice"
+ },
+ {
+ "severity": "medium",
+ "code": "IP_DATA_RETURN_ATTESTATION_MISSING",
+ "message": "team-lovelace has no complete IP return and data-destruction attestation.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-lovelace"
+ ],
+ "action": "collect_ip_return_and_data_destruction_attestation"
+ },
+ {
+ "severity": "medium",
+ "code": "IP_DATA_RETURN_ATTESTATION_MISSING",
+ "message": "team-hopper has no complete IP return and data-destruction attestation.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-hopper"
+ ],
+ "action": "collect_ip_return_and_data_destruction_attestation"
+ },
+ {
+ "severity": "medium",
+ "code": "IP_DATA_RETURN_ATTESTATION_MISSING",
+ "message": "team-johnson has no complete IP return and data-destruction attestation.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK",
+ "team-johnson"
+ ],
+ "action": "collect_ip_return_and_data_destruction_attestation"
+ },
+ {
+ "severity": "medium",
+ "code": "WITHDRAWAL_REASON_TOO_THIN",
+ "message": "BNTY-WITHDRAW-RISK has no reviewer-ready reason for withdrawal or material reduction.",
+ "refs": [
+ "BNTY-WITHDRAW-RISK"
+ ],
+ "action": "record_specific_withdrawal_reason_for_audit"
+ }
+ ]
+}
diff --git a/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg b/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg
new file mode 100644
index 00000000..fe101681
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg
@@ -0,0 +1,29 @@
+
diff --git a/challenge-withdrawal-reimbursement-guard/sample-data.js b/challenge-withdrawal-reimbursement-guard/sample-data.js
new file mode 100644
index 00000000..f2482b72
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/sample-data.js
@@ -0,0 +1,274 @@
+const cleanPacket = {
+ reviewDate: "2026-06-01",
+ policy: {
+ equalNoticeToleranceHours: 12,
+ disputeHoldDays: 10,
+ maxReimbursementPercent: 0.35,
+ materialReductionThresholdPercent: 25,
+ milestoneCreditRate: 0.18,
+ irreversibleSpendRequiresReceipt: true,
+ requireSponsorFundedReserve: true
+ },
+ challenges: [
+ {
+ id: "BNTY-WITHDRAW-ALPHA",
+ title: "Federated reproducibility benchmark",
+ status: "withdrawn",
+ sponsor: {
+ id: "sponsor-lab-a",
+ cancellationAuthority: true,
+ reimbursementReserveUsd: 2200
+ },
+ bounty: {
+ amountUsd: 6000,
+ reimbursementCapUsd: 2100
+ },
+ cancellation: {
+ type: "withdrawal",
+ requestedAt: "2026-05-28T15:00:00Z",
+ effectiveAt: "2026-06-07T15:00:00Z",
+ reason: "Sponsor lost access to the proprietary validation corpus and withdrew before finalist scoring.",
+ authorizedBy: "sponsor-owner-17",
+ materialReductionPercent: 100,
+ appealDeadline: "2026-06-06T15:00:00Z",
+ disputeHoldUntil: "2026-06-08T15:00:00Z"
+ },
+ communications: {
+ sponsorNoticeAt: "2026-05-28T15:10:00Z",
+ solverNotices: [
+ {
+ teamId: "team-curie",
+ sentAt: "2026-05-28T15:15:00Z",
+ channel: "platform",
+ includesAppealWindow: true,
+ includesCostClaimLink: true
+ },
+ {
+ teamId: "team-noether",
+ sentAt: "2026-05-28T16:00:00Z",
+ channel: "platform",
+ includesAppealWindow: true,
+ includesCostClaimLink: true
+ }
+ ]
+ },
+ solvers: [
+ {
+ teamId: "team-curie",
+ acceptedAt: "2026-05-20T12:00:00Z",
+ startedAt: "2026-05-21T09:00:00Z",
+ milestoneProgressPercent: 42,
+ submittedMilestones: [
+ { id: "design-review", percent: 25, evidenceHash: "sha256:curie-design-review-0a1b2c3d4e" },
+ { id: "baseline-run", percent: 17, evidenceHash: "sha256:curie-baseline-run-0a1b2c3d4e" }
+ ],
+ costClaims: [
+ {
+ id: "cloud-prep",
+ amountUsd: 180,
+ category: "compute",
+ incurredAt: "2026-05-25",
+ nonRefundable: true,
+ evidenceHash: "sha256:curie-cloud-evidence-0a1b2c3d4e",
+ receiptHash: "sha256:curie-cloud-receipt-0a1b2c3d4e"
+ }
+ ],
+ ipReturn: {
+ returnedAt: "2026-05-30T12:00:00Z",
+ dataDestroyedAt: "2026-05-30T12:15:00Z",
+ attestationHash: "sha256:curie-return-attestation-0a1b2c3d4e"
+ }
+ },
+ {
+ teamId: "team-noether",
+ acceptedAt: "2026-05-22T13:30:00Z",
+ startedAt: "2026-05-23T10:00:00Z",
+ milestoneProgressPercent: 28,
+ submittedMilestones: [
+ { id: "replication-plan", percent: 28, evidenceHash: "sha256:noether-plan-evidence-0a1b2c3d4e" }
+ ],
+ costClaims: [
+ {
+ id: "dataset-access",
+ amountUsd: 140,
+ category: "data",
+ incurredAt: "2026-05-24",
+ nonRefundable: true,
+ evidenceHash: "sha256:noether-data-evidence-0a1b2c3d4e",
+ receiptHash: "sha256:noether-data-receipt-0a1b2c3d4e"
+ }
+ ],
+ ipReturn: {
+ returnedAt: "2026-05-30T13:00:00Z",
+ dataDestroyedAt: "2026-05-30T13:20:00Z",
+ attestationHash: "sha256:noether-return-attestation-0a1b2c3d4e"
+ }
+ }
+ ],
+ reimbursements: [
+ {
+ teamId: "team-curie",
+ amountUsd: 633.60,
+ approvedBy: "bounty-ops",
+ approvedAt: "2026-05-31T10:00:00Z"
+ },
+ {
+ teamId: "team-noether",
+ amountUsd: 442.40,
+ approvedBy: "bounty-ops",
+ approvedAt: "2026-05-31T10:05:00Z"
+ }
+ ]
+ }
+ ]
+};
+
+const riskyPacket = {
+ reviewDate: "2026-06-01",
+ policy: {
+ equalNoticeToleranceHours: 8,
+ disputeHoldDays: 10,
+ maxReimbursementPercent: 0.35,
+ materialReductionThresholdPercent: 25,
+ milestoneCreditRate: 0.18,
+ irreversibleSpendRequiresReceipt: true,
+ requireSponsorFundedReserve: true
+ },
+ challenges: [
+ {
+ id: "BNTY-WITHDRAW-RISK",
+ title: "Private clinical signal challenge",
+ status: "canceled",
+ sponsor: {
+ id: "sponsor-clinic-x",
+ cancellationAuthority: false,
+ reimbursementReserveUsd: 300
+ },
+ bounty: {
+ amountUsd: 9000,
+ reimbursementCapUsd: 2600
+ },
+ cancellation: {
+ type: "cancellation",
+ requestedAt: "2026-05-29T11:00:00Z",
+ effectiveAt: "2026-05-30T11:00:00Z",
+ reason: "Budget changed",
+ materialReductionPercent: 100,
+ disputeHoldUntil: "2026-06-02T11:00:00Z"
+ },
+ communications: {
+ sponsorNoticeAt: "2026-05-29T12:00:00Z",
+ solverNotices: [
+ {
+ teamId: "team-lovelace",
+ sentAt: "2026-05-29T13:00:00Z",
+ channel: "email",
+ includesAppealWindow: false,
+ includesCostClaimLink: false
+ },
+ {
+ teamId: "team-hopper",
+ sentAt: "2026-05-30T09:00:00Z",
+ channel: "email",
+ includesAppealWindow: false,
+ includesCostClaimLink: true
+ }
+ ]
+ },
+ solvers: [
+ {
+ teamId: "team-lovelace",
+ acceptedAt: "2026-05-20T12:00:00Z",
+ startedAt: "2026-05-21T08:00:00Z",
+ milestoneProgressPercent: 45,
+ submittedMilestones: [
+ { id: "baseline-model", percent: 35, evidenceHash: "" },
+ { id: "validation-report", percent: 10, evidenceHash: "sha256:lovelace-report-evidence-0a1b2c3d4e" }
+ ],
+ costClaims: [
+ {
+ id: "gpu-reservation",
+ amountUsd: 720,
+ category: "compute",
+ incurredAt: "2026-05-24",
+ nonRefundable: true,
+ evidenceHash: "sha256:lovelace-gpu-evidence-0a1b2c3d4e",
+ receiptHash: ""
+ },
+ {
+ id: "annotation-vendor",
+ amountUsd: 420,
+ category: "annotation",
+ incurredAt: "2026-05-25",
+ nonRefundable: true,
+ evidenceHash: "",
+ receiptHash: ""
+ }
+ ],
+ ipReturn: {
+ returnedAt: "",
+ dataDestroyedAt: "",
+ attestationHash: ""
+ }
+ },
+ {
+ teamId: "team-hopper",
+ acceptedAt: "2026-05-22T10:00:00Z",
+ startedAt: "2026-05-23T09:00:00Z",
+ milestoneProgressPercent: 20,
+ submittedMilestones: [],
+ costClaims: [
+ {
+ id: "secure-workspace",
+ amountUsd: 310,
+ category: "workspace",
+ incurredAt: "2026-05-24",
+ nonRefundable: true,
+ evidenceHash: "sha256:hopper-workspace-evidence-0a1b2c3d4e",
+ receiptHash: ""
+ }
+ ],
+ ipReturn: null
+ },
+ {
+ teamId: "team-johnson",
+ acceptedAt: "2026-05-23T10:00:00Z",
+ startedAt: "2026-05-24T09:00:00Z",
+ milestoneProgressPercent: 15,
+ submittedMilestones: [
+ { id: "error-analysis", percent: 15, evidenceHash: "sha256:johnson-error-analysis-0a1b2c3d4e" }
+ ],
+ costClaims: [
+ {
+ id: "dataset-license",
+ amountUsd: 260,
+ category: "data",
+ incurredAt: "2026-05-25",
+ nonRefundable: true,
+ evidenceHash: "sha256:johnson-license-evidence-0a1b2c3d4e",
+ receiptHash: "sha256:johnson-license-receipt-0a1b2c3d4e"
+ }
+ ],
+ ipReturn: {
+ returnedAt: "2026-05-31T12:00:00Z",
+ dataDestroyedAt: "",
+ attestationHash: ""
+ }
+ }
+ ],
+ reimbursements: [
+ {
+ teamId: "team-lovelace",
+ amountUsd: 500,
+ approvedBy: "sponsor-admin",
+ approvedAt: "2026-05-30T12:00:00Z"
+ }
+ ]
+ }
+ ]
+};
+
+module.exports = {
+ cleanPacket,
+ riskyPacket
+};
diff --git a/challenge-withdrawal-reimbursement-guard/test.js b/challenge-withdrawal-reimbursement-guard/test.js
new file mode 100644
index 00000000..c376b33a
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/test.js
@@ -0,0 +1,44 @@
+const assert = require("node:assert/strict");
+const { evaluateChallengeWithdrawal, sha256 } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const clean = evaluateChallengeWithdrawal(cleanPacket);
+assert.equal(clean.summary.decision, "release_withdrawal_closeout");
+assert.equal(clean.summary.findingCount, 0);
+assert.equal(clean.summary.challengeCount, 1);
+assert.equal(clean.summary.affectedSolvers, 2);
+assert.equal(clean.summary.recommendedReimbursementUsd, 1076);
+assert.equal(clean.summary.fundedShortfallUsd, 0);
+assert.ok(clean.summary.auditDigest.startsWith("sha256:"));
+assert.equal(clean.challenges[0].action, "release_withdrawal_closeout");
+
+const risky = evaluateChallengeWithdrawal(riskyPacket);
+assert.equal(risky.summary.decision, "escalate_withdrawal_dispute");
+assert.equal(risky.summary.challengeCount, 1);
+assert.equal(risky.summary.affectedSolvers, 3);
+assert.equal(risky.summary.recommendedReimbursementUsd, 2586);
+assert.equal(risky.summary.fundedShortfallUsd, 2286);
+assert.ok(risky.summary.findingCount >= 17);
+assert.ok(risky.summary.criticalFindings >= 3);
+assert.ok(risky.summary.highOrCriticalFindings >= 12);
+
+const findingCodes = new Set(risky.findings.map((finding) => finding.code));
+assert.ok(findingCodes.has("CANCELLATION_AUTHORITY_MISSING"));
+assert.ok(findingCodes.has("SOLVER_WITHDRAWAL_NOTICE_MISSING"));
+assert.ok(findingCodes.has("NOTICE_PARITY_GAP"));
+assert.ok(findingCodes.has("COST_CLAIM_PATH_NOT_IN_NOTICE"));
+assert.ok(findingCodes.has("MILESTONE_EVIDENCE_HASH_MISSING"));
+assert.ok(findingCodes.has("COST_CLAIM_EVIDENCE_MISSING"));
+assert.ok(findingCodes.has("NONREFUNDABLE_RECEIPT_MISSING"));
+assert.ok(findingCodes.has("REIMBURSEMENT_SHORTFALL"));
+assert.ok(findingCodes.has("REIMBURSEMENT_DECISION_MISSING"));
+assert.ok(findingCodes.has("DISPUTE_HOLD_WINDOW_TOO_SHORT"));
+assert.ok(findingCodes.has("REIMBURSEMENT_RESERVE_SHORTFALL"));
+assert.ok(findingCodes.has("IP_DATA_RETURN_ATTESTATION_MISSING"));
+
+const firstDigest = evaluateChallengeWithdrawal(riskyPacket).summary.auditDigest;
+const secondDigest = evaluateChallengeWithdrawal(riskyPacket).summary.auditDigest;
+assert.equal(firstDigest, secondDigest);
+assert.equal(sha256({ b: 2, a: 1 }), sha256({ a: 1, b: 2 }));
+
+console.log("challenge withdrawal reimbursement guard tests passed");
diff --git a/challenge-withdrawal-reimbursement-guard/verify-video.js b/challenge-withdrawal-reimbursement-guard/verify-video.js
new file mode 100644
index 00000000..39af983c
--- /dev/null
+++ b/challenge-withdrawal-reimbursement-guard/verify-video.js
@@ -0,0 +1,37 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const videoPath = path.join(__dirname, "reports", "demo.mp4");
+assert.ok(fs.existsSync(videoPath), "reports/demo.mp4 must exist");
+assert.ok(fs.statSync(videoPath).size > 5000, "reports/demo.mp4 should not be empty");
+
+const probe = spawnSync(process.env.FFPROBE_PATH || "ffprobe", [
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=codec_name,width,height,r_frame_rate:format=duration",
+ "-of",
+ "json",
+ videoPath
+], { encoding: "utf8" });
+
+if (probe.status !== 0) {
+ process.stderr.write(probe.stderr || "ffprobe failed\n");
+ process.exit(probe.status || 1);
+}
+
+const metadata = JSON.parse(probe.stdout);
+const stream = metadata.streams && metadata.streams[0];
+assert.equal(stream.codec_name, "h264");
+assert.equal(stream.width, 960);
+assert.equal(stream.height, 540);
+assert.equal(stream.r_frame_rate, "18/1");
+
+const duration = Number(metadata.format && metadata.format.duration);
+assert.ok(duration >= 3.9 && duration <= 4.2, `unexpected duration ${duration}`);
+
+console.log(`demo.mp4 verified: ${stream.codec_name}, ${stream.width}x${stream.height}, ${duration.toFixed(3)}s, ${stream.r_frame_rate}`);