diff --git a/repository-restore-rehearsal-guard/README.md b/repository-restore-rehearsal-guard/README.md new file mode 100644 index 00000000..73855496 --- /dev/null +++ b/repository-restore-rehearsal-guard/README.md @@ -0,0 +1,30 @@ +# Repository Restore Rehearsal Guard + +This module adds a focused Project Repository & Version Control slice for SCIBASE issue #10. It validates whether a tagged repository snapshot can be restored before DOI/export publication. + +The guard is intentionally narrower than repository ledgers, rollback, legal-hold, embargo, access-review, and release-engine work. It answers one reviewer question: can this exact scientific snapshot be restored with the expected refs, components, large artifacts, environment locks, notebook replay evidence, and export manifest? + +## What It Checks + +- release candidates declare an immutable snapshot id +- restore drills are recent and passed +- protected refs and semantic tags are included in the snapshot +- required `manuscript`, `data`, `code`, and `metadata` components match expected hashes +- large or restricted artifacts have ready mirrors with matching hashes +- container and dependency locks are available for replay +- notebook replay evidence is fresh +- export manifests point at the restored snapshot + +## Usage + +```bash +npm run check +npm test +npm run demo +``` + +`npm run demo` writes deterministic reviewer artifacts into `reports/`, including JSON, Markdown, SVG, and an MP4 demo video when `ffmpeg` is available. + +## Safety + +All examples are synthetic. The module does not scan private repositories, call external services, open network connections, or include credentials. diff --git a/repository-restore-rehearsal-guard/acceptance-notes.md b/repository-restore-rehearsal-guard/acceptance-notes.md new file mode 100644 index 00000000..c04b42c6 --- /dev/null +++ b/repository-restore-rehearsal-guard/acceptance-notes.md @@ -0,0 +1,21 @@ +# Acceptance Notes + +## Reviewer Path + +1. Run `npm run check` to verify syntax. +2. Run `npm test` to verify restore decisions, blockers, clean release behavior, and deterministic audit digests. +3. Run `npm run demo` to regenerate reviewer artifacts in `reports/`. +4. Inspect `reports/restore-rehearsal-report.md` for the release queue and remediation queue. + +## Expected Results + +- `neuro-reproducibility-atlas` is `restore_ready`. +- `climate-table-fork` is held because restore drill, ref coverage, tag coverage, data checksum, mirror readiness, and environment lock checks fail. +- `protein-model-sandbox` is held because the snapshot/drill/notebook replay evidence is stale and no export manifest is linked. + +## Demo Artifacts + +- `reports/restore-rehearsal-packet.json` +- `reports/restore-rehearsal-report.md` +- `reports/summary.svg` +- `reports/demo.mp4` diff --git a/repository-restore-rehearsal-guard/demo.js b/repository-restore-rehearsal-guard/demo.js new file mode 100644 index 00000000..baa6704c --- /dev/null +++ b/repository-restore-rehearsal-guard/demo.js @@ -0,0 +1,88 @@ +const fs = require("node:fs") +const path = require("node:path") +const { spawnSync } = require("node:child_process") +const { evaluateRestoreRehearsal } = require("./index") +const { repositories, policy } = require("./sample-data") + +const reportsDir = path.join(__dirname, "reports") +fs.mkdirSync(reportsDir, { recursive: true }) + +const packet = evaluateRestoreRehearsal({ + asOf: "2026-05-23T02:00:00.000Z", + repositories, + policy, +}) + +fs.writeFileSync( + path.join(reportsDir, "restore-rehearsal-packet.json"), + `${JSON.stringify(packet, null, 2)}\n`, +) + +const markdown = [ + "# Repository Restore Rehearsal Report", + "", + `Repositories reviewed: ${packet.summary.totalRepositories}`, + `Restore-ready repositories: ${packet.summary.readyRepositories}`, + `Held repositories: ${packet.summary.heldRepositories}`, + `Critical findings: ${packet.summary.criticalFindings}`, + `Warning findings: ${packet.summary.warningFindings}`, + `Audit digest: \`${packet.audit.digest}\``, + "", + "## Repository Decisions", + ...packet.repositories.flatMap((repository) => [ + "", + `### ${repository.title}`, + `- Status: ${repository.status}`, + `- Snapshot: ${repository.snapshotId}`, + `- Candidate tag: ${repository.releaseCandidate.tag || "none"}`, + `- Checks passed: ${repository.summary.passedChecks}/${repository.summary.checks}`, + `- Finding codes: ${repository.findings.map((finding) => finding.code).join(", ") || "none"}`, + ]), + "", + "## Remediation Queue", + ...packet.remediationQueue.map((item) => ( + `- ${item.repositoryId}/${item.snapshotId}: ${item.action} (${item.severity})` + )), + "", +] + +fs.writeFileSync(path.join(reportsDir, "restore-rehearsal-report.md"), markdown.join("\n")) + +const svg = ` + + Repository Restore Rehearsal Guard + Release snapshots are checked before DOI/export publication + + ${packet.summary.readyRepositories} + restore ready + + ${packet.summary.warningFindings} + warnings + + ${packet.summary.criticalFindings} + critical + Controls: restore drill, refs, tags, checksums, LFS mirrors, environment locks, notebook replay, export manifest. + Digest ${packet.audit.digest.slice(0, 28)}... + +` + +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg) + +const ffmpeg = spawnSync("ffmpeg", [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x102018:s=960x540:d=5:r=15", + "-vf", + "drawbox=x=48:y=172:w=250:h=150:color=0x047857@1:t=fill,drawbox=x=355:y=172:w=250:h=150:color=0xb45309@1:t=fill,drawbox=x=662:y=172:w=250:h=150:color=0xbe123c@1:t=fill,drawbox=x=48:y=368:w=864:h=18:color=0x22c55e@1:t=fill", + "-pix_fmt", + "yuv420p", + path.join(reportsDir, "demo.mp4"), +], { stdio: "ignore" }) + +if (ffmpeg.status !== 0) { + console.warn("ffmpeg video generation failed; JSON, Markdown, and SVG reports were still generated.") +} + +console.log(`Wrote repository restore rehearsal artifacts to ${reportsDir}`) diff --git a/repository-restore-rehearsal-guard/index.js b/repository-restore-rehearsal-guard/index.js new file mode 100644 index 00000000..0e27ce57 --- /dev/null +++ b/repository-restore-rehearsal-guard/index.js @@ -0,0 +1,451 @@ +const crypto = require("node:crypto") + +const DAY_MS = 24 * 60 * 60 * 1000 + +const DEFAULT_POLICY = Object.freeze({ + maxSnapshotAgeDays: 30, + maxRestoreDrillAgeDays: 21, + maxNotebookReplayAgeDays: 14, + requiredRefs: ["refs/heads/main"], + requiredComponents: ["manuscript", "data", "code", "metadata"], + requiredEnvironmentLocks: ["containerDigest", "dependencyLock"], + policyVersion: "repository-restore-rehearsal-v1", +}) + +function evaluateRestoreRehearsal(input = {}) { + const policy = { ...DEFAULT_POLICY, ...(input.policy || {}) } + const asOf = input.asOf || new Date().toISOString() + const now = Date.parse(asOf) + const repositories = Array.isArray(input.repositories) ? input.repositories : [] + const repositoryDecisions = repositories.map((repository) => evaluateRepository(repository, now, policy)) + const remediationQueue = repositoryDecisions.flatMap((repository) => repository.remediationQueue) + const summary = summarize(repositoryDecisions, remediationQueue) + const auditPayload = { + asOf, + policy, + summary, + repositories: repositoryDecisions.map((repository) => ({ + repositoryId: repository.repositoryId, + status: repository.status, + snapshotId: repository.snapshotId, + findingCodes: repository.findings.map((finding) => finding.code), + })), + } + + return { + asOf, + policy, + summary, + repositories: repositoryDecisions, + remediationQueue, + releaseQueue: repositoryDecisions + .filter((repository) => repository.status === "restore_ready") + .map((repository) => ({ + repositoryId: repository.repositoryId, + snapshotId: repository.snapshotId, + tag: repository.releaseCandidate.tag, + action: "allow_release_export", + })), + audit: { + algorithm: "sha256", + digest: digest(auditPayload), + policyVersion: policy.policyVersion, + }, + } +} + +function evaluateRepository(repository, now, policy) { + const releaseCandidate = repository.releaseCandidate || {} + const snapshot = findSnapshot(repository.snapshots, releaseCandidate.snapshotId) + const findings = [] + const checks = [] + + addCheck(checks, findings, { + code: "candidate_snapshot_declared", + severity: "critical", + pass: Boolean(releaseCandidate.snapshotId), + message: "Release candidate declares the snapshot that should be restored before export.", + remediation: "Bind the release candidate to an immutable snapshot id before tagging or exporting.", + }) + + addCheck(checks, findings, { + code: "snapshot_found", + severity: "critical", + pass: Boolean(snapshot), + message: "Declared snapshot exists in the repository restore catalog.", + remediation: "Create or re-index the immutable snapshot before continuing release review.", + }) + + if (!snapshot) { + return repositoryDecision(repository, releaseCandidate, null, checks, findings, policy) + } + + const snapshotAgeDays = daysBetween(now, snapshot.createdAt) + addCheck(checks, findings, { + code: "snapshot_recency", + severity: "warning", + pass: snapshotAgeDays <= policy.maxSnapshotAgeDays, + message: `Snapshot age is ${snapshotAgeDays} days.`, + remediation: "Cut a fresh snapshot or document why an older snapshot is still the release source.", + evidence: { snapshotAgeDays, maxSnapshotAgeDays: policy.maxSnapshotAgeDays }, + }) + + const drill = latestRestoreDrill(repository.restoreDrills, snapshot.id) + const drillAgeDays = drill ? daysBetween(now, drill.completedAt) : null + addCheck(checks, findings, { + code: "restore_drill_recent_passed", + severity: "critical", + pass: Boolean(drill && drill.status === "passed" && drillAgeDays <= policy.maxRestoreDrillAgeDays), + message: drill + ? `Latest restore drill status is ${drill.status} and age is ${drillAgeDays} days.` + : "No restore drill exists for the release snapshot.", + remediation: "Run a restore rehearsal from the immutable snapshot and attach the passed drill report.", + evidence: drill ? { drillId: drill.id, status: drill.status, drillAgeDays } : { drillId: null }, + }) + + const requiredRefs = unique([...(policy.requiredRefs || []), ...(releaseCandidate.requiredRefs || [])]) + const snapshotRefs = new Set(normalizeArray(snapshot.refs)) + const missingRefs = requiredRefs.filter((ref) => !snapshotRefs.has(ref)) + addCheck(checks, findings, { + code: "ref_coverage", + severity: "critical", + pass: missingRefs.length === 0, + message: missingRefs.length === 0 + ? "Snapshot covers every required protected branch ref." + : `Snapshot is missing required refs: ${missingRefs.join(", ")}.`, + remediation: "Rebuild the snapshot with all protected branch refs required for release reconstruction.", + evidence: { requiredRefs, missingRefs }, + }) + + const snapshotTags = new Set(normalizeArray(snapshot.tags)) + addCheck(checks, findings, { + code: "tag_coverage", + severity: "warning", + pass: !releaseCandidate.tag || snapshotTags.has(releaseCandidate.tag), + message: releaseCandidate.tag + ? `Release tag ${releaseCandidate.tag} is ${snapshotTags.has(releaseCandidate.tag) ? "present" : "missing"} in the snapshot.` + : "Release candidate does not declare a semantic tag.", + remediation: "Attach the release tag to the immutable snapshot before DOI or export publication.", + evidence: { tag: releaseCandidate.tag || null }, + }) + + const componentResult = evaluateComponentIntegrity(repository.expectedComponents, snapshot, policy) + addCheck(checks, findings, { + code: "component_manifest_integrity", + severity: "critical", + pass: componentResult.failures.length === 0, + message: componentResult.failures.length === 0 + ? "Required repository components match the snapshot manifest." + : `Component manifest has ${componentResult.failures.length} restore blockers.`, + remediation: "Regenerate the manifest from restored files and reconcile missing or mismatched component hashes.", + evidence: componentResult, + }) + + const mirrorResult = evaluateArtifactMirrors(snapshot) + addCheck(checks, findings, { + code: "artifact_mirror_ready", + severity: "critical", + pass: mirrorResult.failures.length === 0, + message: mirrorResult.failures.length === 0 + ? "Large and restricted artifacts have matching restore mirrors." + : `Artifact mirror readiness has ${mirrorResult.failures.length} blockers.`, + remediation: "Mirror required LFS or restricted artifacts and verify mirror digests before export.", + evidence: mirrorResult, + }) + + const lockResult = evaluateEnvironmentLocks(snapshot, policy) + addCheck(checks, findings, { + code: "environment_lock_coverage", + severity: "warning", + pass: lockResult.missing.length === 0, + message: lockResult.missing.length === 0 + ? "Environment locks are present for restore replay." + : `Missing environment locks: ${lockResult.missing.join(", ")}.`, + remediation: "Attach container image digests and dependency lockfiles used by the restore drill.", + evidence: lockResult, + }) + + const replayResult = evaluateNotebookReplay(snapshot, now, policy) + addCheck(checks, findings, { + code: "notebook_replay_recent", + severity: replayResult.failed.length > 0 ? "critical" : "warning", + pass: replayResult.failed.length === 0 && replayResult.stale.length === 0 && replayResult.missing.length === 0, + message: replayResult.total === 0 + ? "Snapshot contains no notebooks requiring replay evidence." + : `Notebook replay evidence reviewed for ${replayResult.total} notebook artifacts.`, + remediation: "Replay notebooks from the restored environment and attach fresh pass evidence.", + evidence: replayResult, + }) + + addCheck(checks, findings, { + code: "export_manifest_linked", + severity: "critical", + pass: Boolean(snapshot.exportManifest && snapshot.exportManifest.snapshotId === snapshot.id), + message: snapshot.exportManifest + ? "Export manifest is linked to the restored snapshot." + : "Export manifest is missing for the release snapshot.", + remediation: "Generate an export manifest that names the immutable snapshot and all restored components.", + evidence: { + snapshotId: snapshot.id, + exportSnapshotId: snapshot.exportManifest ? snapshot.exportManifest.snapshotId : null, + doi: snapshot.exportManifest ? snapshot.exportManifest.doi || null : null, + }, + }) + + return repositoryDecision(repository, releaseCandidate, snapshot, checks, findings, policy) +} + +function repositoryDecision(repository, releaseCandidate, snapshot, checks, findings, policy) { + const criticalFindings = findings.filter((finding) => finding.severity === "critical") + const warningFindings = findings.filter((finding) => finding.severity === "warning") + const status = criticalFindings.length > 0 + ? "hold_restore_rehearsal" + : warningFindings.length > 0 + ? "steward_review" + : "restore_ready" + + return { + repositoryId: repository.repositoryId, + title: repository.title, + status, + snapshotId: snapshot ? snapshot.id : releaseCandidate.snapshotId || null, + releaseCandidate, + summary: { + checks: checks.length, + passedChecks: checks.filter((check) => check.pass).length, + criticalFindings: criticalFindings.length, + warningFindings: warningFindings.length, + }, + checks, + findings, + remediationQueue: findings.map((finding) => ({ + repositoryId: repository.repositoryId, + snapshotId: snapshot ? snapshot.id : releaseCandidate.snapshotId || null, + code: finding.code, + severity: finding.severity, + action: remediationAction(finding.code), + remediation: finding.remediation, + })), + restorePacket: { + policyVersion: policy.policyVersion, + candidateTag: releaseCandidate.tag || null, + restoredSnapshot: snapshot ? snapshot.id : null, + decision: status, + }, + } +} + +function evaluateComponentIntegrity(expectedComponents = [], snapshot, policy) { + const expected = expectedComponents.length > 0 + ? expectedComponents + : policy.requiredComponents.map((component) => ({ component, hash: null })) + const artifacts = new Map((snapshot.artifacts || []).map((artifact) => [artifact.component, artifact])) + const required = new Set(policy.requiredComponents || []) + const failures = [] + const covered = [] + + for (const component of expected) { + const artifact = artifacts.get(component.component) + if (!artifact && required.has(component.component)) { + failures.push({ component: component.component, reason: "missing_required_component" }) + continue + } + + if (!artifact) { + continue + } + + if (component.hash && artifact.hash !== component.hash) { + failures.push({ + component: component.component, + reason: "hash_mismatch", + expectedHash: component.hash, + snapshotHash: artifact.hash, + }) + continue + } + + covered.push(component.component) + } + + for (const requiredComponent of required) { + if (!artifacts.has(requiredComponent)) { + failures.push({ component: requiredComponent, reason: "required_component_absent_from_snapshot" }) + } + } + + return { + covered: unique(covered), + failures: uniqueFailures(failures), + } +} + +function evaluateArtifactMirrors(snapshot) { + const failures = [] + const mirrored = [] + + for (const artifact of snapshot.artifacts || []) { + const needsMirror = artifact.large === true || artifact.restricted === true || artifact.storage === "lfs" + if (!needsMirror) { + continue + } + + if (!artifact.mirror || artifact.mirror.status !== "ready") { + failures.push({ path: artifact.path, component: artifact.component, reason: "mirror_not_ready" }) + continue + } + + if (artifact.mirror.hash && artifact.mirror.hash !== artifact.hash) { + failures.push({ path: artifact.path, component: artifact.component, reason: "mirror_hash_mismatch" }) + continue + } + + mirrored.push({ path: artifact.path, component: artifact.component }) + } + + return { mirrored, failures } +} + +function evaluateEnvironmentLocks(snapshot, policy) { + const locks = snapshot.environmentLocks || {} + return { + present: Object.keys(locks).filter((key) => Boolean(locks[key])), + missing: (policy.requiredEnvironmentLocks || []).filter((key) => !locks[key]), + } +} + +function evaluateNotebookReplay(snapshot, now, policy) { + const notebooks = (snapshot.artifacts || []).filter((artifact) => artifact.component === "notebooks") + const failed = [] + const stale = [] + const missing = [] + + for (const notebook of notebooks) { + if (!notebook.replay) { + missing.push(notebook.path) + continue + } + + if (notebook.replay.status !== "passed") { + failed.push(notebook.path) + continue + } + + const ageDays = daysBetween(now, notebook.replay.completedAt) + if (ageDays > policy.maxNotebookReplayAgeDays) { + stale.push({ path: notebook.path, ageDays }) + } + } + + return { + total: notebooks.length, + failed, + stale, + missing, + } +} + +function addCheck(checks, findings, check) { + checks.push({ + code: check.code, + severity: check.severity, + pass: check.pass, + message: check.message, + evidence: check.evidence || {}, + }) + + if (!check.pass) { + findings.push({ + code: check.code, + severity: check.severity, + message: check.message, + remediation: check.remediation, + evidence: check.evidence || {}, + }) + } +} + +function summarize(repositoryDecisions, remediationQueue) { + return { + totalRepositories: repositoryDecisions.length, + readyRepositories: repositoryDecisions.filter((repository) => repository.status === "restore_ready").length, + stewardReviewRepositories: repositoryDecisions.filter((repository) => repository.status === "steward_review").length, + heldRepositories: repositoryDecisions.filter((repository) => repository.status === "hold_restore_rehearsal").length, + totalFindings: remediationQueue.length, + criticalFindings: remediationQueue.filter((item) => item.severity === "critical").length, + warningFindings: remediationQueue.filter((item) => item.severity === "warning").length, + } +} + +function findSnapshot(snapshots = [], snapshotId) { + if (!snapshotId) { + return null + } + + return snapshots.find((snapshot) => snapshot.id === snapshotId) || null +} + +function latestRestoreDrill(drills = [], snapshotId) { + const matching = drills.filter((drill) => drill.snapshotId === snapshotId) + matching.sort((a, b) => Date.parse(b.completedAt) - Date.parse(a.completedAt)) + return matching[0] || null +} + +function daysBetween(now, dateValue) { + if (!dateValue) { + return Number.POSITIVE_INFINITY + } + + return Math.floor((now - Date.parse(dateValue)) / DAY_MS) +} + +function normalizeArray(value) { + return Array.isArray(value) ? value.filter(Boolean) : [] +} + +function unique(values) { + return [...new Set(values)] +} + +function uniqueFailures(failures) { + const seen = new Set() + return failures.filter((failure) => { + const key = `${failure.component}:${failure.reason}` + if (seen.has(key)) { + return false + } + + seen.add(key) + return true + }) +} + +function remediationAction(code) { + const actions = { + candidate_snapshot_declared: "bind_release_candidate_snapshot", + snapshot_found: "restore_snapshot_catalog", + snapshot_recency: "refresh_release_snapshot", + restore_drill_recent_passed: "run_restore_rehearsal", + ref_coverage: "rebuild_snapshot_refs", + tag_coverage: "attach_release_tag", + component_manifest_integrity: "reconcile_component_manifest", + artifact_mirror_ready: "repair_artifact_mirror", + environment_lock_coverage: "attach_environment_locks", + notebook_replay_recent: "rerun_notebook_replay", + export_manifest_linked: "generate_export_manifest", + } + + return actions[code] || "steward_review" +} + +function digest(payload) { + return crypto + .createHash("sha256") + .update(JSON.stringify(payload)) + .digest("hex") +} + +module.exports = { + DEFAULT_POLICY, + evaluateRestoreRehearsal, +} diff --git a/repository-restore-rehearsal-guard/package.json b/repository-restore-rehearsal-guard/package.json new file mode 100644 index 00000000..61856ecd --- /dev/null +++ b/repository-restore-rehearsal-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "repository-restore-rehearsal-guard", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js", + "test": "node test.js", + "demo": "node demo.js" + } +} diff --git a/repository-restore-rehearsal-guard/reports/demo.mp4 b/repository-restore-rehearsal-guard/reports/demo.mp4 new file mode 100644 index 00000000..87f6c63b Binary files /dev/null and b/repository-restore-rehearsal-guard/reports/demo.mp4 differ diff --git a/repository-restore-rehearsal-guard/reports/restore-rehearsal-packet.json b/repository-restore-rehearsal-guard/reports/restore-rehearsal-packet.json new file mode 100644 index 00000000..70f358db --- /dev/null +++ b/repository-restore-rehearsal-guard/reports/restore-rehearsal-packet.json @@ -0,0 +1,818 @@ +{ + "asOf": "2026-05-23T02:00:00.000Z", + "policy": { + "maxSnapshotAgeDays": 30, + "maxRestoreDrillAgeDays": 21, + "maxNotebookReplayAgeDays": 14, + "requiredRefs": [ + "refs/heads/main" + ], + "requiredComponents": [ + "manuscript", + "data", + "code", + "metadata" + ], + "requiredEnvironmentLocks": [ + "containerDigest", + "dependencyLock" + ], + "policyVersion": "repository-restore-rehearsal-v1" + }, + "summary": { + "totalRepositories": 3, + "readyRepositories": 1, + "stewardReviewRepositories": 0, + "heldRepositories": 2, + "totalFindings": 10, + "criticalFindings": 6, + "warningFindings": 4 + }, + "repositories": [ + { + "repositoryId": "neuro-reproducibility-atlas", + "title": "Neuro Reproducibility Atlas", + "status": "restore_ready", + "snapshotId": "snap-2026-05-21", + "releaseCandidate": { + "snapshotId": "snap-2026-05-21", + "tag": "v1.2.0", + "requiredRefs": [ + "refs/heads/release/v1.2" + ] + }, + "summary": { + "checks": 11, + "passedChecks": 11, + "criticalFindings": 0, + "warningFindings": 0 + }, + "checks": [ + { + "code": "candidate_snapshot_declared", + "severity": "critical", + "pass": true, + "message": "Release candidate declares the snapshot that should be restored before export.", + "evidence": {} + }, + { + "code": "snapshot_found", + "severity": "critical", + "pass": true, + "message": "Declared snapshot exists in the repository restore catalog.", + "evidence": {} + }, + { + "code": "snapshot_recency", + "severity": "warning", + "pass": true, + "message": "Snapshot age is 1 days.", + "evidence": { + "snapshotAgeDays": 1, + "maxSnapshotAgeDays": 30 + } + }, + { + "code": "restore_drill_recent_passed", + "severity": "critical", + "pass": true, + "message": "Latest restore drill status is passed and age is 0 days.", + "evidence": { + "drillId": "drill-neuro-20260522", + "status": "passed", + "drillAgeDays": 0 + } + }, + { + "code": "ref_coverage", + "severity": "critical", + "pass": true, + "message": "Snapshot covers every required protected branch ref.", + "evidence": { + "requiredRefs": [ + "refs/heads/main", + "refs/heads/release/v1.2" + ], + "missingRefs": [] + } + }, + { + "code": "tag_coverage", + "severity": "warning", + "pass": true, + "message": "Release tag v1.2.0 is present in the snapshot.", + "evidence": { + "tag": "v1.2.0" + } + }, + { + "code": "component_manifest_integrity", + "severity": "critical", + "pass": true, + "message": "Required repository components match the snapshot manifest.", + "evidence": { + "covered": [ + "manuscript", + "data", + "code", + "metadata" + ], + "failures": [] + } + }, + { + "code": "artifact_mirror_ready", + "severity": "critical", + "pass": true, + "message": "Large and restricted artifacts have matching restore mirrors.", + "evidence": { + "mirrored": [ + { + "path": "data/participants.parquet", + "component": "data" + } + ], + "failures": [] + } + }, + { + "code": "environment_lock_coverage", + "severity": "warning", + "pass": true, + "message": "Environment locks are present for restore replay.", + "evidence": { + "present": [ + "containerDigest", + "dependencyLock" + ], + "missing": [] + } + }, + { + "code": "notebook_replay_recent", + "severity": "warning", + "pass": true, + "message": "Notebook replay evidence reviewed for 1 notebook artifacts.", + "evidence": { + "total": 1, + "failed": [], + "stale": [], + "missing": [] + } + }, + { + "code": "export_manifest_linked", + "severity": "critical", + "pass": true, + "message": "Export manifest is linked to the restored snapshot.", + "evidence": { + "snapshotId": "snap-2026-05-21", + "exportSnapshotId": "snap-2026-05-21", + "doi": "10.5555/scibase.neuro-atlas.v1.2.0" + } + } + ], + "findings": [], + "remediationQueue": [], + "restorePacket": { + "policyVersion": "repository-restore-rehearsal-v1", + "candidateTag": "v1.2.0", + "restoredSnapshot": "snap-2026-05-21", + "decision": "restore_ready" + } + }, + { + "repositoryId": "climate-table-fork", + "title": "Climate Table Fork", + "status": "hold_restore_rehearsal", + "snapshotId": "snap-2026-05-08", + "releaseCandidate": { + "snapshotId": "snap-2026-05-08", + "tag": "preprint-v0.9", + "requiredRefs": [ + "refs/heads/release/preprint" + ] + }, + "summary": { + "checks": 11, + "passedChecks": 5, + "criticalFindings": 4, + "warningFindings": 2 + }, + "checks": [ + { + "code": "candidate_snapshot_declared", + "severity": "critical", + "pass": true, + "message": "Release candidate declares the snapshot that should be restored before export.", + "evidence": {} + }, + { + "code": "snapshot_found", + "severity": "critical", + "pass": true, + "message": "Declared snapshot exists in the repository restore catalog.", + "evidence": {} + }, + { + "code": "snapshot_recency", + "severity": "warning", + "pass": true, + "message": "Snapshot age is 14 days.", + "evidence": { + "snapshotAgeDays": 14, + "maxSnapshotAgeDays": 30 + } + }, + { + "code": "restore_drill_recent_passed", + "severity": "critical", + "pass": false, + "message": "Latest restore drill status is failed and age is 13 days.", + "evidence": { + "drillId": "drill-climate-20260509", + "status": "failed", + "drillAgeDays": 13 + } + }, + { + "code": "ref_coverage", + "severity": "critical", + "pass": false, + "message": "Snapshot is missing required refs: refs/heads/release/preprint.", + "evidence": { + "requiredRefs": [ + "refs/heads/main", + "refs/heads/release/preprint" + ], + "missingRefs": [ + "refs/heads/release/preprint" + ] + } + }, + { + "code": "tag_coverage", + "severity": "warning", + "pass": false, + "message": "Release tag preprint-v0.9 is missing in the snapshot.", + "evidence": { + "tag": "preprint-v0.9" + } + }, + { + "code": "component_manifest_integrity", + "severity": "critical", + "pass": false, + "message": "Component manifest has 1 restore blockers.", + "evidence": { + "covered": [ + "manuscript", + "code", + "metadata" + ], + "failures": [ + { + "component": "data", + "reason": "hash_mismatch", + "expectedHash": "sha256:expected-climate-data", + "snapshotHash": "sha256:actual-climate-data" + } + ] + } + }, + { + "code": "artifact_mirror_ready", + "severity": "critical", + "pass": false, + "message": "Artifact mirror readiness has 1 blockers.", + "evidence": { + "mirrored": [], + "failures": [ + { + "path": "data/raw-grid.parquet", + "component": "data", + "reason": "mirror_not_ready" + } + ] + } + }, + { + "code": "environment_lock_coverage", + "severity": "warning", + "pass": false, + "message": "Missing environment locks: dependencyLock.", + "evidence": { + "present": [ + "containerDigest" + ], + "missing": [ + "dependencyLock" + ] + } + }, + { + "code": "notebook_replay_recent", + "severity": "warning", + "pass": true, + "message": "Snapshot contains no notebooks requiring replay evidence.", + "evidence": { + "total": 0, + "failed": [], + "stale": [], + "missing": [] + } + }, + { + "code": "export_manifest_linked", + "severity": "critical", + "pass": true, + "message": "Export manifest is linked to the restored snapshot.", + "evidence": { + "snapshotId": "snap-2026-05-08", + "exportSnapshotId": "snap-2026-05-08", + "doi": "10.5555/scibase.climate.preprint" + } + } + ], + "findings": [ + { + "code": "restore_drill_recent_passed", + "severity": "critical", + "message": "Latest restore drill status is failed and age is 13 days.", + "remediation": "Run a restore rehearsal from the immutable snapshot and attach the passed drill report.", + "evidence": { + "drillId": "drill-climate-20260509", + "status": "failed", + "drillAgeDays": 13 + } + }, + { + "code": "ref_coverage", + "severity": "critical", + "message": "Snapshot is missing required refs: refs/heads/release/preprint.", + "remediation": "Rebuild the snapshot with all protected branch refs required for release reconstruction.", + "evidence": { + "requiredRefs": [ + "refs/heads/main", + "refs/heads/release/preprint" + ], + "missingRefs": [ + "refs/heads/release/preprint" + ] + } + }, + { + "code": "tag_coverage", + "severity": "warning", + "message": "Release tag preprint-v0.9 is missing in the snapshot.", + "remediation": "Attach the release tag to the immutable snapshot before DOI or export publication.", + "evidence": { + "tag": "preprint-v0.9" + } + }, + { + "code": "component_manifest_integrity", + "severity": "critical", + "message": "Component manifest has 1 restore blockers.", + "remediation": "Regenerate the manifest from restored files and reconcile missing or mismatched component hashes.", + "evidence": { + "covered": [ + "manuscript", + "code", + "metadata" + ], + "failures": [ + { + "component": "data", + "reason": "hash_mismatch", + "expectedHash": "sha256:expected-climate-data", + "snapshotHash": "sha256:actual-climate-data" + } + ] + } + }, + { + "code": "artifact_mirror_ready", + "severity": "critical", + "message": "Artifact mirror readiness has 1 blockers.", + "remediation": "Mirror required LFS or restricted artifacts and verify mirror digests before export.", + "evidence": { + "mirrored": [], + "failures": [ + { + "path": "data/raw-grid.parquet", + "component": "data", + "reason": "mirror_not_ready" + } + ] + } + }, + { + "code": "environment_lock_coverage", + "severity": "warning", + "message": "Missing environment locks: dependencyLock.", + "remediation": "Attach container image digests and dependency lockfiles used by the restore drill.", + "evidence": { + "present": [ + "containerDigest" + ], + "missing": [ + "dependencyLock" + ] + } + } + ], + "remediationQueue": [ + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "restore_drill_recent_passed", + "severity": "critical", + "action": "run_restore_rehearsal", + "remediation": "Run a restore rehearsal from the immutable snapshot and attach the passed drill report." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "ref_coverage", + "severity": "critical", + "action": "rebuild_snapshot_refs", + "remediation": "Rebuild the snapshot with all protected branch refs required for release reconstruction." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "tag_coverage", + "severity": "warning", + "action": "attach_release_tag", + "remediation": "Attach the release tag to the immutable snapshot before DOI or export publication." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "component_manifest_integrity", + "severity": "critical", + "action": "reconcile_component_manifest", + "remediation": "Regenerate the manifest from restored files and reconcile missing or mismatched component hashes." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "artifact_mirror_ready", + "severity": "critical", + "action": "repair_artifact_mirror", + "remediation": "Mirror required LFS or restricted artifacts and verify mirror digests before export." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "environment_lock_coverage", + "severity": "warning", + "action": "attach_environment_locks", + "remediation": "Attach container image digests and dependency lockfiles used by the restore drill." + } + ], + "restorePacket": { + "policyVersion": "repository-restore-rehearsal-v1", + "candidateTag": "preprint-v0.9", + "restoredSnapshot": "snap-2026-05-08", + "decision": "hold_restore_rehearsal" + } + }, + { + "repositoryId": "protein-model-sandbox", + "title": "Protein Model Sandbox", + "status": "hold_restore_rehearsal", + "snapshotId": "snap-2026-04-14", + "releaseCandidate": { + "snapshotId": "snap-2026-04-14", + "tag": "v0.4.0", + "requiredRefs": [ + "refs/heads/release/v0.4" + ] + }, + "summary": { + "checks": 11, + "passedChecks": 7, + "criticalFindings": 2, + "warningFindings": 2 + }, + "checks": [ + { + "code": "candidate_snapshot_declared", + "severity": "critical", + "pass": true, + "message": "Release candidate declares the snapshot that should be restored before export.", + "evidence": {} + }, + { + "code": "snapshot_found", + "severity": "critical", + "pass": true, + "message": "Declared snapshot exists in the repository restore catalog.", + "evidence": {} + }, + { + "code": "snapshot_recency", + "severity": "warning", + "pass": false, + "message": "Snapshot age is 38 days.", + "evidence": { + "snapshotAgeDays": 38, + "maxSnapshotAgeDays": 30 + } + }, + { + "code": "restore_drill_recent_passed", + "severity": "critical", + "pass": false, + "message": "Latest restore drill status is passed and age is 35 days.", + "evidence": { + "drillId": "drill-protein-20260417", + "status": "passed", + "drillAgeDays": 35 + } + }, + { + "code": "ref_coverage", + "severity": "critical", + "pass": true, + "message": "Snapshot covers every required protected branch ref.", + "evidence": { + "requiredRefs": [ + "refs/heads/main", + "refs/heads/release/v0.4" + ], + "missingRefs": [] + } + }, + { + "code": "tag_coverage", + "severity": "warning", + "pass": true, + "message": "Release tag v0.4.0 is present in the snapshot.", + "evidence": { + "tag": "v0.4.0" + } + }, + { + "code": "component_manifest_integrity", + "severity": "critical", + "pass": true, + "message": "Required repository components match the snapshot manifest.", + "evidence": { + "covered": [ + "manuscript", + "data", + "code", + "metadata" + ], + "failures": [] + } + }, + { + "code": "artifact_mirror_ready", + "severity": "critical", + "pass": true, + "message": "Large and restricted artifacts have matching restore mirrors.", + "evidence": { + "mirrored": [ + { + "path": "data/features.arrow", + "component": "data" + } + ], + "failures": [] + } + }, + { + "code": "environment_lock_coverage", + "severity": "warning", + "pass": true, + "message": "Environment locks are present for restore replay.", + "evidence": { + "present": [ + "containerDigest", + "dependencyLock" + ], + "missing": [] + } + }, + { + "code": "notebook_replay_recent", + "severity": "warning", + "pass": false, + "message": "Notebook replay evidence reviewed for 1 notebook artifacts.", + "evidence": { + "total": 1, + "failed": [], + "stale": [ + { + "path": "notebooks/evaluation.ipynb", + "ageDays": 36 + } + ], + "missing": [] + } + }, + { + "code": "export_manifest_linked", + "severity": "critical", + "pass": false, + "message": "Export manifest is missing for the release snapshot.", + "evidence": { + "snapshotId": "snap-2026-04-14", + "exportSnapshotId": null, + "doi": null + } + } + ], + "findings": [ + { + "code": "snapshot_recency", + "severity": "warning", + "message": "Snapshot age is 38 days.", + "remediation": "Cut a fresh snapshot or document why an older snapshot is still the release source.", + "evidence": { + "snapshotAgeDays": 38, + "maxSnapshotAgeDays": 30 + } + }, + { + "code": "restore_drill_recent_passed", + "severity": "critical", + "message": "Latest restore drill status is passed and age is 35 days.", + "remediation": "Run a restore rehearsal from the immutable snapshot and attach the passed drill report.", + "evidence": { + "drillId": "drill-protein-20260417", + "status": "passed", + "drillAgeDays": 35 + } + }, + { + "code": "notebook_replay_recent", + "severity": "warning", + "message": "Notebook replay evidence reviewed for 1 notebook artifacts.", + "remediation": "Replay notebooks from the restored environment and attach fresh pass evidence.", + "evidence": { + "total": 1, + "failed": [], + "stale": [ + { + "path": "notebooks/evaluation.ipynb", + "ageDays": 36 + } + ], + "missing": [] + } + }, + { + "code": "export_manifest_linked", + "severity": "critical", + "message": "Export manifest is missing for the release snapshot.", + "remediation": "Generate an export manifest that names the immutable snapshot and all restored components.", + "evidence": { + "snapshotId": "snap-2026-04-14", + "exportSnapshotId": null, + "doi": null + } + } + ], + "remediationQueue": [ + { + "repositoryId": "protein-model-sandbox", + "snapshotId": "snap-2026-04-14", + "code": "snapshot_recency", + "severity": "warning", + "action": "refresh_release_snapshot", + "remediation": "Cut a fresh snapshot or document why an older snapshot is still the release source." + }, + { + "repositoryId": "protein-model-sandbox", + "snapshotId": "snap-2026-04-14", + "code": "restore_drill_recent_passed", + "severity": "critical", + "action": "run_restore_rehearsal", + "remediation": "Run a restore rehearsal from the immutable snapshot and attach the passed drill report." + }, + { + "repositoryId": "protein-model-sandbox", + "snapshotId": "snap-2026-04-14", + "code": "notebook_replay_recent", + "severity": "warning", + "action": "rerun_notebook_replay", + "remediation": "Replay notebooks from the restored environment and attach fresh pass evidence." + }, + { + "repositoryId": "protein-model-sandbox", + "snapshotId": "snap-2026-04-14", + "code": "export_manifest_linked", + "severity": "critical", + "action": "generate_export_manifest", + "remediation": "Generate an export manifest that names the immutable snapshot and all restored components." + } + ], + "restorePacket": { + "policyVersion": "repository-restore-rehearsal-v1", + "candidateTag": "v0.4.0", + "restoredSnapshot": "snap-2026-04-14", + "decision": "hold_restore_rehearsal" + } + } + ], + "remediationQueue": [ + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "restore_drill_recent_passed", + "severity": "critical", + "action": "run_restore_rehearsal", + "remediation": "Run a restore rehearsal from the immutable snapshot and attach the passed drill report." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "ref_coverage", + "severity": "critical", + "action": "rebuild_snapshot_refs", + "remediation": "Rebuild the snapshot with all protected branch refs required for release reconstruction." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "tag_coverage", + "severity": "warning", + "action": "attach_release_tag", + "remediation": "Attach the release tag to the immutable snapshot before DOI or export publication." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "component_manifest_integrity", + "severity": "critical", + "action": "reconcile_component_manifest", + "remediation": "Regenerate the manifest from restored files and reconcile missing or mismatched component hashes." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "artifact_mirror_ready", + "severity": "critical", + "action": "repair_artifact_mirror", + "remediation": "Mirror required LFS or restricted artifacts and verify mirror digests before export." + }, + { + "repositoryId": "climate-table-fork", + "snapshotId": "snap-2026-05-08", + "code": "environment_lock_coverage", + "severity": "warning", + "action": "attach_environment_locks", + "remediation": "Attach container image digests and dependency lockfiles used by the restore drill." + }, + { + "repositoryId": "protein-model-sandbox", + "snapshotId": "snap-2026-04-14", + "code": "snapshot_recency", + "severity": "warning", + "action": "refresh_release_snapshot", + "remediation": "Cut a fresh snapshot or document why an older snapshot is still the release source." + }, + { + "repositoryId": "protein-model-sandbox", + "snapshotId": "snap-2026-04-14", + "code": "restore_drill_recent_passed", + "severity": "critical", + "action": "run_restore_rehearsal", + "remediation": "Run a restore rehearsal from the immutable snapshot and attach the passed drill report." + }, + { + "repositoryId": "protein-model-sandbox", + "snapshotId": "snap-2026-04-14", + "code": "notebook_replay_recent", + "severity": "warning", + "action": "rerun_notebook_replay", + "remediation": "Replay notebooks from the restored environment and attach fresh pass evidence." + }, + { + "repositoryId": "protein-model-sandbox", + "snapshotId": "snap-2026-04-14", + "code": "export_manifest_linked", + "severity": "critical", + "action": "generate_export_manifest", + "remediation": "Generate an export manifest that names the immutable snapshot and all restored components." + } + ], + "releaseQueue": [ + { + "repositoryId": "neuro-reproducibility-atlas", + "snapshotId": "snap-2026-05-21", + "tag": "v1.2.0", + "action": "allow_release_export" + } + ], + "audit": { + "algorithm": "sha256", + "digest": "33359f292086b7e89d2ea1bf3c76937a1557755ddf9627660e44c40cb7f1d319", + "policyVersion": "repository-restore-rehearsal-v1" + } +} diff --git a/repository-restore-rehearsal-guard/reports/restore-rehearsal-report.md b/repository-restore-rehearsal-guard/reports/restore-rehearsal-report.md new file mode 100644 index 00000000..d04eb385 --- /dev/null +++ b/repository-restore-rehearsal-guard/reports/restore-rehearsal-report.md @@ -0,0 +1,43 @@ +# Repository Restore Rehearsal Report + +Repositories reviewed: 3 +Restore-ready repositories: 1 +Held repositories: 2 +Critical findings: 6 +Warning findings: 4 +Audit digest: `33359f292086b7e89d2ea1bf3c76937a1557755ddf9627660e44c40cb7f1d319` + +## Repository Decisions + +### Neuro Reproducibility Atlas +- Status: restore_ready +- Snapshot: snap-2026-05-21 +- Candidate tag: v1.2.0 +- Checks passed: 11/11 +- Finding codes: none + +### Climate Table Fork +- Status: hold_restore_rehearsal +- Snapshot: snap-2026-05-08 +- Candidate tag: preprint-v0.9 +- Checks passed: 5/11 +- Finding codes: restore_drill_recent_passed, ref_coverage, tag_coverage, component_manifest_integrity, artifact_mirror_ready, environment_lock_coverage + +### Protein Model Sandbox +- Status: hold_restore_rehearsal +- Snapshot: snap-2026-04-14 +- Candidate tag: v0.4.0 +- Checks passed: 7/11 +- Finding codes: snapshot_recency, restore_drill_recent_passed, notebook_replay_recent, export_manifest_linked + +## Remediation Queue +- climate-table-fork/snap-2026-05-08: run_restore_rehearsal (critical) +- climate-table-fork/snap-2026-05-08: rebuild_snapshot_refs (critical) +- climate-table-fork/snap-2026-05-08: attach_release_tag (warning) +- climate-table-fork/snap-2026-05-08: reconcile_component_manifest (critical) +- climate-table-fork/snap-2026-05-08: repair_artifact_mirror (critical) +- climate-table-fork/snap-2026-05-08: attach_environment_locks (warning) +- protein-model-sandbox/snap-2026-04-14: refresh_release_snapshot (warning) +- protein-model-sandbox/snap-2026-04-14: run_restore_rehearsal (critical) +- protein-model-sandbox/snap-2026-04-14: rerun_notebook_replay (warning) +- protein-model-sandbox/snap-2026-04-14: generate_export_manifest (critical) diff --git a/repository-restore-rehearsal-guard/reports/summary.svg b/repository-restore-rehearsal-guard/reports/summary.svg new file mode 100644 index 00000000..a229779a --- /dev/null +++ b/repository-restore-rehearsal-guard/reports/summary.svg @@ -0,0 +1,16 @@ + + + Repository Restore Rehearsal Guard + Release snapshots are checked before DOI/export publication + + 1 + restore ready + + 4 + warnings + + 6 + critical + Controls: restore drill, refs, tags, checksums, LFS mirrors, environment locks, notebook replay, export manifest. + Digest 33359f292086b7e89d2ea1bf3c76... + diff --git a/repository-restore-rehearsal-guard/requirements-map.md b/repository-restore-rehearsal-guard/requirements-map.md new file mode 100644 index 00000000..1e943c24 --- /dev/null +++ b/repository-restore-rehearsal-guard/requirements-map.md @@ -0,0 +1,15 @@ +# Requirements Map + +| Issue #10 requirement | Implementation coverage | +| --- | --- | +| File and metadata versioning | Checks immutable snapshot ids, semantic tags, refs, component hashes, and export manifest linkage. | +| Git-compatible repository lifecycle | Validates protected branch refs and release tags before export. | +| Hash-based integrity | Compares expected component hashes with restored snapshot artifact hashes. | +| Git LFS / large files | Requires large or restricted artifacts to have ready mirrors with matching digests. | +| Computation-aware reproducibility | Requires recent passed restore drills, environment locks, and notebook replay evidence. | +| Repository identifiers and citation | Blocks export when the DOI/export manifest is missing or points at the wrong snapshot. | +| Programmatic export | Emits a deterministic release queue, remediation queue, and audit packet suitable for API or reviewer dashboard integration. | + +## Non-Overlap Notes + +This PR is scoped to restore rehearsal readiness. It does not implement generic repository ledgers, rollback UI, legal-hold workflows, release embargo, access review, dependency licensing, component-owner approval quorum, or sensitive-artifact scanning. diff --git a/repository-restore-rehearsal-guard/sample-data.js b/repository-restore-rehearsal-guard/sample-data.js new file mode 100644 index 00000000..7dbcc4ac --- /dev/null +++ b/repository-restore-rehearsal-guard/sample-data.js @@ -0,0 +1,177 @@ +const policy = { + maxSnapshotAgeDays: 30, + maxRestoreDrillAgeDays: 21, + maxNotebookReplayAgeDays: 14, + requiredRefs: ["refs/heads/main"], + requiredEnvironmentLocks: ["containerDigest", "dependencyLock"], +} + +const repositories = [ + { + repositoryId: "neuro-reproducibility-atlas", + title: "Neuro Reproducibility Atlas", + releaseCandidate: { + snapshotId: "snap-2026-05-21", + tag: "v1.2.0", + requiredRefs: ["refs/heads/release/v1.2"], + }, + expectedComponents: [ + { component: "manuscript", hash: "sha256:manuscript-9f4a" }, + { component: "data", hash: "sha256:data-18aa" }, + { component: "code", hash: "sha256:code-73be" }, + { component: "metadata", hash: "sha256:metadata-4420" }, + ], + snapshots: [ + { + id: "snap-2026-05-21", + createdAt: "2026-05-21T10:00:00.000Z", + refs: ["refs/heads/main", "refs/heads/release/v1.2"], + tags: ["v1.2.0"], + environmentLocks: { + containerDigest: "sha256:container-20260521", + dependencyLock: "renv.lock@sha256:836d", + }, + exportManifest: { + snapshotId: "snap-2026-05-21", + doi: "10.5555/scibase.neuro-atlas.v1.2.0", + }, + artifacts: [ + { component: "manuscript", path: "manuscript/main.md", hash: "sha256:manuscript-9f4a" }, + { + component: "data", + path: "data/participants.parquet", + hash: "sha256:data-18aa", + large: true, + storage: "lfs", + mirror: { status: "ready", hash: "sha256:data-18aa" }, + }, + { component: "code", path: "code/pipeline.py", hash: "sha256:code-73be" }, + { + component: "notebooks", + path: "notebooks/reproduce.ipynb", + hash: "sha256:notebook-530a", + replay: { status: "passed", completedAt: "2026-05-22T08:00:00.000Z" }, + }, + { component: "metadata", path: "metadata.json", hash: "sha256:metadata-4420" }, + ], + }, + ], + restoreDrills: [ + { + id: "drill-neuro-20260522", + snapshotId: "snap-2026-05-21", + status: "passed", + completedAt: "2026-05-22T09:00:00.000Z", + }, + ], + }, + { + repositoryId: "climate-table-fork", + title: "Climate Table Fork", + releaseCandidate: { + snapshotId: "snap-2026-05-08", + tag: "preprint-v0.9", + requiredRefs: ["refs/heads/release/preprint"], + }, + expectedComponents: [ + { component: "manuscript", hash: "sha256:climate-manuscript" }, + { component: "data", hash: "sha256:expected-climate-data" }, + { component: "code", hash: "sha256:climate-code" }, + { component: "metadata", hash: "sha256:climate-metadata" }, + ], + snapshots: [ + { + id: "snap-2026-05-08", + createdAt: "2026-05-08T11:00:00.000Z", + refs: ["refs/heads/main"], + tags: [], + environmentLocks: { + containerDigest: "sha256:container-climate", + }, + exportManifest: { + snapshotId: "snap-2026-05-08", + doi: "10.5555/scibase.climate.preprint", + }, + artifacts: [ + { component: "manuscript", path: "manuscript/paper.md", hash: "sha256:climate-manuscript" }, + { + component: "data", + path: "data/raw-grid.parquet", + hash: "sha256:actual-climate-data", + large: true, + storage: "lfs", + mirror: { status: "missing" }, + }, + { component: "code", path: "code/model.py", hash: "sha256:climate-code" }, + { component: "metadata", path: "metadata.json", hash: "sha256:climate-metadata" }, + ], + }, + ], + restoreDrills: [ + { + id: "drill-climate-20260509", + snapshotId: "snap-2026-05-08", + status: "failed", + completedAt: "2026-05-09T09:00:00.000Z", + }, + ], + }, + { + repositoryId: "protein-model-sandbox", + title: "Protein Model Sandbox", + releaseCandidate: { + snapshotId: "snap-2026-04-14", + tag: "v0.4.0", + requiredRefs: ["refs/heads/release/v0.4"], + }, + expectedComponents: [ + { component: "manuscript", hash: "sha256:protein-manuscript" }, + { component: "data", hash: "sha256:protein-data" }, + { component: "code", hash: "sha256:protein-code" }, + { component: "metadata", hash: "sha256:protein-metadata" }, + ], + snapshots: [ + { + id: "snap-2026-04-14", + createdAt: "2026-04-14T09:00:00.000Z", + refs: ["refs/heads/main", "refs/heads/release/v0.4"], + tags: ["v0.4.0"], + environmentLocks: { + containerDigest: "sha256:container-protein", + dependencyLock: "requirements.txt@sha256:7e8a", + }, + artifacts: [ + { component: "manuscript", path: "manuscript/model.md", hash: "sha256:protein-manuscript" }, + { + component: "data", + path: "data/features.arrow", + hash: "sha256:protein-data", + large: true, + mirror: { status: "ready", hash: "sha256:protein-data" }, + }, + { component: "code", path: "code/train.py", hash: "sha256:protein-code" }, + { + component: "notebooks", + path: "notebooks/evaluation.ipynb", + hash: "sha256:protein-notebook", + replay: { status: "passed", completedAt: "2026-04-16T10:00:00.000Z" }, + }, + { component: "metadata", path: "metadata.json", hash: "sha256:protein-metadata" }, + ], + }, + ], + restoreDrills: [ + { + id: "drill-protein-20260417", + snapshotId: "snap-2026-04-14", + status: "passed", + completedAt: "2026-04-17T10:00:00.000Z", + }, + ], + }, +] + +module.exports = { + policy, + repositories, +} diff --git a/repository-restore-rehearsal-guard/test.js b/repository-restore-rehearsal-guard/test.js new file mode 100644 index 00000000..fba19bb2 --- /dev/null +++ b/repository-restore-rehearsal-guard/test.js @@ -0,0 +1,96 @@ +const assert = require("node:assert/strict") +const { evaluateRestoreRehearsal } = require("./index") +const { repositories, policy } = require("./sample-data") + +const packet = evaluateRestoreRehearsal({ + asOf: "2026-05-23T02:00:00.000Z", + repositories, + policy, +}) + +assert.equal(packet.summary.totalRepositories, 3) +assert.equal(packet.summary.readyRepositories, 1) +assert.equal(packet.summary.stewardReviewRepositories, 0) +assert.equal(packet.summary.heldRepositories, 2) +assert.equal(packet.summary.criticalFindings, 6) +assert.equal(packet.summary.warningFindings, 4) +assert.equal(packet.releaseQueue.length, 1) +assert.equal(packet.releaseQueue[0].repositoryId, "neuro-reproducibility-atlas") + +const ready = packet.repositories.find((repository) => repository.repositoryId === "neuro-reproducibility-atlas") +assert.equal(ready.status, "restore_ready") +assert(ready.checks.every((check) => check.pass)) +assert.equal(ready.restorePacket.candidateTag, "v1.2.0") + +const climate = packet.repositories.find((repository) => repository.repositoryId === "climate-table-fork") +assert.equal(climate.status, "hold_restore_rehearsal") +assert(climate.findings.some((finding) => finding.code === "restore_drill_recent_passed")) +assert(climate.findings.some((finding) => finding.code === "ref_coverage")) +assert(climate.findings.some((finding) => finding.code === "component_manifest_integrity")) +assert(climate.findings.some((finding) => finding.code === "artifact_mirror_ready")) +assert(climate.findings.some((finding) => finding.code === "environment_lock_coverage")) + +const climateManifest = climate.findings.find((finding) => finding.code === "component_manifest_integrity") +assert(climateManifest.evidence.failures.some((failure) => failure.reason === "hash_mismatch")) + +const protein = packet.repositories.find((repository) => repository.repositoryId === "protein-model-sandbox") +assert.equal(protein.status, "hold_restore_rehearsal") +assert(protein.findings.some((finding) => finding.code === "snapshot_recency")) +assert(protein.findings.some((finding) => finding.code === "restore_drill_recent_passed")) +assert(protein.findings.some((finding) => finding.code === "notebook_replay_recent")) +assert(protein.findings.some((finding) => finding.code === "export_manifest_linked")) + +const minimalReady = evaluateRestoreRehearsal({ + asOf: "2026-05-23T02:00:00.000Z", + repositories: [{ + repositoryId: "minimal-ready", + title: "Minimal Ready", + releaseCandidate: { + snapshotId: "snap-minimal", + tag: "v1.0.0", + }, + expectedComponents: [ + { component: "manuscript", hash: "sha256:a" }, + { component: "data", hash: "sha256:b" }, + { component: "code", hash: "sha256:c" }, + { component: "metadata", hash: "sha256:d" }, + ], + snapshots: [{ + id: "snap-minimal", + createdAt: "2026-05-22T02:00:00.000Z", + refs: ["refs/heads/main"], + tags: ["v1.0.0"], + environmentLocks: { + containerDigest: "sha256:container", + dependencyLock: "package-lock.json@sha256:1", + }, + exportManifest: { + snapshotId: "snap-minimal", + }, + artifacts: [ + { component: "manuscript", path: "manuscript/main.md", hash: "sha256:a" }, + { component: "data", path: "data/table.csv", hash: "sha256:b" }, + { component: "code", path: "code/analysis.py", hash: "sha256:c" }, + { component: "metadata", path: "metadata.json", hash: "sha256:d" }, + ], + }], + restoreDrills: [{ + id: "drill-minimal", + snapshotId: "snap-minimal", + status: "passed", + completedAt: "2026-05-22T03:00:00.000Z", + }], + }], + policy, +}) +assert.equal(minimalReady.summary.readyRepositories, 1) +assert.equal(minimalReady.summary.totalFindings, 0) + +const repeated = evaluateRestoreRehearsal({ + asOf: "2026-05-23T02:00:00.000Z", + repositories, + policy, +}) +assert.equal(repeated.audit.digest, packet.audit.digest) + +console.log("repository restore rehearsal guard tests passed")