diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/README.md b/scientific-bounty-system/challenge-closeout-retention-guard/README.md
new file mode 100644
index 00000000..62ae82f1
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/README.md
@@ -0,0 +1,34 @@
+# Challenge Closeout Retention Guard
+
+This module adds a focused post-challenge closeout guard for issue #18, Scientific Bounty System.
+
+It validates what happens after a scientific bounty has been awarded, cancelled, or closed without award:
+
+- Sponsor data-room access must be revoked after the closeout deadline.
+- Restricted sponsor datasets must be returned, destroyed, or retained under an explicit legal hold.
+- Solver IP transfer must stay blocked until settlement is funded and recorded.
+- Reviewer/arbitration records must remain retained for the appeal window.
+- Private challenge outputs must be redacted before any public winner announcement.
+- Cancelled or no-award challenges must preserve solver work and compensation evidence.
+
+The guard is dependency-free and uses synthetic records only. It emits deterministic JSON, Markdown, SVG, and MP4 artifacts for reviewer inspection.
+
+## Files
+
+- `guard.js` - closeout policy engine and report renderers.
+- `data/closeout-records.json` - synthetic post-challenge closeout records.
+- `demo.js` - generates JSON, Markdown, and SVG artifacts in `artifacts/`.
+- `make-demo-video.js` - builds a short local MP4 demo with `ffmpeg`.
+- `test.js` - dependency-free regression checks using Node's built-in `assert`.
+
+## Verification
+
+```sh
+node scientific-bounty-system/challenge-closeout-retention-guard/test.js
+node scientific-bounty-system/challenge-closeout-retention-guard/demo.js
+node scientific-bounty-system/challenge-closeout-retention-guard/make-demo-video.js
+```
+
+## Scope Boundary
+
+This is not another intake, rubric-readiness, scoring, arbitration, payout eligibility, sponsor scorecard, IP-redaction preview, data-room access grant, cancellation/no-award, or solver-withdrawal module. It focuses on the final closeout boundary after solver work has ended: revocation, destruction/return evidence, appeal-record retention, settlement-gated IP transfer, and public disclosure readiness.
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.json b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.json
new file mode 100644
index 00000000..197080a0
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.json
@@ -0,0 +1,143 @@
+{
+ "generatedBy": "challenge-closeout-retention-guard",
+ "packetId": "challenge-closeout-demo-001",
+ "generatedAt": "2026-05-28T00:00:00Z",
+ "decision": "hold-closeout",
+ "riskScore": 32,
+ "severityCounts": {
+ "critical": 2,
+ "high": 6,
+ "medium": 2,
+ "low": 0
+ },
+ "challenges": [
+ {
+ "challengeId": "climate-model-prize",
+ "challengeTitle": "Regional climate downscaling model prize",
+ "outcome": "awarded",
+ "daysSinceClose": 8
+ },
+ {
+ "challengeId": "oncology-biomarker-private",
+ "challengeTitle": "Private oncology biomarker discovery challenge",
+ "outcome": "awarded",
+ "daysSinceClose": 14
+ },
+ {
+ "challengeId": "quantum-noise-cancelled",
+ "challengeTitle": "Quantum noise mitigation prototype",
+ "outcome": "cancelled",
+ "daysSinceClose": 3
+ }
+ ],
+ "findings": [
+ {
+ "challengeId": "oncology-biomarker-private",
+ "challengeTitle": "Private oncology biomarker discovery challenge",
+ "code": "active-data-room-access-after-closeout",
+ "severity": "critical",
+ "domain": "access revocation",
+ "evidence": "solver-team-beta still has solver access to sponsor-rnaseq-training-cohort 14 days after closeout; policy requires revocation within 2 days.",
+ "action": "Revoke access, rotate shared credentials, and record the revocation event before closeout is accepted.",
+ "closeoutHold": true
+ },
+ {
+ "challengeId": "oncology-biomarker-private",
+ "challengeTitle": "Private oncology biomarker discovery challenge",
+ "code": "restricted-data-disposition-missing",
+ "severity": "high",
+ "domain": "data retention",
+ "evidence": "sponsor-rnaseq-training-cohort is restricted and has no destruction, return, or legal-hold disposition.",
+ "action": "Collect a destruction certificate, return receipt, or legal-hold order for the restricted dataset.",
+ "closeoutHold": true
+ },
+ {
+ "challengeId": "oncology-biomarker-private",
+ "challengeTitle": "Private oncology biomarker discovery challenge",
+ "code": "destruction-evidence-missing",
+ "severity": "high",
+ "domain": "data retention",
+ "evidence": "sponsor-clinical-labels is marked destroyed without a certificate or audit evidence identifier.",
+ "action": "Attach the destruction certificate hash or reviewer-verifiable evidence identifier.",
+ "closeoutHold": true
+ },
+ {
+ "challengeId": "oncology-biomarker-private",
+ "challengeTitle": "Private oncology biomarker discovery challenge",
+ "code": "ip-transferred-before-funded-settlement",
+ "severity": "critical",
+ "domain": "IP transfer",
+ "evidence": "IP status is transferred while settlement status is pending.",
+ "action": "Reverse the transfer state or block sponsor use until funded settlement is recorded.",
+ "closeoutHold": true
+ },
+ {
+ "challengeId": "oncology-biomarker-private",
+ "challengeTitle": "Private oncology biomarker discovery challenge",
+ "code": "appeal-record-retention-too-short",
+ "severity": "high",
+ "domain": "appeal records",
+ "evidence": "Appeal records are retained for 32 days, below the 60-day policy.",
+ "action": "Extend arbitration and appeal record retention before deleting review evidence.",
+ "closeoutHold": true
+ },
+ {
+ "challengeId": "oncology-biomarker-private",
+ "challengeTitle": "Private oncology biomarker discovery challenge",
+ "code": "private-challenge-disclosure-unredacted",
+ "severity": "high",
+ "domain": "public disclosure",
+ "evidence": "Public disclosure is enabled for a private challenge without approved redactions.",
+ "action": "Block announcement until sponsor data, solver trade secrets, and reviewer identities are redacted.",
+ "closeoutHold": true
+ },
+ {
+ "challengeId": "oncology-biomarker-private",
+ "challengeTitle": "Private oncology biomarker discovery challenge",
+ "code": "closeout-audit-digest-unsigned",
+ "severity": "medium",
+ "domain": "audit evidence",
+ "evidence": "Closeout audit digest status is draft.",
+ "action": "Sign the closeout digest after revocation, retention, and settlement checks complete.",
+ "closeoutHold": false
+ },
+ {
+ "challengeId": "quantum-noise-cancelled",
+ "challengeTitle": "Quantum noise mitigation prototype",
+ "code": "cancelled-challenge-compensation-missing",
+ "severity": "high",
+ "domain": "solver protection",
+ "evidence": "The challenge was cancelled after work started, but no partial-compensation decision is recorded.",
+ "action": "Record partial compensation, refund, or no-compensation rationale before final closeout.",
+ "closeoutHold": true
+ },
+ {
+ "challengeId": "quantum-noise-cancelled",
+ "challengeTitle": "Quantum noise mitigation prototype",
+ "code": "appeal-record-retention-too-short",
+ "severity": "high",
+ "domain": "appeal records",
+ "evidence": "Appeal records are retained for 11 days, below the 30-day policy.",
+ "action": "Extend arbitration and appeal record retention before deleting review evidence.",
+ "closeoutHold": true
+ },
+ {
+ "challengeId": "quantum-noise-cancelled",
+ "challengeTitle": "Quantum noise mitigation prototype",
+ "code": "closeout-audit-digest-unsigned",
+ "severity": "medium",
+ "domain": "audit evidence",
+ "evidence": "Closeout audit digest status is unsigned.",
+ "action": "Sign the closeout digest after revocation, retention, and settlement checks complete.",
+ "closeoutHold": false
+ }
+ ],
+ "closeoutActions": [
+ "Approve redactions before publishing private-challenge outcomes.",
+ "Collect destruction, return, or legal-hold evidence for restricted sponsor datasets.",
+ "Gate IP transfer and sponsor use until funded settlement is recorded.",
+ "Publish solver-facing closeout rationale and compensation decisions.",
+ "Retain arbitration records through the policy appeal window.",
+ "Revoke stale data-room and reviewer access, then rotate shared challenge credentials."
+ ]
+}
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.md b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.md
new file mode 100644
index 00000000..5c57aa1a
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.md
@@ -0,0 +1,82 @@
+# Challenge Closeout Retention Guard
+
+Decision: hold-closeout
+Risk score: 32
+
+## Severity Counts
+
+| Severity | Count |
+| --- | ---: |
+| critical | 2 |
+| high | 6 |
+| medium | 2 |
+| low | 0 |
+
+## Challenge Summary
+
+| Challenge | Outcome | Days Since Close |
+| --- | --- | ---: |
+| Regional climate downscaling model prize | awarded | 8 |
+| Private oncology biomarker discovery challenge | awarded | 14 |
+| Quantum noise mitigation prototype | cancelled | 3 |
+
+## Findings
+
+### CRITICAL: active-data-room-access-after-closeout
+Challenge: Private oncology biomarker discovery challenge
+Evidence: solver-team-beta still has solver access to sponsor-rnaseq-training-cohort 14 days after closeout; policy requires revocation within 2 days.
+Action: Revoke access, rotate shared credentials, and record the revocation event before closeout is accepted.
+
+### HIGH: restricted-data-disposition-missing
+Challenge: Private oncology biomarker discovery challenge
+Evidence: sponsor-rnaseq-training-cohort is restricted and has no destruction, return, or legal-hold disposition.
+Action: Collect a destruction certificate, return receipt, or legal-hold order for the restricted dataset.
+
+### HIGH: destruction-evidence-missing
+Challenge: Private oncology biomarker discovery challenge
+Evidence: sponsor-clinical-labels is marked destroyed without a certificate or audit evidence identifier.
+Action: Attach the destruction certificate hash or reviewer-verifiable evidence identifier.
+
+### CRITICAL: ip-transferred-before-funded-settlement
+Challenge: Private oncology biomarker discovery challenge
+Evidence: IP status is transferred while settlement status is pending.
+Action: Reverse the transfer state or block sponsor use until funded settlement is recorded.
+
+### HIGH: appeal-record-retention-too-short
+Challenge: Private oncology biomarker discovery challenge
+Evidence: Appeal records are retained for 32 days, below the 60-day policy.
+Action: Extend arbitration and appeal record retention before deleting review evidence.
+
+### HIGH: private-challenge-disclosure-unredacted
+Challenge: Private oncology biomarker discovery challenge
+Evidence: Public disclosure is enabled for a private challenge without approved redactions.
+Action: Block announcement until sponsor data, solver trade secrets, and reviewer identities are redacted.
+
+### MEDIUM: closeout-audit-digest-unsigned
+Challenge: Private oncology biomarker discovery challenge
+Evidence: Closeout audit digest status is draft.
+Action: Sign the closeout digest after revocation, retention, and settlement checks complete.
+
+### HIGH: cancelled-challenge-compensation-missing
+Challenge: Quantum noise mitigation prototype
+Evidence: The challenge was cancelled after work started, but no partial-compensation decision is recorded.
+Action: Record partial compensation, refund, or no-compensation rationale before final closeout.
+
+### HIGH: appeal-record-retention-too-short
+Challenge: Quantum noise mitigation prototype
+Evidence: Appeal records are retained for 11 days, below the 30-day policy.
+Action: Extend arbitration and appeal record retention before deleting review evidence.
+
+### MEDIUM: closeout-audit-digest-unsigned
+Challenge: Quantum noise mitigation prototype
+Evidence: Closeout audit digest status is unsigned.
+Action: Sign the closeout digest after revocation, retention, and settlement checks complete.
+
+## Closeout Actions
+
+- Approve redactions before publishing private-challenge outcomes.
+- Collect destruction, return, or legal-hold evidence for restricted sponsor datasets.
+- Gate IP transfer and sponsor use until funded settlement is recorded.
+- Publish solver-facing closeout rationale and compensation decisions.
+- Retain arbitration records through the policy appeal window.
+- Revoke stale data-room and reviewer access, then rotate shared challenge credentials.
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-summary.svg b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-summary.svg
new file mode 100644
index 00000000..7b292cdd
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-summary.svg
@@ -0,0 +1,11 @@
+
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/demo.mp4 b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/demo.mp4
new file mode 100644
index 00000000..2cb4aefd
Binary files /dev/null and b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/demo.mp4 differ
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/data/closeout-records.json b/scientific-bounty-system/challenge-closeout-retention-guard/data/closeout-records.json
new file mode 100644
index 00000000..21a775a3
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/data/closeout-records.json
@@ -0,0 +1,150 @@
+{
+ "packetId": "challenge-closeout-demo-001",
+ "generatedAt": "2026-05-28T00:00:00Z",
+ "challenges": [
+ {
+ "id": "climate-model-prize",
+ "title": "Regional climate downscaling model prize",
+ "closeout": {
+ "outcome": "awarded",
+ "closedAt": "2026-05-20T00:00:00Z"
+ },
+ "policy": {
+ "revokeAccessWithinDays": 3,
+ "retainAppealRecordsDays": 45
+ },
+ "dataGrants": [
+ {
+ "actor": "solver-team-alpha",
+ "role": "solver",
+ "datasetId": "sponsor-weather-stations-v4",
+ "classification": "restricted",
+ "active": false,
+ "closeoutDisposition": "destroyed",
+ "evidenceId": "cert-8841"
+ },
+ {
+ "actor": "external-reviewer-2",
+ "role": "reviewer",
+ "datasetId": "public-baseline-archive",
+ "classification": "public",
+ "active": false,
+ "closeoutDisposition": "retained",
+ "evidenceId": "audit-9910"
+ }
+ ],
+ "settlement": {
+ "status": "funded"
+ },
+ "ipTransfer": {
+ "status": "transferred"
+ },
+ "solverProtection": {
+ "partialCompensationDecision": "not-needed",
+ "noAwardReasonPublished": true
+ },
+ "records": {
+ "appealRecordsRetainUntil": "2026-07-10T00:00:00Z",
+ "auditDigestStatus": "signed"
+ },
+ "publicDisclosure": {
+ "enabled": true,
+ "privateChallenge": false,
+ "redactionApproved": true
+ }
+ },
+ {
+ "id": "oncology-biomarker-private",
+ "title": "Private oncology biomarker discovery challenge",
+ "closeout": {
+ "outcome": "awarded",
+ "closedAt": "2026-05-14T00:00:00Z"
+ },
+ "policy": {
+ "revokeAccessWithinDays": 2,
+ "retainAppealRecordsDays": 60
+ },
+ "dataGrants": [
+ {
+ "actor": "solver-team-beta",
+ "role": "solver",
+ "datasetId": "sponsor-rnaseq-training-cohort",
+ "classification": "restricted",
+ "active": true,
+ "closeoutDisposition": "unresolved",
+ "evidenceId": null
+ },
+ {
+ "actor": "reviewer-panel-a",
+ "role": "reviewer",
+ "datasetId": "sponsor-clinical-labels",
+ "classification": "restricted",
+ "active": false,
+ "closeoutDisposition": "destroyed",
+ "evidenceId": null
+ }
+ ],
+ "settlement": {
+ "status": "pending"
+ },
+ "ipTransfer": {
+ "status": "transferred"
+ },
+ "solverProtection": {
+ "partialCompensationDecision": "not-needed",
+ "noAwardReasonPublished": true
+ },
+ "records": {
+ "appealRecordsRetainUntil": "2026-06-15T00:00:00Z",
+ "auditDigestStatus": "draft"
+ },
+ "publicDisclosure": {
+ "enabled": true,
+ "privateChallenge": true,
+ "redactionApproved": false
+ }
+ },
+ {
+ "id": "quantum-noise-cancelled",
+ "title": "Quantum noise mitigation prototype",
+ "closeout": {
+ "outcome": "cancelled",
+ "closedAt": "2026-05-25T00:00:00Z"
+ },
+ "policy": {
+ "revokeAccessWithinDays": 5,
+ "retainAppealRecordsDays": 30
+ },
+ "dataGrants": [
+ {
+ "actor": "solver-lab-gamma",
+ "role": "solver",
+ "datasetId": "public-noise-fixtures",
+ "classification": "public",
+ "active": false,
+ "closeoutDisposition": "retained",
+ "evidenceId": "audit-2237"
+ }
+ ],
+ "settlement": {
+ "status": "refund-pending"
+ },
+ "ipTransfer": {
+ "status": "blocked"
+ },
+ "solverProtection": {
+ "partialCompensationDecision": null,
+ "noAwardReasonPublished": false
+ },
+ "records": {
+ "appealRecordsRetainUntil": "2026-06-05T00:00:00Z",
+ "auditDigestStatus": "unsigned"
+ },
+ "publicDisclosure": {
+ "enabled": false,
+ "privateChallenge": false,
+ "redactionApproved": false
+ }
+ }
+ ]
+}
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/demo.js b/scientific-bounty-system/challenge-closeout-retention-guard/demo.js
new file mode 100644
index 00000000..3724bb75
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/demo.js
@@ -0,0 +1,25 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const {
+ analyzeCloseoutRecords,
+ renderMarkdown,
+ renderSvg
+} = require('./guard');
+
+const root = __dirname;
+const packetPath = path.join(root, 'data', 'closeout-records.json');
+const artifactsDir = path.join(root, 'artifacts');
+const packet = JSON.parse(fs.readFileSync(packetPath, 'utf8'));
+const report = analyzeCloseoutRecords(packet);
+
+fs.mkdirSync(artifactsDir, { recursive: true });
+fs.writeFileSync(path.join(artifactsDir, 'closeout-report.json'), `${JSON.stringify(report, null, 2)}\n`);
+fs.writeFileSync(path.join(artifactsDir, 'closeout-report.md'), renderMarkdown(report));
+fs.writeFileSync(path.join(artifactsDir, 'closeout-summary.svg'), renderSvg(report));
+
+console.log(`decision=${report.decision}`);
+console.log(`riskScore=${report.riskScore}`);
+console.log(`findings=${report.findings.length}`);
+console.log(`artifacts=${artifactsDir}`);
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/guard.js b/scientific-bounty-system/challenge-closeout-retention-guard/guard.js
new file mode 100644
index 00000000..adab3a51
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/guard.js
@@ -0,0 +1,272 @@
+'use strict';
+
+const severityWeight = {
+ critical: 5,
+ high: 3,
+ medium: 2,
+ low: 1
+};
+
+const severityOrder = ['critical', 'high', 'medium', 'low'];
+
+function daysBetween(start, end) {
+ const msPerDay = 24 * 60 * 60 * 1000;
+ return Math.round((new Date(end).getTime() - new Date(start).getTime()) / msPerDay);
+}
+
+function addFinding(findings, challenge, code, severity, domain, evidence, action) {
+ findings.push({
+ challengeId: challenge.id,
+ challengeTitle: challenge.title,
+ code,
+ severity,
+ domain,
+ evidence,
+ action,
+ closeoutHold: severity === 'critical' || severity === 'high'
+ });
+}
+
+function evaluateChallenge(challenge, now = '2026-05-28T00:00:00Z') {
+ const findings = [];
+ const daysSinceClose = daysBetween(challenge.closeout.closedAt, now);
+ const revokeDeadlineDays = challenge.policy.revokeAccessWithinDays;
+ const retainAppealDays = challenge.policy.retainAppealRecordsDays;
+
+ for (const grant of challenge.dataGrants) {
+ if (grant.active && daysSinceClose > revokeDeadlineDays) {
+ addFinding(
+ findings,
+ challenge,
+ 'active-data-room-access-after-closeout',
+ 'critical',
+ 'access revocation',
+ `${grant.actor} still has ${grant.role} access to ${grant.datasetId} ${daysSinceClose} days after closeout; policy requires revocation within ${revokeDeadlineDays} days.`,
+ 'Revoke access, rotate shared credentials, and record the revocation event before closeout is accepted.'
+ );
+ }
+
+ if (grant.classification === 'restricted' && grant.closeoutDisposition === 'unresolved') {
+ addFinding(
+ findings,
+ challenge,
+ 'restricted-data-disposition-missing',
+ 'high',
+ 'data retention',
+ `${grant.datasetId} is restricted and has no destruction, return, or legal-hold disposition.`,
+ 'Collect a destruction certificate, return receipt, or legal-hold order for the restricted dataset.'
+ );
+ }
+
+ if (grant.closeoutDisposition === 'destroyed' && !grant.evidenceId) {
+ addFinding(
+ findings,
+ challenge,
+ 'destruction-evidence-missing',
+ 'high',
+ 'data retention',
+ `${grant.datasetId} is marked destroyed without a certificate or audit evidence identifier.`,
+ 'Attach the destruction certificate hash or reviewer-verifiable evidence identifier.'
+ );
+ }
+ }
+
+ if (challenge.settlement.status !== 'funded' && challenge.ipTransfer.status === 'transferred') {
+ addFinding(
+ findings,
+ challenge,
+ 'ip-transferred-before-funded-settlement',
+ 'critical',
+ 'IP transfer',
+ `IP status is transferred while settlement status is ${challenge.settlement.status}.`,
+ 'Reverse the transfer state or block sponsor use until funded settlement is recorded.'
+ );
+ }
+
+ if (challenge.closeout.outcome === 'cancelled' && !challenge.solverProtection.partialCompensationDecision) {
+ addFinding(
+ findings,
+ challenge,
+ 'cancelled-challenge-compensation-missing',
+ 'high',
+ 'solver protection',
+ 'The challenge was cancelled after work started, but no partial-compensation decision is recorded.',
+ 'Record partial compensation, refund, or no-compensation rationale before final closeout.'
+ );
+ }
+
+ if (challenge.closeout.outcome === 'no-award' && !challenge.solverProtection.noAwardReasonPublished) {
+ addFinding(
+ findings,
+ challenge,
+ 'no-award-reason-not-published',
+ 'medium',
+ 'solver protection',
+ 'No-award closeout does not include a solver-visible reason packet.',
+ 'Publish a solver-facing reason packet that preserves private sponsor data.'
+ );
+ }
+
+ const appealAge = daysBetween(challenge.closeout.closedAt, challenge.records.appealRecordsRetainUntil);
+ if (appealAge < retainAppealDays) {
+ addFinding(
+ findings,
+ challenge,
+ 'appeal-record-retention-too-short',
+ 'high',
+ 'appeal records',
+ `Appeal records are retained for ${appealAge} days, below the ${retainAppealDays}-day policy.`,
+ 'Extend arbitration and appeal record retention before deleting review evidence.'
+ );
+ }
+
+ if (challenge.publicDisclosure.enabled && challenge.publicDisclosure.privateChallenge && !challenge.publicDisclosure.redactionApproved) {
+ addFinding(
+ findings,
+ challenge,
+ 'private-challenge-disclosure-unredacted',
+ 'high',
+ 'public disclosure',
+ 'Public disclosure is enabled for a private challenge without approved redactions.',
+ 'Block announcement until sponsor data, solver trade secrets, and reviewer identities are redacted.'
+ );
+ }
+
+ if (challenge.records.auditDigestStatus !== 'signed') {
+ addFinding(
+ findings,
+ challenge,
+ 'closeout-audit-digest-unsigned',
+ 'medium',
+ 'audit evidence',
+ `Closeout audit digest status is ${challenge.records.auditDigestStatus}.`,
+ 'Sign the closeout digest after revocation, retention, and settlement checks complete.'
+ );
+ }
+
+ return {
+ challengeId: challenge.id,
+ challengeTitle: challenge.title,
+ outcome: challenge.closeout.outcome,
+ daysSinceClose,
+ findings
+ };
+}
+
+function summarizeFindings(findings) {
+ return severityOrder.reduce((summary, severity) => {
+ summary[severity] = findings.filter((finding) => finding.severity === severity).length;
+ return summary;
+ }, {});
+}
+
+function buildCloseoutActions(findings) {
+ const actions = new Set();
+
+ for (const finding of findings) {
+ if (finding.domain === 'access revocation') actions.add('Revoke stale data-room and reviewer access, then rotate shared challenge credentials.');
+ if (finding.domain === 'data retention') actions.add('Collect destruction, return, or legal-hold evidence for restricted sponsor datasets.');
+ if (finding.domain === 'IP transfer') actions.add('Gate IP transfer and sponsor use until funded settlement is recorded.');
+ if (finding.domain === 'solver protection') actions.add('Publish solver-facing closeout rationale and compensation decisions.');
+ if (finding.domain === 'appeal records') actions.add('Retain arbitration records through the policy appeal window.');
+ if (finding.domain === 'public disclosure') actions.add('Approve redactions before publishing private-challenge outcomes.');
+ }
+
+ return [...actions].sort();
+}
+
+function analyzeCloseoutRecords(packet) {
+ const challenges = packet.challenges.map((challenge) => evaluateChallenge(challenge, packet.generatedAt));
+ const findings = challenges.flatMap((challenge) => challenge.findings);
+ const riskScore = findings.reduce((score, finding) => score + severityWeight[finding.severity], 0);
+ const decision = findings.some((finding) => finding.closeoutHold)
+ ? 'hold-closeout'
+ : 'closeout-ready';
+
+ return {
+ generatedBy: 'challenge-closeout-retention-guard',
+ packetId: packet.packetId,
+ generatedAt: packet.generatedAt,
+ decision,
+ riskScore,
+ severityCounts: summarizeFindings(findings),
+ challenges: challenges.map(({ findings: _findings, ...challenge }) => challenge),
+ findings,
+ closeoutActions: buildCloseoutActions(findings)
+ };
+}
+
+function renderMarkdown(report) {
+ const lines = [
+ '# Challenge Closeout Retention Guard',
+ '',
+ `Decision: ${report.decision}`,
+ `Risk score: ${report.riskScore}`,
+ '',
+ '## Severity Counts',
+ '',
+ '| Severity | Count |',
+ '| --- | ---: |',
+ ...severityOrder.map((severity) => `| ${severity} | ${report.severityCounts[severity]} |`),
+ '',
+ '## Challenge Summary',
+ '',
+ '| Challenge | Outcome | Days Since Close |',
+ '| --- | --- | ---: |',
+ ...report.challenges.map((challenge) => `| ${challenge.challengeTitle} | ${challenge.outcome} | ${challenge.daysSinceClose} |`),
+ '',
+ '## Findings',
+ ''
+ ];
+
+ for (const finding of report.findings) {
+ lines.push(`### ${finding.severity.toUpperCase()}: ${finding.code}`);
+ lines.push(`Challenge: ${finding.challengeTitle}`);
+ lines.push(`Evidence: ${finding.evidence}`);
+ lines.push(`Action: ${finding.action}`);
+ lines.push('');
+ }
+
+ lines.push('## Closeout Actions');
+ lines.push('');
+ for (const action of report.closeoutActions) {
+ lines.push(`- ${action}`);
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+function renderSvg(report) {
+ const total = Math.max(1, report.findings.length);
+ const colors = {
+ critical: '#991b1b',
+ high: '#dc2626',
+ medium: '#f59e0b',
+ low: '#2563eb'
+ };
+ const bars = severityOrder.map((severity, index) => {
+ const count = report.severityCounts[severity];
+ const width = 30 + Math.round((count / total) * 300);
+ const y = 92 + index * 42;
+ return `${severity}${count}`;
+ }).join('\n');
+
+ return [
+ '',
+ ''
+ ].join('\n');
+}
+
+module.exports = {
+ analyzeCloseoutRecords,
+ evaluateChallenge,
+ renderMarkdown,
+ renderSvg
+};
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/make-demo-video.js b/scientific-bounty-system/challenge-closeout-retention-guard/make-demo-video.js
new file mode 100644
index 00000000..3dab7742
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/make-demo-video.js
@@ -0,0 +1,167 @@
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const width = 640;
+const height = 360;
+const fps = 12;
+const frames = 60;
+const colors = {
+ bg: [248, 250, 252],
+ card: [255, 255, 255],
+ border: [203, 213, 225],
+ ink: [15, 23, 42],
+ muted: [71, 85, 105],
+ critical: [153, 27, 27],
+ high: [220, 38, 38],
+ medium: [245, 158, 11],
+ low: [37, 99, 235]
+};
+
+const glyphs = {
+ ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
+ '-': ['00000', '00000', '00000', '11110', '00000', '00000', '00000'],
+ '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
+ '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
+ '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
+ '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
+ '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
+ '5': ['11111', '10000', '10000', '11110', '00001', '00001', '11110'],
+ '6': ['01110', '10000', '10000', '11110', '10001', '10001', '01110'],
+ '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
+ '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
+ '9': ['01110', '10001', '10001', '01111', '00001', '00001', '01110'],
+ A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
+ B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
+ C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'],
+ D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
+ E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
+ F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
+ G: ['01111', '10000', '10000', '10011', '10001', '10001', '01111'],
+ H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
+ I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
+ J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'],
+ K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
+ L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
+ M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
+ N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
+ O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
+ P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
+ Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
+ 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'],
+ W: ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
+ X: ['10001', '01010', '00100', '00100', '01010', '10001', '10001'],
+ Y: ['10001', '01010', '00100', '00100', '00100', '00100', '00100'],
+ Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111']
+};
+
+function setPixel(buffer, x, y, color) {
+ if (x < 0 || y < 0 || x >= width || y >= height) return;
+ const index = (y * width + x) * 3;
+ buffer[index] = color[0];
+ buffer[index + 1] = color[1];
+ buffer[index + 2] = color[2];
+}
+
+function rect(buffer, x, y, w, h, color) {
+ for (let py = Math.max(0, y); py < Math.min(height, y + h); py++) {
+ for (let px = Math.max(0, x); px < Math.min(width, x + w); px++) {
+ setPixel(buffer, px, py, color);
+ }
+ }
+}
+
+function fill(buffer, color) {
+ for (let i = 0; i < buffer.length; i += 3) {
+ buffer[i] = color[0];
+ buffer[i + 1] = color[1];
+ buffer[i + 2] = color[2];
+ }
+}
+
+function drawText(buffer, text, x, y, scale, color) {
+ let cursor = x;
+ for (const char of text.toUpperCase()) {
+ const pattern = glyphs[char] || glyphs[' '];
+ for (let row = 0; row < pattern.length; row++) {
+ for (let col = 0; col < pattern[row].length; col++) {
+ if (pattern[row][col] === '1') {
+ rect(buffer, cursor + col * scale, y + row * scale, scale, scale, color);
+ }
+ }
+ }
+ cursor += 6 * scale;
+ }
+}
+
+function writeFrame(filePath, buffer) {
+ fs.writeFileSync(filePath, Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), Buffer.from(buffer)]));
+}
+
+function renderFrame(index, outputDir) {
+ const buffer = Buffer.alloc(width * height * 3);
+ const progress = (index + 1) / frames;
+ fill(buffer, colors.bg);
+ rect(buffer, 34, 30, 572, 300, colors.card);
+ rect(buffer, 34, 30, 572, 3, colors.border);
+ rect(buffer, 34, 327, 572, 3, colors.border);
+ rect(buffer, 34, 30, 3, 300, colors.border);
+ rect(buffer, 603, 30, 3, 300, colors.border);
+
+ drawText(buffer, 'CLOSEOUT GUARD', 58, 58, 4, colors.ink);
+ drawText(buffer, 'HOLD CLOSEOUT', 58, 104, 3, colors.muted);
+ drawText(buffer, 'RISK SCORE 32', 58, 140, 3, colors.ink);
+
+ const scale = Math.min(1, progress * 1.3);
+ drawText(buffer, 'CRITICAL 2', 58, 188, 3, colors.ink);
+ rect(buffer, 236, 186, Math.round(230 * scale), 26, colors.critical);
+ drawText(buffer, 'HIGH 6', 58, 226, 3, colors.ink);
+ rect(buffer, 236, 224, Math.round(205 * Math.max(0, progress - 0.15) * 1.35), 26, colors.high);
+ drawText(buffer, 'MEDIUM 2', 58, 264, 3, colors.ink);
+ rect(buffer, 236, 262, Math.round(120 * Math.max(0, progress - 0.3) * 1.6), 26, colors.medium);
+
+ rect(buffer, 430, 105, 95, 95, colors.border);
+ rect(buffer, 454, 129, 47, 47, index % 16 < 8 ? colors.high : colors.critical);
+ drawText(buffer, 'REVOKE', 414, 218, 2, colors.muted);
+ drawText(buffer, 'RETAIN', 414, 252, 2, colors.muted);
+
+ writeFrame(path.join(outputDir, `frame${String(index).padStart(3, '0')}.ppm`), buffer);
+}
+
+function main() {
+ const root = __dirname;
+ const artifactsDir = path.join(root, 'artifacts');
+ const outputPath = path.join(artifactsDir, 'demo.mp4');
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'closeout-demo-'));
+ fs.mkdirSync(artifactsDir, { recursive: true });
+
+ for (let index = 0; index < frames; index++) renderFrame(index, tempDir);
+
+ const result = spawnSync('ffmpeg', [
+ '-y',
+ '-framerate',
+ String(fps),
+ '-i',
+ path.join(tempDir, 'frame%03d.ppm'),
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ '-movflags',
+ '+faststart',
+ outputPath
+ ], { stdio: 'inherit' });
+
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ if (result.status !== 0) throw new Error('ffmpeg failed to create demo.mp4');
+ console.log(outputPath);
+}
+
+main();
diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/test.js b/scientific-bounty-system/challenge-closeout-retention-guard/test.js
new file mode 100644
index 00000000..40354213
--- /dev/null
+++ b/scientific-bounty-system/challenge-closeout-retention-guard/test.js
@@ -0,0 +1,49 @@
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const path = require('path');
+const {
+ analyzeCloseoutRecords,
+ evaluateChallenge,
+ renderMarkdown,
+ renderSvg
+} = require('./guard');
+
+const packetPath = path.join(__dirname, 'data', 'closeout-records.json');
+const packet = JSON.parse(fs.readFileSync(packetPath, 'utf8'));
+const report = analyzeCloseoutRecords(packet);
+
+assert.equal(report.generatedBy, 'challenge-closeout-retention-guard');
+assert.equal(report.packetId, 'challenge-closeout-demo-001');
+assert.equal(report.decision, 'hold-closeout');
+assert.equal(report.challenges.length, 3);
+assert.ok(report.riskScore >= 25);
+assert.ok(report.severityCounts.critical >= 2);
+assert.ok(report.severityCounts.high >= 4);
+
+const oncologyFindings = report.findings.filter((finding) => finding.challengeId === 'oncology-biomarker-private');
+const oncologyCodes = new Set(oncologyFindings.map((finding) => finding.code));
+assert.ok(oncologyCodes.has('active-data-room-access-after-closeout'));
+assert.ok(oncologyCodes.has('restricted-data-disposition-missing'));
+assert.ok(oncologyCodes.has('destruction-evidence-missing'));
+assert.ok(oncologyCodes.has('ip-transferred-before-funded-settlement'));
+assert.ok(oncologyCodes.has('private-challenge-disclosure-unredacted'));
+
+const cleanChallenge = packet.challenges.find((challenge) => challenge.id === 'climate-model-prize');
+assert.deepEqual(evaluateChallenge(cleanChallenge, packet.generatedAt).findings, []);
+
+assert.ok(report.closeoutActions.some((action) => action.includes('Revoke stale data-room')));
+assert.ok(report.closeoutActions.some((action) => action.includes('Gate IP transfer')));
+
+const markdown = renderMarkdown(report);
+assert.ok(markdown.includes('Challenge Closeout Retention Guard'));
+assert.ok(markdown.includes('active-data-room-access-after-closeout'));
+assert.ok(markdown.includes('| critical |'));
+
+const svg = renderSvg(report);
+assert.ok(svg.startsWith('