diff --git a/challenge-solver-withdrawal-continuity-guard/README.md b/challenge-solver-withdrawal-continuity-guard/README.md
new file mode 100644
index 00000000..1dbfcfa8
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/README.md
@@ -0,0 +1,33 @@
+# Solver Withdrawal Continuity Guard
+
+Self-contained Scientific Bounty System slice for issue #18. It evaluates active solver/team withdrawals or replacements after a private challenge workspace has opened, before milestone review, sponsor scoring, payout, or IP handoff continues.
+
+## What It Checks
+
+- Dated withdrawal notice records.
+- Workspace access revocation after a solver leaves.
+- Solver IP return, retention, or destruction confirmation.
+- Team quorum and eligible replacement readiness.
+- Artifact custody for work owned by withdrawing solvers.
+- Near-term milestone risk after team membership changes.
+- Sponsor and reviewer notification coverage.
+
+## Outputs
+
+- `reports/withdrawal-continuity-packet.json`: structured decisions, findings, summaries, and actions.
+- `reports/coordinator-packet.md`: readable challenge coordinator packet.
+- `reports/summary.svg`: visual decision summary.
+- `reports/demo.mp4`: short demo artifact for Algora review.
+
+## Local Verification
+
+```bash
+node --test challenge-solver-withdrawal-continuity-guard/test.js
+node challenge-solver-withdrawal-continuity-guard/demo.js
+node --check challenge-solver-withdrawal-continuity-guard/index.js
+node --check challenge-solver-withdrawal-continuity-guard/sample-data.js
+node --check challenge-solver-withdrawal-continuity-guard/demo.js
+node --check challenge-solver-withdrawal-continuity-guard/test.js
+```
+
+The module is dependency-free, uses synthetic data only, and makes no network calls. Set `FFMPEG_PATH` to an ffmpeg binary before running `demo.js` if regenerating `reports/demo.mp4`.
diff --git a/challenge-solver-withdrawal-continuity-guard/demo.js b/challenge-solver-withdrawal-continuity-guard/demo.js
new file mode 100644
index 00000000..c8ee9102
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/demo.js
@@ -0,0 +1,135 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const {spawnSync} = require('node:child_process');
+
+const {
+ evaluateSolverWithdrawalContinuity,
+ buildCoordinatorPacket,
+} = require('./index');
+const {scenarios} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+const framesDir = path.join(reportsDir, 'frames');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const evaluations = scenarios.map((scenario) => ({
+ scenario: scenario.name,
+ ...evaluateSolverWithdrawalContinuity(scenario),
+}));
+
+const decisionCounts = evaluations.reduce((counts, item) => {
+ counts[item.decision] = (counts[item.decision] || 0) + 1;
+ return counts;
+}, {});
+const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0);
+const affectedTeams = evaluations.reduce((sum, item) => sum + item.summary.affectedTeams, 0);
+const readyReplacements = evaluations.reduce((sum, item) => sum + item.summary.replacementsReady, 0);
+
+const packetJson = JSON.stringify(evaluations, null, 2);
+const coordinatorPacket = evaluations.map(buildCoordinatorPacket).join('\n---\n');
+const svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'withdrawal-continuity-packet.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'coordinator-packet.md'), coordinatorPacket);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) {
+ const [r, g, b] = color;
+ const x1 = Math.min(width, x0 + rectWidth);
+ const y1 = Math.min(Math.floor(buffer.length / (width * 3)), y0 + rectHeight);
+ for (let y = Math.max(0, y0); y < y1; y += 1) {
+ for (let x = Math.max(0, x0); x < x1; x += 1) {
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+ }
+ }
+}
+
+function writePpmFrame(filePath, frameIndex, frameCount) {
+ const width = 640;
+ const height = 360;
+ const buffer = Buffer.alloc(width * height * 3);
+ for (let i = 0; i < width * height; i += 1) {
+ buffer[i * 3] = 17;
+ buffer[i * 3 + 1] = 24;
+ buffer[i * 3 + 2] = 39;
+ }
+ const progress = frameIndex / Math.max(1, frameCount - 1);
+ fillRect(buffer, width, 42, 42, 556, 44, [248, 250, 252]);
+ fillRect(buffer, width, 42, 112, 160, 118, [127, 29, 29]);
+ fillRect(buffer, width, 240, 112, 160, 118, [146, 64, 14]);
+ fillRect(buffer, width, 438, 112, 160, 118, [22, 101, 52]);
+ fillRect(buffer, width, 42, 266, Math.round(556 * progress), 28, [191, 219, 254]);
+ fillRect(buffer, width, 42 + Math.round(492 * progress), 310, 64, 26, [248, 250, 252]);
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
+ fs.writeFileSync(filePath, Buffer.concat([header, buffer]));
+}
+
+function createDemoVideo() {
+ const ffmpegPath = process.env.FFMPEG_PATH;
+ if (!ffmpegPath) {
+ console.log('FFMPEG_PATH not set; skipped MP4 generation.');
+ return;
+ }
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ fs.mkdirSync(framesDir, {recursive: true});
+ const frameCount = 72;
+ for (let index = 0; index < frameCount; index += 1) {
+ writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount);
+ }
+
+ const output = path.join(reportsDir, 'demo.mp4');
+ const result = spawnSync(ffmpegPath, [
+ '-y',
+ '-framerate',
+ '24',
+ '-i',
+ path.join(framesDir, 'frame-%03d.ppm'),
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ '-movflags',
+ '+faststart',
+ output,
+ ], {encoding: 'utf8'});
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ if (result.status !== 0) {
+ throw new Error(result.stderr || 'ffmpeg failed to generate demo.mp4');
+ }
+}
+
+createDemoVideo();
+
+console.log(`Wrote ${evaluations.length} solver withdrawal evaluations to ${reportsDir}`);
+console.log(`Decision counts: ${JSON.stringify(decisionCounts)}`);
+console.log(`Affected teams: ${affectedTeams}`);
+console.log(`Ready replacements: ${readyReplacements}`);
+console.log(`Findings: ${findings}`);
diff --git a/challenge-solver-withdrawal-continuity-guard/index.js b/challenge-solver-withdrawal-continuity-guard/index.js
new file mode 100644
index 00000000..7a543289
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/index.js
@@ -0,0 +1,304 @@
+function list(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function hasText(value) {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+function normalize(value) {
+ return String(value || '').trim().toLowerCase();
+}
+
+function fullDaysBetween(older, newer) {
+ const olderTime = Date.parse(older);
+ const newerTime = Date.parse(newer);
+ if (!Number.isFinite(olderTime) || !Number.isFinite(newerTime)) {
+ return Infinity;
+ }
+ return Math.floor((newerTime - olderTime) / 86400000);
+}
+
+function addFinding(findings, requiredActions, finding, action) {
+ findings.push(finding);
+ if (action) {
+ requiredActions.push(action);
+ }
+}
+
+function reviewerAction(type, target, reason) {
+ return {type, target, reason};
+}
+
+function collectTeams(input) {
+ return list(input.teams).map((team) => ({
+ ...team,
+ members: list(team.members),
+ artifacts: list(team.artifacts),
+ notifications: list(team.notifications),
+ }));
+}
+
+function withdrawingMembers(team) {
+ return team.members.filter((member) => normalize(member.status) === 'withdrawing');
+}
+
+function continuityMembers(team) {
+ return team.members.filter((member) => {
+ const status = normalize(member.status);
+ if (status === 'active') {
+ return true;
+ }
+ return status === 'replacement' && member.replacementEligible === true && member.ndaAccepted === true;
+ });
+}
+
+function readyReplacements(team) {
+ return team.members.filter((member) => (
+ normalize(member.status) === 'replacement'
+ && member.replacementEligible === true
+ && member.ndaAccepted === true
+ ));
+}
+
+function notificationAudiences(team) {
+ return new Set(team.notifications.filter((notice) => hasText(notice.sentAt)).map((notice) => normalize(notice.audience)));
+}
+
+function countSeverities(findings) {
+ return findings.reduce((counts, finding) => {
+ counts[finding.severity] = (counts[finding.severity] || 0) + 1;
+ return counts;
+ }, {critical: 0, high: 0, medium: 0});
+}
+
+function decide(findings) {
+ if (findings.some((finding) => finding.severity === 'critical')) {
+ return 'hold-workspace-continuity';
+ }
+ if (findings.length > 0) {
+ return 'coordinator-review';
+ }
+ return 'continuity-ready';
+}
+
+function evaluateSolverWithdrawalContinuity(input) {
+ const generatedAt = input.generatedAt || new Date(0).toISOString();
+ const teams = collectTeams(input);
+ const findings = [];
+ const requiredActions = [];
+ let replacementsReady = 0;
+ const affectedTeams = new Set();
+
+ for (const team of teams) {
+ const teamId = team.id || 'unknown-team';
+ const withdrawing = withdrawingMembers(team);
+ const withdrawingIds = new Set(withdrawing.map((member) => member.id));
+ replacementsReady += readyReplacements(team).length;
+
+ for (const member of withdrawing) {
+ const memberId = member.id || 'unknown-solver';
+ affectedTeams.add(teamId);
+ if (!hasText(member.noticeAt)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'missing-withdrawal-notice',
+ severity: 'critical',
+ target: memberId,
+ teamId,
+ message: `${memberId} is withdrawing without a dated notice record.`,
+ },
+ reviewerAction(
+ 'record_withdrawal_notice',
+ memberId,
+ 'challenge coordinators need a dated notice before changing solver team state'
+ )
+ );
+ }
+
+ const accessRevoked = hasText(member.accessRevokedAt);
+ const accessAfterRevocation = accessRevoked
+ && Date.parse(member.lastWorkspaceAccessAt) > Date.parse(member.accessRevokedAt);
+ if (!accessRevoked || accessAfterRevocation) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'access-revocation-missing',
+ severity: 'critical',
+ target: memberId,
+ teamId,
+ message: `${memberId} still needs workspace access revocation evidence.`,
+ },
+ reviewerAction(
+ 'revoke_workspace_access',
+ memberId,
+ 'private challenge data and submission workspaces must be protected after withdrawal'
+ )
+ );
+ }
+
+ if (member.ipReturnConfirmed !== true) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'ip-return-missing',
+ severity: 'high',
+ target: memberId,
+ teamId,
+ message: `${memberId} lacks IP return, retention, or destruction confirmation.`,
+ },
+ reviewerAction(
+ 'confirm_ip_return_duties',
+ memberId,
+ 'solver-owned and sponsor-confidential materials need explicit custody status'
+ )
+ );
+ }
+ }
+
+ if (withdrawing.length > 0 && continuityMembers(team).length < (team.minActiveMembers || 1)) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'team-quorum-broken',
+ severity: 'critical',
+ target: teamId,
+ activeContinuityMembers: continuityMembers(team).length,
+ minActiveMembers: team.minActiveMembers || 1,
+ message: `${teamId} falls below required active solver quorum after withdrawal.`,
+ },
+ reviewerAction(
+ 'restore_team_quorum',
+ teamId,
+ 'challenge fairness requires enough active or eligible replacement solvers before work continues'
+ )
+ );
+ affectedTeams.add(teamId);
+ }
+
+ const orphanedArtifacts = team.artifacts.filter((artifact) => (
+ withdrawingIds.has(artifact.ownerId) && artifact.custodyConfirmed !== true
+ ));
+ if (orphanedArtifacts.length > 0) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'artifact-custody-gap',
+ severity: 'high',
+ target: teamId,
+ artifactIds: orphanedArtifacts.map((artifact) => artifact.id || 'unknown-artifact'),
+ message: `${teamId} has withdrawing-solver artifacts without custody confirmation.`,
+ },
+ reviewerAction(
+ 'confirm_artifact_custody',
+ teamId,
+ 'submission artifacts need owner handoff before sponsor review or scoring continues'
+ )
+ );
+ affectedTeams.add(teamId);
+ }
+
+ if (withdrawing.length > 0 && fullDaysBetween(generatedAt, team.currentMilestoneDueAt) <= 7) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'milestone-continuity-risk',
+ severity: 'medium',
+ target: teamId,
+ dueAt: team.currentMilestoneDueAt,
+ message: `${teamId} has a milestone due within 7 days of a withdrawal event.`,
+ },
+ reviewerAction(
+ 'review_milestone_extension',
+ teamId,
+ 'near-term milestones may need sponsor-approved extension or partial-credit handling'
+ )
+ );
+ affectedTeams.add(teamId);
+ }
+
+ const audiences = notificationAudiences(team);
+ if (withdrawing.length > 0 && (!audiences.has('sponsor') || !audiences.has('reviewers'))) {
+ addFinding(
+ findings,
+ requiredActions,
+ {
+ type: 'stakeholder-notification-gap',
+ severity: 'medium',
+ target: teamId,
+ missingAudiences: ['sponsor', 'reviewers'].filter((audience) => !audiences.has(audience)),
+ message: `${teamId} has incomplete sponsor or reviewer withdrawal notifications.`,
+ },
+ reviewerAction(
+ 'notify_stakeholders',
+ teamId,
+ 'sponsors and reviewers need synchronized withdrawal state before judging or deadlines change'
+ )
+ );
+ affectedTeams.add(teamId);
+ }
+ }
+
+ const severities = countSeverities(findings);
+ return {
+ challengeId: input.challengeId || 'unknown-challenge',
+ generatedAt,
+ decision: decide(findings),
+ summary: {
+ teams: teams.length,
+ affectedTeams: affectedTeams.size,
+ withdrawingSolvers: teams.reduce((sum, team) => sum + withdrawingMembers(team).length, 0),
+ replacementsReady,
+ requiredActions: requiredActions.length,
+ ...severities,
+ },
+ findings,
+ requiredActions,
+ };
+}
+
+function buildCoordinatorPacket(result) {
+ const lines = [
+ '# Solver Withdrawal Continuity Guard',
+ '',
+ `Challenge: ${result.challengeId}`,
+ `Decision: ${result.decision}`,
+ `Generated: ${result.generatedAt}`,
+ '',
+ '## Summary',
+ '',
+ `- Teams reviewed: ${result.summary.teams}`,
+ `- Affected teams: ${result.summary.affectedTeams}`,
+ `- Withdrawing solvers: ${result.summary.withdrawingSolvers}`,
+ `- Ready replacements: ${result.summary.replacementsReady}`,
+ `- Required actions: ${result.summary.requiredActions}`,
+ '',
+ ];
+
+ if (result.findings.length === 0) {
+ lines.push('No blocking withdrawal continuity gaps detected.');
+ } else {
+ lines.push('## Findings', '');
+ for (const finding of result.findings) {
+ lines.push(`- **${finding.severity}** ${finding.type}: ${finding.message}`);
+ }
+ lines.push('', '## Required Actions', '');
+ for (const action of result.requiredActions) {
+ lines.push(`- ${action.type} (${action.target}): ${action.reason}`);
+ }
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+module.exports = {
+ evaluateSolverWithdrawalContinuity,
+ buildCoordinatorPacket,
+};
diff --git a/challenge-solver-withdrawal-continuity-guard/reports/coordinator-packet.md b/challenge-solver-withdrawal-continuity-guard/reports/coordinator-packet.md
new file mode 100644
index 00000000..a624cd38
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/reports/coordinator-packet.md
@@ -0,0 +1,75 @@
+# Solver Withdrawal Continuity Guard
+
+Challenge: challenge-protein-folding
+Decision: hold-workspace-continuity
+Generated: 2026-05-22T15:20:00Z
+
+## Summary
+
+- Teams reviewed: 1
+- Affected teams: 1
+- Withdrawing solvers: 1
+- Ready replacements: 0
+- Required actions: 7
+
+## Findings
+
+- **critical** missing-withdrawal-notice: solver-1 is withdrawing without a dated notice record.
+- **critical** access-revocation-missing: solver-1 still needs workspace access revocation evidence.
+- **high** ip-return-missing: solver-1 lacks IP return, retention, or destruction confirmation.
+- **critical** team-quorum-broken: team-alpha falls below required active solver quorum after withdrawal.
+- **high** artifact-custody-gap: team-alpha has withdrawing-solver artifacts without custody confirmation.
+- **medium** milestone-continuity-risk: team-alpha has a milestone due within 7 days of a withdrawal event.
+- **medium** stakeholder-notification-gap: team-alpha has incomplete sponsor or reviewer withdrawal notifications.
+
+## Required Actions
+
+- record_withdrawal_notice (solver-1): challenge coordinators need a dated notice before changing solver team state
+- revoke_workspace_access (solver-1): private challenge data and submission workspaces must be protected after withdrawal
+- confirm_ip_return_duties (solver-1): solver-owned and sponsor-confidential materials need explicit custody status
+- restore_team_quorum (team-alpha): challenge fairness requires enough active or eligible replacement solvers before work continues
+- confirm_artifact_custody (team-alpha): submission artifacts need owner handoff before sponsor review or scoring continues
+- review_milestone_extension (team-alpha): near-term milestones may need sponsor-approved extension or partial-credit handling
+- notify_stakeholders (team-alpha): sponsors and reviewers need synchronized withdrawal state before judging or deadlines change
+
+---
+# Solver Withdrawal Continuity Guard
+
+Challenge: challenge-materials-screen
+Decision: coordinator-review
+Generated: 2026-05-22T15:20:00Z
+
+## Summary
+
+- Teams reviewed: 1
+- Affected teams: 1
+- Withdrawing solvers: 1
+- Ready replacements: 1
+- Required actions: 2
+
+## Findings
+
+- **high** artifact-custody-gap: team-beta has withdrawing-solver artifacts without custody confirmation.
+- **medium** stakeholder-notification-gap: team-beta has incomplete sponsor or reviewer withdrawal notifications.
+
+## Required Actions
+
+- confirm_artifact_custody (team-beta): submission artifacts need owner handoff before sponsor review or scoring continues
+- notify_stakeholders (team-beta): sponsors and reviewers need synchronized withdrawal state before judging or deadlines change
+
+---
+# Solver Withdrawal Continuity Guard
+
+Challenge: challenge-climate-nowcast
+Decision: continuity-ready
+Generated: 2026-05-22T15:20:00Z
+
+## Summary
+
+- Teams reviewed: 1
+- Affected teams: 1
+- Withdrawing solvers: 1
+- Ready replacements: 1
+- Required actions: 0
+
+No blocking withdrawal continuity gaps detected.
diff --git a/challenge-solver-withdrawal-continuity-guard/reports/demo.mp4 b/challenge-solver-withdrawal-continuity-guard/reports/demo.mp4
new file mode 100644
index 00000000..9d81ec44
Binary files /dev/null and b/challenge-solver-withdrawal-continuity-guard/reports/demo.mp4 differ
diff --git a/challenge-solver-withdrawal-continuity-guard/reports/summary.svg b/challenge-solver-withdrawal-continuity-guard/reports/summary.svg
new file mode 100644
index 00000000..8f506f45
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/reports/summary.svg
@@ -0,0 +1,23 @@
+
diff --git a/challenge-solver-withdrawal-continuity-guard/reports/withdrawal-continuity-packet.json b/challenge-solver-withdrawal-continuity-guard/reports/withdrawal-continuity-packet.json
new file mode 100644
index 00000000..2e091caf
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/reports/withdrawal-continuity-packet.json
@@ -0,0 +1,178 @@
+[
+ {
+ "scenario": "broken-quorum-and-unrevoked-access",
+ "challengeId": "challenge-protein-folding",
+ "generatedAt": "2026-05-22T15:20:00Z",
+ "decision": "hold-workspace-continuity",
+ "summary": {
+ "teams": 1,
+ "affectedTeams": 1,
+ "withdrawingSolvers": 1,
+ "replacementsReady": 0,
+ "requiredActions": 7,
+ "critical": 3,
+ "high": 2,
+ "medium": 2
+ },
+ "findings": [
+ {
+ "type": "missing-withdrawal-notice",
+ "severity": "critical",
+ "target": "solver-1",
+ "teamId": "team-alpha",
+ "message": "solver-1 is withdrawing without a dated notice record."
+ },
+ {
+ "type": "access-revocation-missing",
+ "severity": "critical",
+ "target": "solver-1",
+ "teamId": "team-alpha",
+ "message": "solver-1 still needs workspace access revocation evidence."
+ },
+ {
+ "type": "ip-return-missing",
+ "severity": "high",
+ "target": "solver-1",
+ "teamId": "team-alpha",
+ "message": "solver-1 lacks IP return, retention, or destruction confirmation."
+ },
+ {
+ "type": "team-quorum-broken",
+ "severity": "critical",
+ "target": "team-alpha",
+ "activeContinuityMembers": 1,
+ "minActiveMembers": 2,
+ "message": "team-alpha falls below required active solver quorum after withdrawal."
+ },
+ {
+ "type": "artifact-custody-gap",
+ "severity": "high",
+ "target": "team-alpha",
+ "artifactIds": [
+ "model-checkpoint"
+ ],
+ "message": "team-alpha has withdrawing-solver artifacts without custody confirmation."
+ },
+ {
+ "type": "milestone-continuity-risk",
+ "severity": "medium",
+ "target": "team-alpha",
+ "dueAt": "2026-05-25T00:00:00Z",
+ "message": "team-alpha has a milestone due within 7 days of a withdrawal event."
+ },
+ {
+ "type": "stakeholder-notification-gap",
+ "severity": "medium",
+ "target": "team-alpha",
+ "missingAudiences": [
+ "sponsor",
+ "reviewers"
+ ],
+ "message": "team-alpha has incomplete sponsor or reviewer withdrawal notifications."
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "record_withdrawal_notice",
+ "target": "solver-1",
+ "reason": "challenge coordinators need a dated notice before changing solver team state"
+ },
+ {
+ "type": "revoke_workspace_access",
+ "target": "solver-1",
+ "reason": "private challenge data and submission workspaces must be protected after withdrawal"
+ },
+ {
+ "type": "confirm_ip_return_duties",
+ "target": "solver-1",
+ "reason": "solver-owned and sponsor-confidential materials need explicit custody status"
+ },
+ {
+ "type": "restore_team_quorum",
+ "target": "team-alpha",
+ "reason": "challenge fairness requires enough active or eligible replacement solvers before work continues"
+ },
+ {
+ "type": "confirm_artifact_custody",
+ "target": "team-alpha",
+ "reason": "submission artifacts need owner handoff before sponsor review or scoring continues"
+ },
+ {
+ "type": "review_milestone_extension",
+ "target": "team-alpha",
+ "reason": "near-term milestones may need sponsor-approved extension or partial-credit handling"
+ },
+ {
+ "type": "notify_stakeholders",
+ "target": "team-alpha",
+ "reason": "sponsors and reviewers need synchronized withdrawal state before judging or deadlines change"
+ }
+ ]
+ },
+ {
+ "scenario": "replacement-ready-but-custody-gap",
+ "challengeId": "challenge-materials-screen",
+ "generatedAt": "2026-05-22T15:20:00Z",
+ "decision": "coordinator-review",
+ "summary": {
+ "teams": 1,
+ "affectedTeams": 1,
+ "withdrawingSolvers": 1,
+ "replacementsReady": 1,
+ "requiredActions": 2,
+ "critical": 0,
+ "high": 1,
+ "medium": 1
+ },
+ "findings": [
+ {
+ "type": "artifact-custody-gap",
+ "severity": "high",
+ "target": "team-beta",
+ "artifactIds": [
+ "screening-notebook"
+ ],
+ "message": "team-beta has withdrawing-solver artifacts without custody confirmation."
+ },
+ {
+ "type": "stakeholder-notification-gap",
+ "severity": "medium",
+ "target": "team-beta",
+ "missingAudiences": [
+ "reviewers"
+ ],
+ "message": "team-beta has incomplete sponsor or reviewer withdrawal notifications."
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "confirm_artifact_custody",
+ "target": "team-beta",
+ "reason": "submission artifacts need owner handoff before sponsor review or scoring continues"
+ },
+ {
+ "type": "notify_stakeholders",
+ "target": "team-beta",
+ "reason": "sponsors and reviewers need synchronized withdrawal state before judging or deadlines change"
+ }
+ ]
+ },
+ {
+ "scenario": "clean-continuity-handoff",
+ "challengeId": "challenge-climate-nowcast",
+ "generatedAt": "2026-05-22T15:20:00Z",
+ "decision": "continuity-ready",
+ "summary": {
+ "teams": 1,
+ "affectedTeams": 1,
+ "withdrawingSolvers": 1,
+ "replacementsReady": 1,
+ "requiredActions": 0,
+ "critical": 0,
+ "high": 0,
+ "medium": 0
+ },
+ "findings": [],
+ "requiredActions": []
+ }
+]
diff --git a/challenge-solver-withdrawal-continuity-guard/requirements-map.md b/challenge-solver-withdrawal-continuity-guard/requirements-map.md
new file mode 100644
index 00000000..88f89af6
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/requirements-map.md
@@ -0,0 +1,30 @@
+# Issue #18 Requirement Map
+
+## Secure Submission Engine
+
+- Preserves workspace continuity when a solver withdraws after private challenge access starts.
+- Checks workspace revocation, team quorum, replacement readiness, and artifact custody before work continues.
+- Keeps withdrawal handling separate from challenge intake, broad scoring, and final payout settlement.
+
+## Multi-Phase Challenges
+
+- Flags milestone deadlines within seven days of a withdrawal event.
+- Creates coordinator actions for extension review, partial-credit handling, and sponsor/reviewer notifications.
+- Supports replacement members who meet eligibility and NDA requirements.
+
+## Arbitration And Reward Distribution
+
+- Holds challenge continuity when team quorum, access revocation, or notice evidence is missing.
+- Preserves audit evidence for later arbitration if a withdrawal affects scoring, compensation, or IP custody.
+- Prevents sponsor review or payout handoff from continuing while withdrawn solver artifacts lack custody confirmation.
+
+## IP Management Options
+
+- Requires IP return, retention, or destruction confirmation for withdrawing solvers.
+- Tracks artifact ownership by solver and confirms custody before sponsor/reviewer packets continue.
+
+## Review Evidence
+
+- `test.js` covers workspace hold, coordinator review, and continuity-ready outcomes.
+- `demo.js` generates deterministic JSON, Markdown, SVG, and MP4 artifacts from synthetic data.
+- No live challenge workspace, payment, KYC, bank, payout, sponsor data-room, or external account systems are used.
diff --git a/challenge-solver-withdrawal-continuity-guard/sample-data.js b/challenge-solver-withdrawal-continuity-guard/sample-data.js
new file mode 100644
index 00000000..e65e07e2
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/sample-data.js
@@ -0,0 +1,89 @@
+const scenarios = [
+ {
+ name: 'broken-quorum-and-unrevoked-access',
+ challengeId: 'challenge-protein-folding',
+ generatedAt: '2026-05-22T15:20:00Z',
+ teams: [
+ {
+ id: 'team-alpha',
+ minActiveMembers: 2,
+ currentMilestoneDueAt: '2026-05-25T00:00:00Z',
+ members: [
+ {
+ id: 'solver-1',
+ status: 'withdrawing',
+ noticeAt: '',
+ ndaAccepted: true,
+ ipReturnConfirmed: false,
+ accessRevokedAt: '',
+ lastWorkspaceAccessAt: '2026-05-22T14:00:00Z',
+ },
+ {id: 'solver-2', status: 'active', ndaAccepted: true},
+ ],
+ artifacts: [{id: 'model-checkpoint', ownerId: 'solver-1', custodyConfirmed: false}],
+ notifications: [],
+ },
+ ],
+ },
+ {
+ name: 'replacement-ready-but-custody-gap',
+ challengeId: 'challenge-materials-screen',
+ generatedAt: '2026-05-22T15:20:00Z',
+ teams: [
+ {
+ id: 'team-beta',
+ minActiveMembers: 2,
+ currentMilestoneDueAt: '2026-06-15T00:00:00Z',
+ replacementPolicy: {allowsReplacement: true},
+ members: [
+ {
+ id: 'solver-3',
+ status: 'withdrawing',
+ noticeAt: '2026-05-21T10:00:00Z',
+ ndaAccepted: true,
+ ipReturnConfirmed: true,
+ accessRevokedAt: '2026-05-21T11:00:00Z',
+ lastWorkspaceAccessAt: '2026-05-21T09:30:00Z',
+ },
+ {id: 'solver-4', status: 'active', ndaAccepted: true},
+ {id: 'solver-5', status: 'replacement', ndaAccepted: true, replacementEligible: true},
+ ],
+ artifacts: [{id: 'screening-notebook', ownerId: 'solver-3', custodyConfirmed: false}],
+ notifications: [{audience: 'sponsor', sentAt: '2026-05-21T12:00:00Z'}],
+ },
+ ],
+ },
+ {
+ name: 'clean-continuity-handoff',
+ challengeId: 'challenge-climate-nowcast',
+ generatedAt: '2026-05-22T15:20:00Z',
+ teams: [
+ {
+ id: 'team-gamma',
+ minActiveMembers: 2,
+ currentMilestoneDueAt: '2026-06-30T00:00:00Z',
+ replacementPolicy: {allowsReplacement: true},
+ members: [
+ {
+ id: 'solver-6',
+ status: 'withdrawing',
+ noticeAt: '2026-05-20T09:00:00Z',
+ ndaAccepted: true,
+ ipReturnConfirmed: true,
+ accessRevokedAt: '2026-05-20T10:00:00Z',
+ lastWorkspaceAccessAt: '2026-05-20T09:30:00Z',
+ },
+ {id: 'solver-7', status: 'active', ndaAccepted: true},
+ {id: 'solver-8', status: 'replacement', ndaAccepted: true, replacementEligible: true},
+ ],
+ artifacts: [{id: 'nowcast-dataset', ownerId: 'solver-6', custodyConfirmed: true}],
+ notifications: [
+ {audience: 'sponsor', sentAt: '2026-05-20T10:30:00Z'},
+ {audience: 'reviewers', sentAt: '2026-05-20T10:35:00Z'},
+ ],
+ },
+ ],
+ },
+];
+
+module.exports = {scenarios};
diff --git a/challenge-solver-withdrawal-continuity-guard/test.js b/challenge-solver-withdrawal-continuity-guard/test.js
new file mode 100644
index 00000000..659f5fac
--- /dev/null
+++ b/challenge-solver-withdrawal-continuity-guard/test.js
@@ -0,0 +1,130 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluateSolverWithdrawalContinuity,
+ buildCoordinatorPacket,
+} = require('./index');
+
+test('holds active challenge when withdrawal breaks quorum and access is not revoked', () => {
+ const result = evaluateSolverWithdrawalContinuity({
+ challengeId: 'challenge-protein-folding',
+ generatedAt: '2026-05-22T15:20:00Z',
+ teams: [
+ {
+ id: 'team-alpha',
+ minActiveMembers: 2,
+ currentMilestoneDueAt: '2026-05-25T00:00:00Z',
+ members: [
+ {
+ id: 'solver-1',
+ status: 'withdrawing',
+ noticeAt: '',
+ ndaAccepted: true,
+ ipReturnConfirmed: false,
+ accessRevokedAt: '',
+ lastWorkspaceAccessAt: '2026-05-22T14:00:00Z',
+ },
+ {id: 'solver-2', status: 'active', ndaAccepted: true},
+ ],
+ artifacts: [{id: 'model-checkpoint', ownerId: 'solver-1', custodyConfirmed: false}],
+ notifications: [],
+ },
+ ],
+ });
+
+ assert.equal(result.decision, 'hold-workspace-continuity');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ [
+ 'missing-withdrawal-notice',
+ 'access-revocation-missing',
+ 'ip-return-missing',
+ 'team-quorum-broken',
+ 'artifact-custody-gap',
+ 'milestone-continuity-risk',
+ 'stakeholder-notification-gap',
+ ]
+ );
+ assert.equal(result.summary.critical, 3);
+ assert.equal(result.summary.affectedTeams, 1);
+});
+
+test('routes replacement and custody gaps to coordinator review without blocking clean quorum', () => {
+ const result = evaluateSolverWithdrawalContinuity({
+ challengeId: 'challenge-materials-screen',
+ generatedAt: '2026-05-22T15:20:00Z',
+ teams: [
+ {
+ id: 'team-beta',
+ minActiveMembers: 2,
+ currentMilestoneDueAt: '2026-06-15T00:00:00Z',
+ replacementPolicy: {allowsReplacement: true},
+ members: [
+ {
+ id: 'solver-3',
+ status: 'withdrawing',
+ noticeAt: '2026-05-21T10:00:00Z',
+ ndaAccepted: true,
+ ipReturnConfirmed: true,
+ accessRevokedAt: '2026-05-21T11:00:00Z',
+ lastWorkspaceAccessAt: '2026-05-21T09:30:00Z',
+ },
+ {id: 'solver-4', status: 'active', ndaAccepted: true},
+ {id: 'solver-5', status: 'replacement', ndaAccepted: true, replacementEligible: true},
+ ],
+ artifacts: [{id: 'screening-notebook', ownerId: 'solver-3', custodyConfirmed: false}],
+ notifications: [{audience: 'sponsor', sentAt: '2026-05-21T12:00:00Z'}],
+ },
+ ],
+ });
+
+ assert.equal(result.decision, 'coordinator-review');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ ['artifact-custody-gap', 'stakeholder-notification-gap']
+ );
+ assert.equal(result.summary.replacementsReady, 1);
+ assert.equal(result.requiredActions[0].type, 'confirm_artifact_custody');
+});
+
+test('approves continuity when withdrawal evidence, replacement, custody, and notices are complete', () => {
+ const result = evaluateSolverWithdrawalContinuity({
+ challengeId: 'challenge-climate-nowcast',
+ generatedAt: '2026-05-22T15:20:00Z',
+ teams: [
+ {
+ id: 'team-gamma',
+ minActiveMembers: 2,
+ currentMilestoneDueAt: '2026-06-30T00:00:00Z',
+ replacementPolicy: {allowsReplacement: true},
+ members: [
+ {
+ id: 'solver-6',
+ status: 'withdrawing',
+ noticeAt: '2026-05-20T09:00:00Z',
+ ndaAccepted: true,
+ ipReturnConfirmed: true,
+ accessRevokedAt: '2026-05-20T10:00:00Z',
+ lastWorkspaceAccessAt: '2026-05-20T09:30:00Z',
+ },
+ {id: 'solver-7', status: 'active', ndaAccepted: true},
+ {id: 'solver-8', status: 'replacement', ndaAccepted: true, replacementEligible: true},
+ ],
+ artifacts: [{id: 'nowcast-dataset', ownerId: 'solver-6', custodyConfirmed: true}],
+ notifications: [
+ {audience: 'sponsor', sentAt: '2026-05-20T10:30:00Z'},
+ {audience: 'reviewers', sentAt: '2026-05-20T10:35:00Z'},
+ ],
+ },
+ ],
+ });
+ const packet = buildCoordinatorPacket(result);
+
+ assert.equal(result.decision, 'continuity-ready');
+ assert.equal(result.findings.length, 0);
+ assert.equal(result.summary.replacementsReady, 1);
+ assert.match(packet, /Solver Withdrawal Continuity Guard/);
+ assert.match(packet, /continuity-ready/);
+ assert.match(packet, /No blocking withdrawal continuity gaps detected/);
+});