diff --git a/repository-artifact-prune-guard/README.md b/repository-artifact-prune-guard/README.md
new file mode 100644
index 00000000..8438beb4
--- /dev/null
+++ b/repository-artifact-prune-guard/README.md
@@ -0,0 +1,27 @@
+# Repository Artifact Prune Guard
+
+This is a self-contained Project Repository & Version Control slice for issue #10.
+
+The guard checks whether repository artifacts can be pruned before a tagged scientific repository release or export bundle is published. It protects citation evidence, DOI version manifests, export bundle manifests, and reproducibility run packets from accidental deletion.
+
+## Scope
+
+- Synthetic data only.
+- No network calls, credentials, DOI provider, storage provider, Git provider, or SCIBASE production service integration.
+- Focused on artifact garbage-collection readiness, not broad repository ledgers, external reference pinning, retention legal holds, embargo release checks, component-owner approvals, branch protection, or release signatures.
+
+## Validation
+
+```sh
+node repository-artifact-prune-guard/test.js
+node repository-artifact-prune-guard/demo.js
+```
+
+The demo writes deterministic reviewer artifacts under `repository-artifact-prune-guard/reports/`.
+
+Reviewer artifacts:
+
+- `reports/artifact-prune-packet.json`
+- `reports/artifact-prune-report.md`
+- `reports/summary.svg`
+- `reports/demo.mp4`
diff --git a/repository-artifact-prune-guard/artifactPruneGuard.js b/repository-artifact-prune-guard/artifactPruneGuard.js
new file mode 100644
index 00000000..10328b1f
--- /dev/null
+++ b/repository-artifact-prune-guard/artifactPruneGuard.js
@@ -0,0 +1,147 @@
+"use strict";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function daysBetween(olderIso, newerIso) {
+ return Math.floor((Date.parse(newerIso) - Date.parse(olderIso)) / DAY_MS);
+}
+
+function indexReferences(references) {
+ const byArtifact = new Map();
+
+ for (const group of references) {
+ for (const item of group.artifacts) {
+ const entries = byArtifact.get(item.id) || [];
+ entries.push({
+ source: group.source,
+ sourceType: group.sourceType,
+ requiredHash: item.hash || null,
+ severity: group.severity || "blocker",
+ });
+ byArtifact.set(item.id, entries);
+ }
+ }
+
+ return byArtifact;
+}
+
+function artifactLabel(artifact) {
+ return `${artifact.id} (${artifact.path})`;
+}
+
+function analyzeArtifactPrunePlan(input) {
+ const now = input.now || new Date().toISOString();
+ const retentionDays = input.policy?.minimumUnreferencedAgeDays ?? 30;
+ const candidateIds = new Set(input.pruneCandidates || []);
+ const referencesByArtifact = indexReferences(input.references || []);
+
+ const artifactsById = new Map(input.artifacts.map((artifact) => [artifact.id, artifact]));
+ const missingCandidates = [...candidateIds].filter((id) => !artifactsById.has(id));
+
+ const approvedPrunes = [];
+ const blockedPrunes = [];
+ const warnings = [];
+
+ for (const artifact of input.artifacts) {
+ if (!candidateIds.has(artifact.id)) {
+ continue;
+ }
+
+ const ageDays = daysBetween(artifact.lastReferencedAt || artifact.createdAt, now);
+ const references = referencesByArtifact.get(artifact.id) || [];
+ const blockers = [];
+
+ for (const reference of references) {
+ if (reference.requiredHash && reference.requiredHash !== artifact.hash) {
+ blockers.push({
+ code: "REFERENCE_HASH_DRIFT",
+ message: `${reference.source} expects ${reference.requiredHash} but ${artifact.id} is ${artifact.hash}`,
+ source: reference.source,
+ });
+ continue;
+ }
+
+ blockers.push({
+ code: referenceCode(reference.sourceType),
+ message: `${artifactLabel(artifact)} is still required by ${reference.source}`,
+ source: reference.source,
+ });
+ }
+
+ if (artifact.retentionHold) {
+ blockers.push({
+ code: "RETENTION_HOLD",
+ message: `${artifactLabel(artifact)} has an active retention hold: ${artifact.retentionHold}`,
+ source: "repository policy",
+ });
+ }
+
+ if (ageDays < retentionDays) {
+ blockers.push({
+ code: "MINIMUM_AGE_NOT_MET",
+ message: `${artifactLabel(artifact)} is ${ageDays} days old; policy requires ${retentionDays}`,
+ source: "repository policy",
+ });
+ }
+
+ if (blockers.length > 0) {
+ blockedPrunes.push({
+ artifactId: artifact.id,
+ path: artifact.path,
+ blockers,
+ });
+ } else {
+ approvedPrunes.push({
+ artifactId: artifact.id,
+ path: artifact.path,
+ reason: `unreferenced for ${ageDays} days and no release evidence depends on it`,
+ });
+ }
+ }
+
+ for (const id of missingCandidates) {
+ warnings.push({
+ code: "UNKNOWN_CANDIDATE",
+ message: `Prune candidate ${id} is not present in the repository artifact inventory`,
+ });
+ }
+
+ const status = blockedPrunes.length === 0 && missingCandidates.length === 0 ? "pass" : "blocked";
+
+ return {
+ status,
+ checkedAt: now,
+ policy: {
+ minimumUnreferencedAgeDays: retentionDays,
+ },
+ summary: {
+ candidates: candidateIds.size,
+ approved: approvedPrunes.length,
+ blocked: blockedPrunes.length,
+ warnings: warnings.length,
+ },
+ approvedPrunes,
+ blockedPrunes,
+ warnings,
+ };
+}
+
+function referenceCode(sourceType) {
+ switch (sourceType) {
+ case "citation":
+ return "CITATION_EVIDENCE_BOUND";
+ case "doi":
+ return "DOI_VERSION_BOUND";
+ case "export":
+ return "EXPORT_MANIFEST_BOUND";
+ case "reproducibility":
+ return "REPRODUCIBILITY_PACKET_BOUND";
+ default:
+ return "REPOSITORY_REFERENCE_BOUND";
+ }
+}
+
+module.exports = {
+ analyzeArtifactPrunePlan,
+ daysBetween,
+};
diff --git a/repository-artifact-prune-guard/demo.js b/repository-artifact-prune-guard/demo.js
new file mode 100644
index 00000000..d678f6cd
--- /dev/null
+++ b/repository-artifact-prune-guard/demo.js
@@ -0,0 +1,60 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { analyzeArtifactPrunePlan } = require("./artifactPruneGuard");
+const { sampleRepository } = require("./sampleRepository");
+
+const report = analyzeArtifactPrunePlan(sampleRepository);
+const reportsDir = path.join(__dirname, "reports");
+
+fs.mkdirSync(reportsDir, { recursive: true });
+fs.writeFileSync(
+ path.join(reportsDir, "artifact-prune-packet.json"),
+ `${JSON.stringify(report, null, 2)}\n`,
+);
+fs.writeFileSync(path.join(reportsDir, "artifact-prune-report.md"), renderMarkdown(report));
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvg(report));
+
+console.log(`Artifact prune guard status: ${report.status}`);
+console.log(`Approved: ${report.summary.approved}; blocked: ${report.summary.blocked}`);
+
+function renderMarkdown(packet) {
+ const lines = [
+ "# Artifact Prune Guard Report",
+ "",
+ `Status: ${packet.status}`,
+ `Checked at: ${packet.checkedAt}`,
+ "",
+ "## Approved Prunes",
+ "",
+ ...packet.approvedPrunes.map((item) => `- ${item.artifactId}: ${item.reason}`),
+ "",
+ "## Blocked Prunes",
+ "",
+ ];
+
+ for (const item of packet.blockedPrunes) {
+ lines.push(`- ${item.artifactId} (${item.path})`);
+ for (const blocker of item.blockers) {
+ lines.push(` - ${blocker.code}: ${blocker.message}`);
+ }
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvg(packet) {
+ const approvedWidth = 70 + packet.summary.approved * 70;
+ const blockedWidth = 70 + packet.summary.blocked * 70;
+ return `
+`;
+}
diff --git a/repository-artifact-prune-guard/reports/artifact-prune-packet.json b/repository-artifact-prune-guard/reports/artifact-prune-packet.json
new file mode 100644
index 00000000..5454cef0
--- /dev/null
+++ b/repository-artifact-prune-guard/reports/artifact-prune-packet.json
@@ -0,0 +1,77 @@
+{
+ "status": "blocked",
+ "checkedAt": "2026-05-31T04:45:00.000Z",
+ "policy": {
+ "minimumUnreferencedAgeDays": 30
+ },
+ "summary": {
+ "candidates": 5,
+ "approved": 1,
+ "blocked": 4,
+ "warnings": 0
+ },
+ "approvedPrunes": [
+ {
+ "artifactId": "orphan-scratch-plot",
+ "path": "results/tmp/orphan-scratch-plot.png",
+ "reason": "unreferenced for 118 days and no release evidence depends on it"
+ }
+ ],
+ "blockedPrunes": [
+ {
+ "artifactId": "fig-survival-curve",
+ "path": "results/figures/survival-curve.svg",
+ "blockers": [
+ {
+ "code": "CITATION_EVIDENCE_BOUND",
+ "message": "fig-survival-curve (results/figures/survival-curve.svg) is still required by manuscript/citations.json#figure-2",
+ "source": "manuscript/citations.json#figure-2"
+ },
+ {
+ "code": "DOI_VERSION_BOUND",
+ "message": "fig-survival-curve (results/figures/survival-curve.svg) is still required by metadata.json#doi:10.5555/scibase.demo.v1",
+ "source": "metadata.json#doi:10.5555/scibase.demo.v1"
+ }
+ ]
+ },
+ {
+ "artifactId": "raw-screening-table",
+ "path": "data/raw/screening-table.csv",
+ "blockers": [
+ {
+ "code": "EXPORT_MANIFEST_BOUND",
+ "message": "raw-screening-table (data/raw/screening-table.csv) is still required by exports/release-v1/manifest.json",
+ "source": "exports/release-v1/manifest.json"
+ }
+ ]
+ },
+ {
+ "artifactId": "notebook-run-packet",
+ "path": "notebooks/run-2026-05-01/replay-packet.json",
+ "blockers": [
+ {
+ "code": "REPRODUCIBILITY_PACKET_BOUND",
+ "message": "notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json) is still required by results/reproducibility/run-2026-05-01.json",
+ "source": "results/reproducibility/run-2026-05-01.json"
+ },
+ {
+ "code": "MINIMUM_AGE_NOT_MET",
+ "message": "notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json) is 29 days old; policy requires 30",
+ "source": "repository policy"
+ }
+ ]
+ },
+ {
+ "artifactId": "protocol-appendix",
+ "path": "protocols/appendix-a.md",
+ "blockers": [
+ {
+ "code": "RETENTION_HOLD",
+ "message": "protocol-appendix (protocols/appendix-a.md) has an active retention hold: IRB audit window closes 2026-12-31",
+ "source": "repository policy"
+ }
+ ]
+ }
+ ],
+ "warnings": []
+}
diff --git a/repository-artifact-prune-guard/reports/artifact-prune-report.md b/repository-artifact-prune-guard/reports/artifact-prune-report.md
new file mode 100644
index 00000000..de994345
--- /dev/null
+++ b/repository-artifact-prune-guard/reports/artifact-prune-report.md
@@ -0,0 +1,21 @@
+# Artifact Prune Guard Report
+
+Status: blocked
+Checked at: 2026-05-31T04:45:00.000Z
+
+## Approved Prunes
+
+- orphan-scratch-plot: unreferenced for 118 days and no release evidence depends on it
+
+## Blocked Prunes
+
+- fig-survival-curve (results/figures/survival-curve.svg)
+ - CITATION_EVIDENCE_BOUND: fig-survival-curve (results/figures/survival-curve.svg) is still required by manuscript/citations.json#figure-2
+ - DOI_VERSION_BOUND: fig-survival-curve (results/figures/survival-curve.svg) is still required by metadata.json#doi:10.5555/scibase.demo.v1
+- raw-screening-table (data/raw/screening-table.csv)
+ - EXPORT_MANIFEST_BOUND: raw-screening-table (data/raw/screening-table.csv) is still required by exports/release-v1/manifest.json
+- notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json)
+ - REPRODUCIBILITY_PACKET_BOUND: notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json) is still required by results/reproducibility/run-2026-05-01.json
+ - MINIMUM_AGE_NOT_MET: notebook-run-packet (notebooks/run-2026-05-01/replay-packet.json) is 29 days old; policy requires 30
+- protocol-appendix (protocols/appendix-a.md)
+ - RETENTION_HOLD: protocol-appendix (protocols/appendix-a.md) has an active retention hold: IRB audit window closes 2026-12-31
diff --git a/repository-artifact-prune-guard/reports/demo.ass b/repository-artifact-prune-guard/reports/demo.ass
new file mode 100644
index 00000000..b1a23efe
--- /dev/null
+++ b/repository-artifact-prune-guard/reports/demo.ass
@@ -0,0 +1,17 @@
+[Script Info]
+Title: Artifact Prune Guard Demo
+ScriptType: v4.00+
+PlayResX: 1280
+PlayResY: 720
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Title,Arial,54,&H00FFFFFF,&H000000FF,&H00202830,&H99000000,1,0,0,0,100,100,0,0,1,3,1,8,60,60,60,1
+Style: Body,Arial,36,&H00F8FAFC,&H000000FF,&H00202830,&H99000000,0,0,0,0,100,100,0,0,1,2,1,5,90,90,80,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+Dialogue: 0,0:00:00.00,0:00:02.20,Title,,0,0,0,,Repository Artifact Prune Guard
+Dialogue: 0,0:00:02.20,0:00:04.80,Body,,0,0,0,,Checks prune candidates before scientific release or export.
+Dialogue: 0,0:00:04.80,0:00:07.60,Body,,0,0,0,,Blocks artifacts still bound to citations, DOI manifests, exports, or reproducibility packets.
+Dialogue: 0,0:00:07.60,0:00:10.00,Body,,0,0,0,,Demo result: 1 approved prune, 4 blocked prunes, deterministic reviewer packet generated.
diff --git a/repository-artifact-prune-guard/reports/demo.mp4 b/repository-artifact-prune-guard/reports/demo.mp4
new file mode 100644
index 00000000..863a87ab
Binary files /dev/null and b/repository-artifact-prune-guard/reports/demo.mp4 differ
diff --git a/repository-artifact-prune-guard/reports/summary.svg b/repository-artifact-prune-guard/reports/summary.svg
new file mode 100644
index 00000000..9b001964
--- /dev/null
+++ b/repository-artifact-prune-guard/reports/summary.svg
@@ -0,0 +1,9 @@
+
diff --git a/repository-artifact-prune-guard/sampleRepository.js b/repository-artifact-prune-guard/sampleRepository.js
new file mode 100644
index 00000000..26f427e1
--- /dev/null
+++ b/repository-artifact-prune-guard/sampleRepository.js
@@ -0,0 +1,88 @@
+"use strict";
+
+const sampleRepository = {
+ now: "2026-05-31T04:45:00.000Z",
+ policy: {
+ minimumUnreferencedAgeDays: 30,
+ },
+ artifacts: [
+ {
+ id: "fig-survival-curve",
+ path: "results/figures/survival-curve.svg",
+ kind: "figure",
+ hash: "sha256:fig-001",
+ createdAt: "2026-03-01T10:00:00.000Z",
+ lastReferencedAt: "2026-03-20T10:00:00.000Z",
+ },
+ {
+ id: "raw-screening-table",
+ path: "data/raw/screening-table.csv",
+ kind: "dataset",
+ hash: "sha256:data-raw-017",
+ createdAt: "2026-01-12T10:00:00.000Z",
+ lastReferencedAt: "2026-01-13T10:00:00.000Z",
+ },
+ {
+ id: "notebook-run-packet",
+ path: "notebooks/run-2026-05-01/replay-packet.json",
+ kind: "reproducibility-packet",
+ hash: "sha256:packet-022",
+ createdAt: "2026-05-01T10:00:00.000Z",
+ lastReferencedAt: "2026-05-01T10:00:00.000Z",
+ },
+ {
+ id: "orphan-scratch-plot",
+ path: "results/tmp/orphan-scratch-plot.png",
+ kind: "scratch",
+ hash: "sha256:tmp-003",
+ createdAt: "2026-02-01T10:00:00.000Z",
+ lastReferencedAt: "2026-02-01T10:00:00.000Z",
+ },
+ {
+ id: "protocol-appendix",
+ path: "protocols/appendix-a.md",
+ kind: "protocol",
+ hash: "sha256:protocol-009",
+ createdAt: "2026-02-10T10:00:00.000Z",
+ lastReferencedAt: "2026-02-10T10:00:00.000Z",
+ retentionHold: "IRB audit window closes 2026-12-31",
+ },
+ ],
+ references: [
+ {
+ source: "manuscript/citations.json#figure-2",
+ sourceType: "citation",
+ severity: "blocker",
+ artifacts: [{ id: "fig-survival-curve", hash: "sha256:fig-001" }],
+ },
+ {
+ source: "metadata.json#doi:10.5555/scibase.demo.v1",
+ sourceType: "doi",
+ severity: "blocker",
+ artifacts: [{ id: "fig-survival-curve", hash: "sha256:fig-001" }],
+ },
+ {
+ source: "exports/release-v1/manifest.json",
+ sourceType: "export",
+ severity: "blocker",
+ artifacts: [{ id: "raw-screening-table", hash: "sha256:data-raw-017" }],
+ },
+ {
+ source: "results/reproducibility/run-2026-05-01.json",
+ sourceType: "reproducibility",
+ severity: "blocker",
+ artifacts: [{ id: "notebook-run-packet", hash: "sha256:packet-022" }],
+ },
+ ],
+ pruneCandidates: [
+ "fig-survival-curve",
+ "raw-screening-table",
+ "notebook-run-packet",
+ "orphan-scratch-plot",
+ "protocol-appendix",
+ ],
+};
+
+module.exports = {
+ sampleRepository,
+};
diff --git a/repository-artifact-prune-guard/test.js b/repository-artifact-prune-guard/test.js
new file mode 100644
index 00000000..24d7ea0d
--- /dev/null
+++ b/repository-artifact-prune-guard/test.js
@@ -0,0 +1,63 @@
+"use strict";
+
+const assert = require("assert");
+const { analyzeArtifactPrunePlan, daysBetween } = require("./artifactPruneGuard");
+const { sampleRepository } = require("./sampleRepository");
+
+assert.strictEqual(daysBetween("2026-01-01T00:00:00.000Z", "2026-01-31T00:00:00.000Z"), 30);
+
+const packet = analyzeArtifactPrunePlan(sampleRepository);
+assert.strictEqual(packet.status, "blocked");
+assert.strictEqual(packet.summary.candidates, 5);
+assert.strictEqual(packet.summary.approved, 1);
+assert.strictEqual(packet.summary.blocked, 4);
+assert.deepStrictEqual(packet.approvedPrunes.map((item) => item.artifactId), ["orphan-scratch-plot"]);
+
+const blockedCodes = new Map(
+ packet.blockedPrunes.map((item) => [
+ item.artifactId,
+ item.blockers.map((blocker) => blocker.code),
+ ]),
+);
+assert.deepStrictEqual(blockedCodes.get("fig-survival-curve"), [
+ "CITATION_EVIDENCE_BOUND",
+ "DOI_VERSION_BOUND",
+]);
+assert.deepStrictEqual(blockedCodes.get("raw-screening-table"), ["EXPORT_MANIFEST_BOUND"]);
+assert(blockedCodes.get("notebook-run-packet").includes("REPRODUCIBILITY_PACKET_BOUND"));
+assert(blockedCodes.get("notebook-run-packet").includes("MINIMUM_AGE_NOT_MET"));
+assert(blockedCodes.get("protocol-appendix").includes("RETENTION_HOLD"));
+
+const hashDriftPacket = analyzeArtifactPrunePlan({
+ now: "2026-05-31T00:00:00.000Z",
+ policy: { minimumUnreferencedAgeDays: 1 },
+ artifacts: [
+ {
+ id: "model-card",
+ path: "results/model-card.json",
+ hash: "sha256:new",
+ createdAt: "2026-05-01T00:00:00.000Z",
+ lastReferencedAt: "2026-05-01T00:00:00.000Z",
+ },
+ ],
+ references: [
+ {
+ source: "exports/v2/manifest.json",
+ sourceType: "export",
+ artifacts: [{ id: "model-card", hash: "sha256:old" }],
+ },
+ ],
+ pruneCandidates: ["model-card"],
+});
+assert.strictEqual(hashDriftPacket.blockedPrunes[0].blockers[0].code, "REFERENCE_HASH_DRIFT");
+
+const missingPacket = analyzeArtifactPrunePlan({
+ artifacts: [],
+ references: [],
+ pruneCandidates: ["missing-artifact"],
+ now: "2026-05-31T00:00:00.000Z",
+});
+assert.strictEqual(missingPacket.status, "blocked");
+assert.strictEqual(missingPacket.warnings[0].code, "UNKNOWN_CANDIDATE");
+
+console.log("artifact-prune-guard tests passed");