diff --git a/user-project-management/project-deletion-escrow-auditor/README.md b/user-project-management/project-deletion-escrow-auditor/README.md new file mode 100644 index 00000000..4ef6c22c --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/README.md @@ -0,0 +1,23 @@ +# Project Deletion Escrow Auditor + +This module adds a deterministic, dependency-free destructive-action escrow auditor for SCIBASE issue #11. It checks whether project archive/delete requests are safe to schedule without losing provenance, citations, collaborator access, exports, or compliance records. + +## Scope + +- Requester membership, owner/admin role, and fresh MFA evidence +- Reversible escrow window and rollback checkpoint checks +- Pre-delete export bundle, DOI tombstone, and indexed-project redirect readiness +- Collaborator notice coverage and notice window enforcement +- Legal hold, active review, downstream fork attribution, and unsettled compute charge checks + +All fixtures are synthetic. The module does not access live projects, private files, identity providers, billing providers, credentials, or external APIs, and it never executes destructive actions. + +## Run + +```bash +node user-project-management/project-deletion-escrow-auditor/test.js +node user-project-management/project-deletion-escrow-auditor/demo.js +node user-project-management/project-deletion-escrow-auditor/make-demo-video.js +``` + +Generated deletion escrow audit artifacts are written to `reports/`. diff --git a/user-project-management/project-deletion-escrow-auditor/demo.js b/user-project-management/project-deletion-escrow-auditor/demo.js new file mode 100644 index 00000000..9ee83f16 --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/demo.js @@ -0,0 +1,15 @@ +const fs = require("fs"); +const path = require("path"); +const { auditDeletionEscrow, renderMarkdownReport, renderSvgSummary } = require("./index"); +const { auditPolicy, riskyProject, riskyRequest } = require("./sample-data"); + +const outputDir = path.join(__dirname, "reports"); +fs.mkdirSync(outputDir, { recursive: true }); + +const result = auditDeletionEscrow(riskyProject, riskyRequest, auditPolicy); +fs.writeFileSync(path.join(outputDir, "project-deletion-escrow-audit.json"), `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(path.join(outputDir, "project-deletion-escrow-audit.md"), renderMarkdownReport(result)); +fs.writeFileSync(path.join(outputDir, "project-deletion-escrow-summary.svg"), renderSvgSummary(result)); + +console.log(`decision=${result.decision} riskScore=${result.riskScore} findings=${result.findings.length}`); +console.log(`reports=${outputDir}`); diff --git a/user-project-management/project-deletion-escrow-auditor/index.js b/user-project-management/project-deletion-escrow-auditor/index.js new file mode 100644 index 00000000..695feeb7 --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/index.js @@ -0,0 +1,343 @@ +const crypto = require("crypto"); + +const SEVERITY_WEIGHT = { + blocker: 34, + high: 17, + medium: 8, + low: 3, +}; + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16); +} + +function finding(severity, code, title, evidence, action) { + return { severity, code, title, evidence, action }; +} + +function hoursBetween(startIso, endIso) { + return Math.max(0, Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 36e5)); +} + +function verifyRequester(project, request) { + const findings = []; + const requester = project.members.find((member) => member.id === request.requestedBy); + + if (!requester) { + findings.push(finding( + "blocker", + "REQUESTER_NOT_PROJECT_MEMBER", + "Deletion requester is not a project member", + `${request.requestedBy} is not listed in project ${project.id}.`, + "Reject the destructive action until it is initiated by a current project member.", + )); + return findings; + } + + if (!["owner", "admin"].includes(requester.role)) { + findings.push(finding( + "blocker", + "REQUESTER_LACKS_DESTRUCTIVE_ROLE", + "Requester lacks permission for destructive project action", + `${requester.id} role=${requester.role} attempted ${request.action}.`, + "Require an owner/admin role and a fresh authorization event before deletion or archive.", + )); + } + + if (!requester.mfaConfirmedAt) { + findings.push(finding( + "high", + "REQUESTER_MFA_MISSING", + "Requester has not confirmed MFA for the destructive action", + `${requester.id} has no mfaConfirmedAt value.`, + "Require fresh MFA or passkey confirmation before continuing the escrow workflow.", + )); + } + + return findings; +} + +function verifyEscrowWindow(project, request, policy) { + const findings = []; + const minimumHours = policy.minimumEscrowHours || 72; + + if (!request.escrowCreatedAt || !request.scheduledAt) { + findings.push(finding( + "blocker", + "ESCROW_WINDOW_MISSING", + "Deletion request lacks an escrow window", + `${request.action} has escrowCreatedAt=${request.escrowCreatedAt || "missing"} scheduledAt=${request.scheduledAt || "missing"}.`, + "Create a reversible escrow window before executing destructive project actions.", + )); + return findings; + } + + const hours = hoursBetween(request.escrowCreatedAt, request.scheduledAt); + if (hours < minimumHours) { + findings.push(finding( + "blocker", + "ESCROW_WINDOW_TOO_SHORT", + "Deletion escrow window is too short", + `${project.id} escrow window is ${hours}h, below ${minimumHours}h.`, + "Extend the scheduled deletion time or route to manual institutional review.", + )); + } + + if (!request.rollbackCheckpointId) { + findings.push(finding( + "high", + "ROLLBACK_CHECKPOINT_MISSING", + "No rollback checkpoint is attached", + `${project.id} has no rollback checkpoint for ${request.action}.`, + "Create a recovery snapshot before irreversible archive/delete execution.", + )); + } + + return findings; +} + +function verifyExportAndCitation(project, request) { + const findings = []; + + if (!request.exportBundleId) { + findings.push(finding( + "high", + "PRE_DELETE_EXPORT_MISSING", + "No pre-delete export bundle is attached", + `${project.id} has no export bundle for ${request.action}.`, + "Generate a manifest-backed export bundle before allowing destructive project changes.", + )); + } + + if (project.publication?.doi && !request.doiTombstonePlanned) { + findings.push(finding( + "blocker", + "DOI_TOMBSTONE_MISSING", + "Published DOI project lacks a tombstone plan", + `${project.id} DOI=${project.publication.doi} without tombstone metadata.`, + "Prepare DOI tombstone and citation-preservation metadata before deletion.", + )); + } + + if (project.publication?.indexed && !request.redirectUrl) { + findings.push(finding( + "high", + "INDEXED_PROJECT_REDIRECT_MISSING", + "Indexed project lacks a redirect URL", + `${project.id} is indexed with no redirectUrl.`, + "Add a persistent redirect for search engines, institutional repositories, and citations.", + )); + } + + return findings; +} + +function verifyCollaboratorNotices(project, request, policy) { + const findings = []; + const requiredNoticeHours = policy.minimumNoticeHours || 48; + const notified = new Set(request.notifiedMemberIds || []); + const activeMembers = project.members.filter((member) => member.status === "active"); + const missingNotices = activeMembers.filter((member) => member.id !== request.requestedBy && !notified.has(member.id)); + + if (missingNotices.length) { + findings.push(finding( + "high", + "COLLABORATOR_NOTICE_MISSING", + "Not all active collaborators were notified", + `${missingNotices.map((member) => member.id).join(", ")} did not receive deletion notice.`, + "Notify all active collaborators and give them a chance to export, fork, or dispute the action.", + )); + } + + if (request.noticeSentAt && request.scheduledAt) { + const noticeHours = hoursBetween(request.noticeSentAt, request.scheduledAt); + if (noticeHours < requiredNoticeHours) { + findings.push(finding( + "medium", + "COLLABORATOR_NOTICE_TOO_SHORT", + "Collaborator notice window is shorter than policy", + `${project.id} notice window is ${noticeHours}h, below ${requiredNoticeHours}h.`, + "Delay execution until the configured collaborator notice period has elapsed.", + )); + } + } + + return findings; +} + +function verifyHoldsAndDependencies(project, request) { + const findings = []; + + if (project.legalHold) { + findings.push(finding( + "blocker", + "LEGAL_HOLD_ACTIVE", + "Project is under legal or compliance hold", + `${project.id} legalHold=${project.legalHold.reason}.`, + "Block deletion until legal/compliance owners release the hold.", + )); + } + + if (project.activeReviews?.length) { + findings.push(finding( + "high", + "ACTIVE_REVIEW_DEPENDENCY", + "Project has active review dependencies", + `${project.id} has active reviews: ${project.activeReviews.join(", ")}.`, + "Pause deletion until open peer review, grant, or institutional review dependencies are resolved.", + )); + } + + if (project.childForks?.some((fork) => fork.visibility === "private" && !fork.attributionMigrated)) { + findings.push(finding( + "medium", + "PRIVATE_FORK_ATTRIBUTION_PENDING", + "Private fork attribution has not been migrated", + `${project.id} has private forks without migrated attribution.`, + "Migrate attribution records so downstream forks keep provenance after deletion.", + )); + } + + if (request.action === "delete" && project.billing?.unsettledComputeCents > 0) { + findings.push(finding( + "high", + "UNSETTLED_COMPUTE_CHARGES", + "Project has unsettled compute charges", + `${project.id} has $${(project.billing.unsettledComputeCents / 100).toFixed(2)} unsettled.`, + "Settle or waive compute charges before project deletion.", + )); + } + + return findings; +} + +function auditDeletionEscrow(project, request, policy = {}) { + const findings = [ + ...verifyRequester(project, request), + ...verifyEscrowWindow(project, request, policy), + ...verifyExportAndCitation(project, request), + ...verifyCollaboratorNotices(project, request, policy), + ...verifyHoldsAndDependencies(project, request), + ]; + + const riskScore = Math.min(100, findings.reduce((sum, item) => sum + SEVERITY_WEIGHT[item.severity], 0)); + const blockers = findings.filter((item) => item.severity === "blocker").length; + const high = findings.filter((item) => item.severity === "high").length; + const decision = blockers + ? "block-destructive-project-action" + : high + ? "manual-project-admin-review" + : findings.length + ? "schedule-with-escrow-caveats" + : "ready-for-escrowed-execution"; + + const packet = { + projectId: project.id, + action: request.action, + requestedBy: request.requestedBy, + reviewedAt: policy.reviewDate, + decision, + riskScore, + scheduledAt: request.scheduledAt || null, + activeMemberCount: project.members.filter((member) => member.status === "active").length, + findings, + remediationActions: findings.map((item) => ({ code: item.code, action: item.action })), + generatedFrom: "synthetic-project-admin-data-only", + }; + + return { + ...packet, + auditDigest: digest(packet), + }; +} + +function renderMarkdownReport(result) { + const lines = [ + "# Project Deletion Escrow Audit", + "", + `Project: ${result.projectId}`, + `Action: ${result.action}`, + `Decision: ${result.decision}`, + `Risk score: ${result.riskScore}`, + `Scheduled at: ${result.scheduledAt || "not scheduled"}`, + `Audit digest: ${result.auditDigest}`, + "", + "## Findings", + ]; + + if (!result.findings.length) { + lines.push("- No deletion escrow issues detected."); + } else { + result.findings.forEach((item) => { + lines.push(`- [${item.severity}] ${item.title}: ${item.evidence}`); + lines.push(` Action: ${item.action}`); + }); + } + + lines.push("", "## Safety", "- Synthetic project administration data only; no live projects, private files, identity providers, billing providers, credentials, or external APIs."); + return `${lines.join("\n")}\n`; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderSvgSummary(result) { + const color = result.decision === "block-destructive-project-action" + ? "#b42318" + : result.decision === "ready-for-escrowed-execution" + ? "#067647" + : "#b54708"; + const severityCounts = ["blocker", "high", "medium", "low"].map((severity) => ({ + severity, + count: result.findings.filter((item) => item.severity === severity).length, + })); + const maxCount = Math.max(...severityCounts.map((item) => item.count), 1); + const bars = severityCounts.map((item, index) => { + const y = 205 + index * 52; + const width = Math.round(460 * item.count / maxCount); + const fill = item.severity === "blocker" ? "#7a271a" : item.severity === "high" ? "#b42318" : item.severity === "medium" ? "#f79009" : "#667085"; + return `${escapeXml(item.severity)} + + + ${item.count}`; + }).join("\n"); + + return ` + + + Project Deletion Escrow Audit + + ${escapeXml(result.decision)} + Risk score: ${result.riskScore} + Audit digest: ${escapeXml(result.auditDigest)} + ${bars} + Synthetic project administration data only. No destructive actions are executed. + +`; +} + +module.exports = { + auditDeletionEscrow, + digest, + hoursBetween, + renderMarkdownReport, + renderSvgSummary, +}; diff --git a/user-project-management/project-deletion-escrow-auditor/make-demo-video.js b/user-project-management/project-deletion-escrow-auditor/make-demo-video.js new file mode 100644 index 00000000..bbf0f4ab --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/make-demo-video.js @@ -0,0 +1,99 @@ +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { auditDeletionEscrow } = require("./index"); +const { auditPolicy, riskyProject, riskyRequest } = require("./sample-data"); + +const WIDTH = 1280; +const HEIGHT = 720; +const outputDir = path.join(__dirname, "reports"); +const frameDir = path.join(outputDir, "ppm-frames"); +fs.mkdirSync(frameDir, { recursive: true }); + +function rgb(hex) { + const clean = hex.replace("#", ""); + return [ + Number.parseInt(clean.slice(0, 2), 16), + Number.parseInt(clean.slice(2, 4), 16), + Number.parseInt(clean.slice(4, 6), 16), + ]; +} + +function canvas(color) { + const data = Buffer.alloc(WIDTH * HEIGHT * 3); + const [r, g, b] = rgb(color); + for (let offset = 0; offset < data.length; offset += 3) { + data[offset] = r; + data[offset + 1] = g; + data[offset + 2] = b; + } + return data; +} + +function rect(data, x, y, width, height, color) { + const [r, g, b] = rgb(color); + for (let row = Math.max(0, y); row < Math.min(HEIGHT, y + height); row += 1) { + for (let col = Math.max(0, x); col < Math.min(WIDTH, x + width); col += 1) { + const offset = (row * WIDTH + col) * 3; + data[offset] = r; + data[offset + 1] = g; + data[offset + 2] = b; + } + } +} + +function writePpm(filePath, data) { + fs.writeFileSync(filePath, Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`), data])); +} + +function renderFrame(filePath, result, frame) { + const data = canvas("#f7f8fa"); + rect(data, 36, 36, 1208, 648, "#ffffff"); + rect(data, 36, 36, 1208, 3, "#d0d5dd"); + rect(data, 36, 681, 1208, 3, "#d0d5dd"); + rect(data, 36, 36, 3, 648, "#d0d5dd"); + rect(data, 1241, 36, 3, 648, "#d0d5dd"); + + rect(data, 80, 82, 800, 34, "#101828"); + rect(data, 80, 132, Math.round(800 * result.riskScore / 100), 42, "#b42318"); + rect(data, 900, 82, 260, 92, "#fee4e2"); + rect(data, 936, 112, 188, 32, "#b42318"); + + const severities = ["blocker", "high", "medium", "low"]; + const counts = severities.map((severity) => result.findings.filter((item) => item.severity === severity).length); + const maxCount = Math.max(...counts, 1); + counts.forEach((count, index) => { + const y = 228 + index * 78; + const width = Math.round(820 * count / maxCount); + const color = severities[index] === "blocker" ? "#7a271a" : severities[index] === "high" ? "#b42318" : severities[index] === "medium" ? "#f79009" : "#667085"; + rect(data, 120, y, 820, 42, "#eaecf0"); + rect(data, 120, y, Math.max(10, width - frame * 4), 42, color); + rect(data, 980, y - 8, 96, 58, "#fee4e2"); + rect(data, 1012, y + 12, 36, 18, color); + }); + + rect(data, 80, 650, 1080, 12, "#98a2b3"); + writePpm(filePath, data); +} + +const result = auditDeletionEscrow(riskyProject, riskyRequest, auditPolicy); +for (let frame = 0; frame < 4; frame += 1) { + renderFrame(path.join(frameDir, `frame-${String(frame + 1).padStart(3, "0")}.ppm`), result, frame); +} + +const outputPath = path.join(outputDir, "demo.mp4"); +execFileSync("ffmpeg", [ + "-y", + "-framerate", + "1", + "-i", + path.join(frameDir, "frame-%03d.ppm"), + "-vf", + "fps=12,format=yuv420p", + "-movflags", + "+faststart", + outputPath, +], { stdio: "inherit" }); + +fs.rmSync(frameDir, { recursive: true, force: true }); +console.log(`demo video=${outputPath}`); diff --git a/user-project-management/project-deletion-escrow-auditor/reports/demo.mp4 b/user-project-management/project-deletion-escrow-auditor/reports/demo.mp4 new file mode 100644 index 00000000..6d5b4ac8 Binary files /dev/null and b/user-project-management/project-deletion-escrow-auditor/reports/demo.mp4 differ diff --git a/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-audit.json b/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-audit.json new file mode 100644 index 00000000..b9750149 --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-audit.json @@ -0,0 +1,148 @@ +{ + "projectId": "project-quantum-cell-archive", + "action": "delete", + "requestedBy": "user-editor", + "reviewedAt": "2026-05-28T00:00:00Z", + "decision": "block-destructive-project-action", + "riskScore": 100, + "scheduledAt": "2026-05-29T12:00:00Z", + "activeMemberCount": 3, + "findings": [ + { + "severity": "blocker", + "code": "REQUESTER_LACKS_DESTRUCTIVE_ROLE", + "title": "Requester lacks permission for destructive project action", + "evidence": "user-editor role=editor attempted delete.", + "action": "Require an owner/admin role and a fresh authorization event before deletion or archive." + }, + { + "severity": "blocker", + "code": "ESCROW_WINDOW_TOO_SHORT", + "title": "Deletion escrow window is too short", + "evidence": "project-quantum-cell-archive escrow window is 36h, below 72h.", + "action": "Extend the scheduled deletion time or route to manual institutional review." + }, + { + "severity": "high", + "code": "ROLLBACK_CHECKPOINT_MISSING", + "title": "No rollback checkpoint is attached", + "evidence": "project-quantum-cell-archive has no rollback checkpoint for delete.", + "action": "Create a recovery snapshot before irreversible archive/delete execution." + }, + { + "severity": "high", + "code": "PRE_DELETE_EXPORT_MISSING", + "title": "No pre-delete export bundle is attached", + "evidence": "project-quantum-cell-archive has no export bundle for delete.", + "action": "Generate a manifest-backed export bundle before allowing destructive project changes." + }, + { + "severity": "blocker", + "code": "DOI_TOMBSTONE_MISSING", + "title": "Published DOI project lacks a tombstone plan", + "evidence": "project-quantum-cell-archive DOI=10.1234/scibase.synthetic.11 without tombstone metadata.", + "action": "Prepare DOI tombstone and citation-preservation metadata before deletion." + }, + { + "severity": "high", + "code": "INDEXED_PROJECT_REDIRECT_MISSING", + "title": "Indexed project lacks a redirect URL", + "evidence": "project-quantum-cell-archive is indexed with no redirectUrl.", + "action": "Add a persistent redirect for search engines, institutional repositories, and citations." + }, + { + "severity": "high", + "code": "COLLABORATOR_NOTICE_MISSING", + "title": "Not all active collaborators were notified", + "evidence": "user-reviewer did not receive deletion notice.", + "action": "Notify all active collaborators and give them a chance to export, fork, or dispute the action." + }, + { + "severity": "medium", + "code": "COLLABORATOR_NOTICE_TOO_SHORT", + "title": "Collaborator notice window is shorter than policy", + "evidence": "project-quantum-cell-archive notice window is 34h, below 48h.", + "action": "Delay execution until the configured collaborator notice period has elapsed." + }, + { + "severity": "blocker", + "code": "LEGAL_HOLD_ACTIVE", + "title": "Project is under legal or compliance hold", + "evidence": "project-quantum-cell-archive legalHold=institutional records retention review.", + "action": "Block deletion until legal/compliance owners release the hold." + }, + { + "severity": "high", + "code": "ACTIVE_REVIEW_DEPENDENCY", + "title": "Project has active review dependencies", + "evidence": "project-quantum-cell-archive has active reviews: peer-review-17, grant-closeout-04.", + "action": "Pause deletion until open peer review, grant, or institutional review dependencies are resolved." + }, + { + "severity": "medium", + "code": "PRIVATE_FORK_ATTRIBUTION_PENDING", + "title": "Private fork attribution has not been migrated", + "evidence": "project-quantum-cell-archive has private forks without migrated attribution.", + "action": "Migrate attribution records so downstream forks keep provenance after deletion." + }, + { + "severity": "high", + "code": "UNSETTLED_COMPUTE_CHARGES", + "title": "Project has unsettled compute charges", + "evidence": "project-quantum-cell-archive has $18.75 unsettled.", + "action": "Settle or waive compute charges before project deletion." + } + ], + "remediationActions": [ + { + "code": "REQUESTER_LACKS_DESTRUCTIVE_ROLE", + "action": "Require an owner/admin role and a fresh authorization event before deletion or archive." + }, + { + "code": "ESCROW_WINDOW_TOO_SHORT", + "action": "Extend the scheduled deletion time or route to manual institutional review." + }, + { + "code": "ROLLBACK_CHECKPOINT_MISSING", + "action": "Create a recovery snapshot before irreversible archive/delete execution." + }, + { + "code": "PRE_DELETE_EXPORT_MISSING", + "action": "Generate a manifest-backed export bundle before allowing destructive project changes." + }, + { + "code": "DOI_TOMBSTONE_MISSING", + "action": "Prepare DOI tombstone and citation-preservation metadata before deletion." + }, + { + "code": "INDEXED_PROJECT_REDIRECT_MISSING", + "action": "Add a persistent redirect for search engines, institutional repositories, and citations." + }, + { + "code": "COLLABORATOR_NOTICE_MISSING", + "action": "Notify all active collaborators and give them a chance to export, fork, or dispute the action." + }, + { + "code": "COLLABORATOR_NOTICE_TOO_SHORT", + "action": "Delay execution until the configured collaborator notice period has elapsed." + }, + { + "code": "LEGAL_HOLD_ACTIVE", + "action": "Block deletion until legal/compliance owners release the hold." + }, + { + "code": "ACTIVE_REVIEW_DEPENDENCY", + "action": "Pause deletion until open peer review, grant, or institutional review dependencies are resolved." + }, + { + "code": "PRIVATE_FORK_ATTRIBUTION_PENDING", + "action": "Migrate attribution records so downstream forks keep provenance after deletion." + }, + { + "code": "UNSETTLED_COMPUTE_CHARGES", + "action": "Settle or waive compute charges before project deletion." + } + ], + "generatedFrom": "synthetic-project-admin-data-only", + "auditDigest": "dc9c67424bd6a288" +} diff --git a/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-audit.md b/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-audit.md new file mode 100644 index 00000000..4b85567b --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-audit.md @@ -0,0 +1,37 @@ +# Project Deletion Escrow Audit + +Project: project-quantum-cell-archive +Action: delete +Decision: block-destructive-project-action +Risk score: 100 +Scheduled at: 2026-05-29T12:00:00Z +Audit digest: dc9c67424bd6a288 + +## Findings +- [blocker] Requester lacks permission for destructive project action: user-editor role=editor attempted delete. + Action: Require an owner/admin role and a fresh authorization event before deletion or archive. +- [blocker] Deletion escrow window is too short: project-quantum-cell-archive escrow window is 36h, below 72h. + Action: Extend the scheduled deletion time or route to manual institutional review. +- [high] No rollback checkpoint is attached: project-quantum-cell-archive has no rollback checkpoint for delete. + Action: Create a recovery snapshot before irreversible archive/delete execution. +- [high] No pre-delete export bundle is attached: project-quantum-cell-archive has no export bundle for delete. + Action: Generate a manifest-backed export bundle before allowing destructive project changes. +- [blocker] Published DOI project lacks a tombstone plan: project-quantum-cell-archive DOI=10.1234/scibase.synthetic.11 without tombstone metadata. + Action: Prepare DOI tombstone and citation-preservation metadata before deletion. +- [high] Indexed project lacks a redirect URL: project-quantum-cell-archive is indexed with no redirectUrl. + Action: Add a persistent redirect for search engines, institutional repositories, and citations. +- [high] Not all active collaborators were notified: user-reviewer did not receive deletion notice. + Action: Notify all active collaborators and give them a chance to export, fork, or dispute the action. +- [medium] Collaborator notice window is shorter than policy: project-quantum-cell-archive notice window is 34h, below 48h. + Action: Delay execution until the configured collaborator notice period has elapsed. +- [blocker] Project is under legal or compliance hold: project-quantum-cell-archive legalHold=institutional records retention review. + Action: Block deletion until legal/compliance owners release the hold. +- [high] Project has active review dependencies: project-quantum-cell-archive has active reviews: peer-review-17, grant-closeout-04. + Action: Pause deletion until open peer review, grant, or institutional review dependencies are resolved. +- [medium] Private fork attribution has not been migrated: project-quantum-cell-archive has private forks without migrated attribution. + Action: Migrate attribution records so downstream forks keep provenance after deletion. +- [high] Project has unsettled compute charges: project-quantum-cell-archive has $18.75 unsettled. + Action: Settle or waive compute charges before project deletion. + +## Safety +- Synthetic project administration data only; no live projects, private files, identity providers, billing providers, credentials, or external APIs. diff --git a/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-summary.svg b/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-summary.svg new file mode 100644 index 00000000..606f9e4a --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/reports/project-deletion-escrow-summary.svg @@ -0,0 +1,26 @@ + + + + Project Deletion Escrow Audit + + block-destructive-project-action + Risk score: 100 + Audit digest: dc9c67424bd6a288 + blocker + + + 4 +high + + + 6 +medium + + + 2 +low + + + 0 + Synthetic project administration data only. No destructive actions are executed. + diff --git a/user-project-management/project-deletion-escrow-auditor/sample-data.js b/user-project-management/project-deletion-escrow-auditor/sample-data.js new file mode 100644 index 00000000..6f23caf1 --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/sample-data.js @@ -0,0 +1,82 @@ +const auditPolicy = { + reviewDate: "2026-05-28T00:00:00Z", + minimumEscrowHours: 72, + minimumNoticeHours: 48, +}; + +const riskyProject = { + id: "project-quantum-cell-archive", + members: [ + { id: "user-owner", role: "owner", status: "active", mfaConfirmedAt: "" }, + { id: "user-editor", role: "editor", status: "active", mfaConfirmedAt: "2026-05-27T18:00:00Z" }, + { id: "user-reviewer", role: "reviewer", status: "active", mfaConfirmedAt: "2026-05-26T11:00:00Z" }, + ], + publication: { + doi: "10.1234/scibase.synthetic.11", + indexed: true, + }, + legalHold: { + reason: "institutional records retention review", + }, + activeReviews: ["peer-review-17", "grant-closeout-04"], + childForks: [ + { id: "fork-private-1", visibility: "private", attributionMigrated: false }, + ], + billing: { + unsettledComputeCents: 1875, + }, +}; + +const riskyRequest = { + action: "delete", + requestedBy: "user-editor", + escrowCreatedAt: "2026-05-28T00:00:00Z", + scheduledAt: "2026-05-29T12:00:00Z", + rollbackCheckpointId: "", + exportBundleId: "", + doiTombstonePlanned: false, + redirectUrl: "", + noticeSentAt: "2026-05-28T02:00:00Z", + notifiedMemberIds: ["user-owner"], +}; + +const readyProject = { + id: "project-open-catalyst-archive", + members: [ + { id: "user-owner", role: "owner", status: "active", mfaConfirmedAt: "2026-05-28T10:00:00Z" }, + { id: "user-editor", role: "editor", status: "active", mfaConfirmedAt: "2026-05-27T18:00:00Z" }, + ], + publication: { + doi: "10.1234/scibase.synthetic.ready", + indexed: true, + }, + legalHold: null, + activeReviews: [], + childForks: [ + { id: "fork-public-1", visibility: "public", attributionMigrated: true }, + ], + billing: { + unsettledComputeCents: 0, + }, +}; + +const readyRequest = { + action: "archive", + requestedBy: "user-owner", + escrowCreatedAt: "2026-05-28T00:00:00Z", + scheduledAt: "2026-06-01T00:00:00Z", + rollbackCheckpointId: "checkpoint-archive-v3", + exportBundleId: "export-bundle-archive-v3", + doiTombstonePlanned: true, + redirectUrl: "https://scibase.example/projects/project-open-catalyst-archive/tombstone", + noticeSentAt: "2026-05-28T00:00:00Z", + notifiedMemberIds: ["user-editor"], +}; + +module.exports = { + auditPolicy, + readyProject, + readyRequest, + riskyProject, + riskyRequest, +}; diff --git a/user-project-management/project-deletion-escrow-auditor/test.js b/user-project-management/project-deletion-escrow-auditor/test.js new file mode 100644 index 00000000..9138c074 --- /dev/null +++ b/user-project-management/project-deletion-escrow-auditor/test.js @@ -0,0 +1,48 @@ +const assert = require("assert"); +const { + auditDeletionEscrow, + digest, + hoursBetween, + renderMarkdownReport, + renderSvgSummary, +} = require("./index"); +const { + auditPolicy, + readyProject, + readyRequest, + riskyProject, + riskyRequest, +} = require("./sample-data"); + +const risky = auditDeletionEscrow(riskyProject, riskyRequest, auditPolicy); +assert.strictEqual(risky.decision, "block-destructive-project-action"); +assert.ok(risky.riskScore >= 90, "risky deletion should produce a strong block score"); +assert.ok(risky.findings.some((item) => item.code === "REQUESTER_LACKS_DESTRUCTIVE_ROLE")); +assert.ok(risky.findings.some((item) => item.code === "ESCROW_WINDOW_TOO_SHORT")); +assert.ok(risky.findings.some((item) => item.code === "ROLLBACK_CHECKPOINT_MISSING")); +assert.ok(risky.findings.some((item) => item.code === "PRE_DELETE_EXPORT_MISSING")); +assert.ok(risky.findings.some((item) => item.code === "DOI_TOMBSTONE_MISSING")); +assert.ok(risky.findings.some((item) => item.code === "COLLABORATOR_NOTICE_MISSING")); +assert.ok(risky.findings.some((item) => item.code === "LEGAL_HOLD_ACTIVE")); +assert.ok(risky.findings.some((item) => item.code === "UNSETTLED_COMPUTE_CHARGES")); + +const repeat = auditDeletionEscrow(riskyProject, riskyRequest, auditPolicy); +assert.strictEqual(risky.auditDigest, repeat.auditDigest, "audit digest must be deterministic"); + +const ready = auditDeletionEscrow(readyProject, readyRequest, auditPolicy); +assert.strictEqual(ready.decision, "ready-for-escrowed-execution"); +assert.strictEqual(ready.findings.length, 0); + +assert.strictEqual(hoursBetween("2026-05-28T00:00:00Z", "2026-05-29T12:00:00Z"), 36); +assert.strictEqual(digest({ b: 2, a: 1 }), digest({ a: 1, b: 2 })); + +const markdown = renderMarkdownReport(risky); +assert.ok(markdown.includes("Project Deletion Escrow Audit")); +assert.ok(markdown.includes("Synthetic project administration data only")); +assert.ok(markdown.includes("block-destructive-project-action")); + +const svg = renderSvgSummary(risky); +assert.ok(svg.includes("