diff --git a/artifact-retention-tombstone-ledger/README.md b/artifact-retention-tombstone-ledger/README.md
new file mode 100644
index 0000000..1281dc3
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/README.md
@@ -0,0 +1,13 @@
+# Artifact Retention Tombstone Ledger
+
+This self-contained slice reviews hosted scientific artifacts before deletion or disposal.
+It preserves citation continuity and checksum evidence by deciding whether an artifact must be retained, tombstoned, or safely disposed.
+
+## Run locally
+
+```bash
+node artifact-retention-tombstone-ledger/test.js
+node artifact-retention-tombstone-ledger/demo.js
+```
+
+Demo outputs are written to `artifact-retention-tombstone-ledger/demo-output/`.
diff --git a/artifact-retention-tombstone-ledger/acceptance-notes.md b/artifact-retention-tombstone-ledger/acceptance-notes.md
new file mode 100644
index 0000000..5d70171
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/acceptance-notes.md
@@ -0,0 +1,7 @@
+# Acceptance Notes
+
+- The module is dependency-free and uses synthetic data only.
+- Tests cover active holds, unelapsed retention, missing checksums, DOI/citation tombstone requirements, and destructive delete prevention.
+- The ready-path test proves an eligible artifact can be disposed with a tombstone packet.
+- Demo artifacts include JSON, Markdown, SVG, and MP4 output.
+- The slice is distinct from prior #14 submissions around FAIR manifests, compute governance, provenance, quarantine, quota/deduplication, preview cache, raw instrument preview, and notebook preview.
diff --git a/artifact-retention-tombstone-ledger/demo-output/demo.mp4 b/artifact-retention-tombstone-ledger/demo-output/demo.mp4
new file mode 100644
index 0000000..8413365
Binary files /dev/null and b/artifact-retention-tombstone-ledger/demo-output/demo.mp4 differ
diff --git a/artifact-retention-tombstone-ledger/demo-output/demo.svg b/artifact-retention-tombstone-ledger/demo-output/demo.svg
new file mode 100644
index 0000000..0c97909
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/demo-output/demo.svg
@@ -0,0 +1,13 @@
+
diff --git a/artifact-retention-tombstone-ledger/demo-output/disposal-review.md b/artifact-retention-tombstone-ledger/demo-output/disposal-review.md
new file mode 100644
index 0000000..e703c72
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/demo-output/disposal-review.md
@@ -0,0 +1,13 @@
+# Artifact retention and tombstone review
+
+Project: hosting-cardio-fluid-dynamics
+Status: hold
+
+## Holds
+- raw-trials-2024: Clear or document the legal, IRB, funder, or citation hold before disposal.
+- simulation-cache-v3: Generate and preserve SHA-256 evidence before any deletion or tombstone export.
+- derived-public-csv: Record DOI, title, type, checksum, license, and replacement URI before disposal.
+- notebook-results-v1: Use a tombstone-preserving disposal mode for cited scientific artifacts.
+- raw-trials-2024: Wait until the retention window elapses or obtain an explicit retention override.
+
+Audit digest: fa494d5362739c320e1bdcc1764ea662c55944682a87715de046142c29f13568
diff --git a/artifact-retention-tombstone-ledger/demo-output/tombstone-ledger-packet.json b/artifact-retention-tombstone-ledger/demo-output/tombstone-ledger-packet.json
new file mode 100644
index 0000000..4896795
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/demo-output/tombstone-ledger-packet.json
@@ -0,0 +1,141 @@
+{
+ "packetType": "artifact-retention-tombstone-ledger",
+ "projectId": "hosting-cardio-fluid-dynamics",
+ "generatedAt": "2026-05-20T12:00:00Z",
+ "overallStatus": "hold",
+ "decisions": [
+ {
+ "artifactId": "raw-trials-2024",
+ "decision": "hold",
+ "tombstoneRequired": false,
+ "retentionUntil": "2027-01-01T00:00:00Z",
+ "disposalMode": "tombstone"
+ },
+ {
+ "artifactId": "simulation-cache-v3",
+ "decision": "hold",
+ "tombstoneRequired": false,
+ "retentionUntil": "2025-01-01T00:00:00Z",
+ "disposalMode": "delete"
+ },
+ {
+ "artifactId": "derived-public-csv",
+ "decision": "tombstone_required",
+ "tombstoneRequired": true,
+ "retentionUntil": "2025-01-01T00:00:00Z",
+ "disposalMode": "tombstone"
+ },
+ {
+ "artifactId": "notebook-results-v1",
+ "decision": "hold",
+ "tombstoneRequired": true,
+ "retentionUntil": "2025-01-01T00:00:00Z",
+ "disposalMode": "hard_delete"
+ }
+ ],
+ "holds": [
+ {
+ "artifactId": "raw-trials-2024",
+ "code": "active_policy_hold",
+ "owner": "data-steward",
+ "severity": "blocker",
+ "evidence": "raw-trials-2024 has active hold(s): irb-hold.",
+ "requiredAction": "Clear or document the legal, IRB, funder, or citation hold before disposal."
+ },
+ {
+ "artifactId": "simulation-cache-v3",
+ "code": "checksum_manifest_missing",
+ "owner": "compute-admin",
+ "severity": "blocker",
+ "evidence": "simulation-cache-v3 is missing a checksum manifest.",
+ "requiredAction": "Generate and preserve SHA-256 evidence before any deletion or tombstone export."
+ },
+ {
+ "artifactId": "derived-public-csv",
+ "code": "citation_tombstone_required",
+ "owner": "repository-owner",
+ "severity": "blocker",
+ "evidence": "derived-public-csv is cited or DOI-backed but lacks complete tombstone metadata.",
+ "requiredAction": "Record DOI, title, type, checksum, license, and replacement URI before disposal."
+ },
+ {
+ "artifactId": "notebook-results-v1",
+ "code": "destructive_delete_not_allowed",
+ "owner": "repository-owner",
+ "severity": "blocker",
+ "evidence": "notebook-results-v1 requested hard delete despite citation dependencies.",
+ "requiredAction": "Use a tombstone-preserving disposal mode for cited scientific artifacts."
+ },
+ {
+ "artifactId": "raw-trials-2024",
+ "code": "retention_window_not_elapsed",
+ "owner": "data-steward",
+ "severity": "blocker",
+ "evidence": "raw-trials-2024 must be retained until 2027-01-01T00:00:00Z.",
+ "requiredAction": "Wait until the retention window elapses or obtain an explicit retention override."
+ }
+ ],
+ "tombstonePlan": [
+ {
+ "artifactId": "raw-trials-2024",
+ "decision": "hold",
+ "requiredMetadata": {
+ "doi": null,
+ "title": "Raw protected waveform trials",
+ "artifactType": "dataset",
+ "sha256": "4f8a6c0f6a3e7c85d236d2dd7251c2a6d4c6f5974f4e1a17c95bbf58b9fd0041",
+ "license": "restricted-dua",
+ "replacementUri": null,
+ "disposalReason": "superseded protected dataset"
+ },
+ "preserveChecksum": true,
+ "publicLandingPage": false
+ },
+ {
+ "artifactId": "simulation-cache-v3",
+ "decision": "hold",
+ "requiredMetadata": {
+ "doi": null,
+ "title": "Simulation cache v3",
+ "artifactType": "model-cache",
+ "sha256": null,
+ "license": "internal",
+ "replacementUri": null,
+ "disposalReason": "cache eviction"
+ },
+ "preserveChecksum": false,
+ "publicLandingPage": false
+ },
+ {
+ "artifactId": "derived-public-csv",
+ "decision": "tombstone_required",
+ "requiredMetadata": {
+ "doi": "10.5555/scibase.derived-pressure",
+ "title": "Derived public pressure table",
+ "artifactType": "dataset",
+ "sha256": "9b340ad03e9c4d2fdc1768c374fd898d021a7e11a62f1c812f12792b3e65cf3d",
+ "license": "cc-by-4.0",
+ "replacementUri": "https://doi.org/10.5555/scibase.derived-pressure-v2",
+ "disposalReason": "replaced by corrected table"
+ },
+ "preserveChecksum": true,
+ "publicLandingPage": true
+ },
+ {
+ "artifactId": "notebook-results-v1",
+ "decision": "hold",
+ "requiredMetadata": {
+ "doi": "10.5555/scibase.notebook-results-v1",
+ "title": "Notebook results v1",
+ "artifactType": "notebook",
+ "sha256": "ce8a3b1337fb61e3a0b273a7f085d45f943fda03f852e37caad06cc8215998e6",
+ "license": "cc-by-4.0",
+ "replacementUri": null,
+ "disposalReason": "author requested cleanup"
+ },
+ "preserveChecksum": true,
+ "publicLandingPage": true
+ }
+ ],
+ "auditDigest": "fa494d5362739c320e1bdcc1764ea662c55944682a87715de046142c29f13568"
+}
diff --git a/artifact-retention-tombstone-ledger/demo.js b/artifact-retention-tombstone-ledger/demo.js
new file mode 100644
index 0000000..245243b
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/demo.js
@@ -0,0 +1,75 @@
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const { buildTombstoneLedgerPacket } = require('./index');
+const { sampleHostingProject } = require('./sample-data');
+
+const outputDir = path.join(__dirname, 'demo-output');
+fs.mkdirSync(outputDir, { recursive: true });
+
+const packet = buildTombstoneLedgerPacket(sampleHostingProject, {
+ asOf: '2026-05-20T12:00:00Z',
+});
+
+fs.writeFileSync(path.join(outputDir, 'tombstone-ledger-packet.json'), `${JSON.stringify(packet, null, 2)}\n`);
+
+const rows = packet.holds
+ .map((hold, index) => `${index + 1}. ${hold.artifactId}: ${hold.code}`)
+ .join('\n ');
+
+fs.writeFileSync(path.join(outputDir, 'demo.svg'), `
+`);
+
+fs.writeFileSync(path.join(outputDir, 'disposal-review.md'), [
+ '# Artifact retention and tombstone review',
+ '',
+ `Project: ${packet.projectId}`,
+ `Status: ${packet.overallStatus}`,
+ '',
+ '## Holds',
+ ...packet.holds.map((hold) => `- ${hold.artifactId}: ${hold.requiredAction}`),
+ '',
+ `Audit digest: ${packet.auditDigest}`,
+ '',
+].join('\n'));
+
+function renderMp4() {
+ const videoPath = path.join(outputDir, 'demo.mp4');
+ const font = 'C\\:/Windows/Fonts/arial.ttf';
+ const escapeText = (value) => String(value).replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'");
+ const filters = [
+ `drawtext=fontfile='${font}':text='${escapeText('Artifact Retention Tombstone Ledger')}':x=70:y=80:fontsize=41:fontcolor=white`,
+ `drawtext=fontfile='${font}':text='${escapeText(`${packet.holds.length} disposal blockers before deletion`)}':x=70:y=155:fontsize=32:fontcolor=0xffd166`,
+ ...packet.holds.map((hold, index) =>
+ `drawtext=fontfile='${font}':text='${escapeText(`${hold.artifactId}: ${hold.code}`)}':x=90:y=${230 + index * 55}:fontsize=26:fontcolor=white`,
+ ),
+ `drawtext=fontfile='${font}':text='${escapeText(`audit ${packet.auditDigest.slice(0, 20)}...`)}':x=70:y=630:fontsize=24:fontcolor=0x93c5fd`,
+ ].join(',');
+
+ execFileSync('ffmpeg', [
+ '-y',
+ '-f',
+ 'lavfi',
+ '-i',
+ 'color=c=0x172554:s=1280x720:d=7',
+ '-vf',
+ filters,
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ videoPath,
+ ], { stdio: 'inherit' });
+}
+
+renderMp4();
+
+console.log(`Wrote demo artifacts to ${outputDir}`);
diff --git a/artifact-retention-tombstone-ledger/index.js b/artifact-retention-tombstone-ledger/index.js
new file mode 100644
index 0000000..5eef4ce
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/index.js
@@ -0,0 +1,189 @@
+const crypto = require('crypto');
+
+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');
+}
+
+function toTime(value) {
+ return new Date(value).getTime();
+}
+
+function addHold(holds, artifact, code, owner, evidence, requiredAction) {
+ holds.push({
+ artifactId: artifact.artifactId,
+ code,
+ owner,
+ severity: 'blocker',
+ evidence,
+ requiredAction,
+ });
+}
+
+function tombstoneMetadataFor(artifact) {
+ return {
+ doi: artifact.doi || null,
+ title: artifact.title,
+ artifactType: artifact.artifactType,
+ sha256: artifact.sha256 || null,
+ license: artifact.license,
+ replacementUri: artifact.replacementUri || null,
+ disposalReason: artifact.disposalRequest.reason,
+ };
+}
+
+function evaluateArtifact(artifact, options) {
+ const holds = [];
+
+ if (artifact.policyHolds.length > 0) {
+ addHold(
+ holds,
+ artifact,
+ 'active_policy_hold',
+ artifact.owner,
+ `${artifact.artifactId} has active hold(s): ${artifact.policyHolds.join(', ')}.`,
+ 'Clear or document the legal, IRB, funder, or citation hold before disposal.',
+ );
+ }
+
+ if (toTime(artifact.retentionUntil) > toTime(options.asOf)) {
+ addHold(
+ holds,
+ artifact,
+ 'retention_window_not_elapsed',
+ artifact.owner,
+ `${artifact.artifactId} must be retained until ${artifact.retentionUntil}.`,
+ 'Wait until the retention window elapses or obtain an explicit retention override.',
+ );
+ }
+
+ if (!artifact.sha256) {
+ addHold(
+ holds,
+ artifact,
+ 'checksum_manifest_missing',
+ artifact.owner,
+ `${artifact.artifactId} is missing a checksum manifest.`,
+ 'Generate and preserve SHA-256 evidence before any deletion or tombstone export.',
+ );
+ }
+
+ const hasCitationDependency = artifact.doi || artifact.citationCount > 0;
+ if (hasCitationDependency && !artifact.tombstoneMetadataComplete) {
+ addHold(
+ holds,
+ artifact,
+ 'citation_tombstone_required',
+ artifact.owner,
+ `${artifact.artifactId} is cited or DOI-backed but lacks complete tombstone metadata.`,
+ 'Record DOI, title, type, checksum, license, and replacement URI before disposal.',
+ );
+ }
+
+ if (artifact.disposalRequest.mode === 'hard_delete' && hasCitationDependency) {
+ addHold(
+ holds,
+ artifact,
+ 'destructive_delete_not_allowed',
+ artifact.owner,
+ `${artifact.artifactId} requested hard delete despite citation dependencies.`,
+ 'Use a tombstone-preserving disposal mode for cited scientific artifacts.',
+ );
+ }
+
+ let decision = 'retain';
+ if (holds.length > 0) {
+ decision = holds.some((hold) => hold.code === 'citation_tombstone_required') && holds.length === 1
+ ? 'tombstone_required'
+ : 'hold';
+ } else if (artifact.disposalRequest.requested) {
+ decision = artifact.doi || artifact.citationCount > 0 ? 'dispose_with_tombstone' : 'dispose';
+ }
+
+ return {
+ decision: {
+ artifactId: artifact.artifactId,
+ decision,
+ tombstoneRequired: Boolean(artifact.doi || artifact.citationCount > 0),
+ retentionUntil: artifact.retentionUntil,
+ disposalMode: artifact.disposalRequest.mode,
+ },
+ holds,
+ tombstonePlan: {
+ artifactId: artifact.artifactId,
+ decision,
+ requiredMetadata: tombstoneMetadataFor(artifact),
+ preserveChecksum: Boolean(artifact.sha256),
+ publicLandingPage: Boolean(artifact.doi || artifact.citationCount > 0),
+ },
+ };
+}
+
+function evaluateArtifactRetention(project, options = {}) {
+ const evaluationOptions = {
+ asOf: options.asOf || new Date().toISOString(),
+ };
+ const decisions = [];
+ const holds = [];
+ const tombstonePlan = [];
+
+ for (const artifact of project.artifacts) {
+ const result = evaluateArtifact(artifact, evaluationOptions);
+ decisions.push(result.decision);
+ holds.push(...result.holds);
+ tombstonePlan.push(result.tombstonePlan);
+ }
+
+ const sortedHolds = holds.sort((left, right) => left.code.localeCompare(right.code));
+
+ return {
+ projectId: project.projectId,
+ overallStatus: sortedHolds.length > 0 ? 'hold' : 'ready',
+ summary: {
+ artifactsReviewed: project.artifacts.length,
+ blockingHolds: sortedHolds.length,
+ disposalReady: decisions.filter((decision) => decision.decision.startsWith('dispose')).length,
+ tombstonesRequired: decisions.filter((decision) => decision.tombstoneRequired).length,
+ },
+ decisions,
+ holds: sortedHolds,
+ tombstonePlan,
+ };
+}
+
+function buildTombstoneLedgerPacket(project, options = {}) {
+ const review = evaluateArtifactRetention(project, options);
+ const packet = {
+ packetType: 'artifact-retention-tombstone-ledger',
+ projectId: review.projectId,
+ generatedAt: options.asOf || new Date().toISOString(),
+ overallStatus: review.overallStatus,
+ decisions: review.decisions,
+ holds: review.holds,
+ tombstonePlan: review.tombstonePlan,
+ };
+
+ return {
+ ...packet,
+ auditDigest: digest(packet),
+ };
+}
+
+module.exports = {
+ evaluateArtifactRetention,
+ buildTombstoneLedgerPacket,
+ stableStringify,
+ digest,
+};
diff --git a/artifact-retention-tombstone-ledger/requirements-map.md b/artifact-retention-tombstone-ledger/requirements-map.md
new file mode 100644
index 0000000..4bbf9c7
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/requirements-map.md
@@ -0,0 +1,12 @@
+# Requirements Map
+
+Issue #14 asks for scientific and engineering data/code hosting, including metadata, access, versioning, reproducibility, storage, and export workflows.
+
+| Requirement area | Coverage in this slice |
+| --- | --- |
+| Hosted artifact lifecycle | Reviews deletion requests for datasets, model caches, notebooks, and derived tables. |
+| Metadata continuity | Requires DOI, title, type, checksum, license, and replacement URI in tombstone records. |
+| Integrity | Blocks disposal when checksum manifests are missing. |
+| Retention and compliance | Enforces IRB, legal, funder, citation, and retention-window holds. |
+| Public repository trust | Prevents destructive deletion of DOI-backed or cited artifacts. |
+| Export readiness | Emits deterministic JSON/Markdown/SVG/MP4 evidence packets for reviewers. |
diff --git a/artifact-retention-tombstone-ledger/sample-data.js b/artifact-retention-tombstone-ledger/sample-data.js
new file mode 100644
index 0000000..f871aeb
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/sample-data.js
@@ -0,0 +1,108 @@
+const sampleHostingProject = {
+ projectId: 'hosting-cardio-fluid-dynamics',
+ artifacts: [
+ {
+ artifactId: 'raw-trials-2024',
+ title: 'Raw protected waveform trials',
+ artifactType: 'dataset',
+ owner: 'data-steward',
+ license: 'restricted-dua',
+ doi: null,
+ citationCount: 0,
+ retentionUntil: '2027-01-01T00:00:00Z',
+ policyHolds: ['irb-hold'],
+ sha256: '4f8a6c0f6a3e7c85d236d2dd7251c2a6d4c6f5974f4e1a17c95bbf58b9fd0041',
+ tombstoneMetadataComplete: true,
+ disposalRequest: {
+ requested: true,
+ mode: 'tombstone',
+ reason: 'superseded protected dataset',
+ },
+ },
+ {
+ artifactId: 'simulation-cache-v3',
+ title: 'Simulation cache v3',
+ artifactType: 'model-cache',
+ owner: 'compute-admin',
+ license: 'internal',
+ doi: null,
+ citationCount: 0,
+ retentionUntil: '2025-01-01T00:00:00Z',
+ policyHolds: [],
+ sha256: null,
+ tombstoneMetadataComplete: true,
+ disposalRequest: {
+ requested: true,
+ mode: 'delete',
+ reason: 'cache eviction',
+ },
+ },
+ {
+ artifactId: 'derived-public-csv',
+ title: 'Derived public pressure table',
+ artifactType: 'dataset',
+ owner: 'repository-owner',
+ license: 'cc-by-4.0',
+ doi: '10.5555/scibase.derived-pressure',
+ citationCount: 8,
+ retentionUntil: '2025-01-01T00:00:00Z',
+ policyHolds: [],
+ sha256: '9b340ad03e9c4d2fdc1768c374fd898d021a7e11a62f1c812f12792b3e65cf3d',
+ tombstoneMetadataComplete: false,
+ replacementUri: 'https://doi.org/10.5555/scibase.derived-pressure-v2',
+ disposalRequest: {
+ requested: true,
+ mode: 'tombstone',
+ reason: 'replaced by corrected table',
+ },
+ },
+ {
+ artifactId: 'notebook-results-v1',
+ title: 'Notebook results v1',
+ artifactType: 'notebook',
+ owner: 'repository-owner',
+ license: 'cc-by-4.0',
+ doi: '10.5555/scibase.notebook-results-v1',
+ citationCount: 3,
+ retentionUntil: '2025-01-01T00:00:00Z',
+ policyHolds: [],
+ sha256: 'ce8a3b1337fb61e3a0b273a7f085d45f943fda03f852e37caad06cc8215998e6',
+ tombstoneMetadataComplete: true,
+ disposalRequest: {
+ requested: true,
+ mode: 'hard_delete',
+ reason: 'author requested cleanup',
+ },
+ },
+ ],
+};
+
+const disposalReadyProject = {
+ projectId: 'hosting-open-microscopy',
+ artifacts: [
+ {
+ artifactId: 'microscopy-derived-v1',
+ title: 'Microscopy derived masks v1',
+ artifactType: 'dataset',
+ owner: 'repository-owner',
+ license: 'cc-by-4.0',
+ doi: '10.5555/scibase.microscopy-derived-v1',
+ citationCount: 2,
+ retentionUntil: '2025-01-01T00:00:00Z',
+ policyHolds: [],
+ sha256: '01ab8d9e2f7c6f7bd88f32ce2a758aa9f26adbc5bc47d2cece9a9d9ea4b76411',
+ tombstoneMetadataComplete: true,
+ replacementUri: 'https://doi.org/10.5555/scibase.microscopy-derived-v2',
+ disposalRequest: {
+ requested: true,
+ mode: 'tombstone',
+ reason: 'superseded by v2 segmentation masks',
+ },
+ },
+ ],
+};
+
+module.exports = {
+ sampleHostingProject,
+ disposalReadyProject,
+};
diff --git a/artifact-retention-tombstone-ledger/test.js b/artifact-retention-tombstone-ledger/test.js
new file mode 100644
index 0000000..b1a2869
--- /dev/null
+++ b/artifact-retention-tombstone-ledger/test.js
@@ -0,0 +1,59 @@
+const assert = require('assert');
+const {
+ evaluateArtifactRetention,
+ buildTombstoneLedgerPacket,
+} = require('./index');
+const { sampleHostingProject, disposalReadyProject } = require('./sample-data');
+
+function testRetentionAndHoldBlockers() {
+ const review = evaluateArtifactRetention(sampleHostingProject, {
+ asOf: '2026-05-20T12:00:00Z',
+ });
+
+ assert.equal(review.overallStatus, 'hold');
+ assert.equal(review.summary.blockingHolds, 5);
+ assert.ok(review.holds.some((hold) => hold.code === 'active_policy_hold'));
+ assert.ok(review.holds.some((hold) => hold.code === 'retention_window_not_elapsed'));
+ assert.ok(review.holds.some((hold) => hold.code === 'checksum_manifest_missing'));
+ assert.ok(review.holds.some((hold) => hold.code === 'citation_tombstone_required'));
+ assert.ok(review.holds.some((hold) => hold.code === 'destructive_delete_not_allowed'));
+ assert.equal(review.decisions.find((decision) => decision.artifactId === 'raw-trials-2024').decision, 'hold');
+ assert.equal(review.decisions.find((decision) => decision.artifactId === 'derived-public-csv').decision, 'tombstone_required');
+}
+
+function testLedgerPacketPreservesAuditEvidence() {
+ const packet = buildTombstoneLedgerPacket(sampleHostingProject, {
+ asOf: '2026-05-20T12:00:00Z',
+ });
+
+ assert.match(packet.auditDigest, /^[a-f0-9]{64}$/);
+ assert.equal(packet.tombstonePlan.length, 4);
+ assert.ok(packet.tombstonePlan.some((item) => item.artifactId === 'derived-public-csv' && item.requiredMetadata.doi));
+ assert.deepEqual(
+ packet.holds.map((hold) => hold.code),
+ [
+ 'active_policy_hold',
+ 'checksum_manifest_missing',
+ 'citation_tombstone_required',
+ 'destructive_delete_not_allowed',
+ 'retention_window_not_elapsed',
+ ],
+ );
+}
+
+function testEligibleDisposalProducesTombstonePacket() {
+ const review = evaluateArtifactRetention(disposalReadyProject, {
+ asOf: '2026-05-20T12:00:00Z',
+ });
+
+ assert.equal(review.overallStatus, 'ready');
+ assert.equal(review.summary.blockingHolds, 0);
+ assert.equal(review.decisions[0].decision, 'dispose_with_tombstone');
+ assert.equal(review.decisions[0].tombstoneRequired, true);
+}
+
+testRetentionAndHoldBlockers();
+testLedgerPacketPreservesAuditEvidence();
+testEligibleDisposalProducesTombstonePacket();
+
+console.log('artifact-retention-tombstone-ledger tests passed');