diff --git a/resumable-upload-checkpoint-guard/README.md b/resumable-upload-checkpoint-guard/README.md
new file mode 100644
index 00000000..bec66543
--- /dev/null
+++ b/resumable-upload-checkpoint-guard/README.md
@@ -0,0 +1,31 @@
+# Resumable Upload Checkpoint Guard
+
+Self-contained Scientific/Engineering Data & Code Hosting slice for issue #14. It validates multipart upload checkpoint evidence before scientific datasets, notebooks, and supplements become durable hosted artifacts.
+
+## What It Checks
+
+- Contiguous chunk coverage from index `0` through the expected final chunk.
+- Per-chunk declared checksum versus observed checksum evidence.
+- Final artifact manifest hash before commit.
+- DataCite/schema.org metadata schema readiness.
+- Expired checkpoint state that must be restarted instead of resumed.
+
+## Outputs
+
+- `reports/upload-checkpoint-packet.json`: structured reviewer decisions and findings.
+- `reports/checkpoint-report.md`: readable report for each synthetic upload scenario.
+- `reports/summary.svg`: visual summary of commit, hold, and abort decisions.
+- `reports/demo.mp4`: short demo artifact for Algora review.
+
+## Local Verification
+
+```bash
+node resumable-upload-checkpoint-guard/test.js
+node resumable-upload-checkpoint-guard/demo.js
+node --check resumable-upload-checkpoint-guard/index.js
+node --check resumable-upload-checkpoint-guard/test.js
+node --check resumable-upload-checkpoint-guard/demo.js
+node --check resumable-upload-checkpoint-guard/sample-data.js
+```
+
+The module is dependency-free, uses synthetic data only, and makes no network calls.
diff --git a/resumable-upload-checkpoint-guard/demo.js b/resumable-upload-checkpoint-guard/demo.js
new file mode 100644
index 00000000..645a3183
--- /dev/null
+++ b/resumable-upload-checkpoint-guard/demo.js
@@ -0,0 +1,59 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+const {evaluateUploadCheckpoint, buildCheckpointReport} = require('./index');
+const {scenarios} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const evaluations = scenarios.map((scenario) => ({
+ scenario: scenario.name,
+ ...evaluateUploadCheckpoint(scenario),
+}));
+
+const packetJson = JSON.stringify(evaluations, null, 2);
+const reviewerReport = evaluations.map(buildCheckpointReport).join('\n---\n');
+const commit = evaluations.filter((item) => item.decision === 'commit-artifact').length;
+const metadata = evaluations.filter((item) => item.decision === 'hold-metadata').length;
+const resume = evaluations.filter((item) => item.decision === 'hold-resume').length;
+const abort = evaluations.filter((item) => item.decision === 'abort-and-reupload').length;
+const findings = evaluations.reduce((sum, item) => sum + item.findings.length, 0);
+
+const svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'upload-checkpoint-packet.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'checkpoint-report.md'), reviewerReport);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+console.log(`Wrote ${evaluations.length} upload checkpoint evaluations to ${reportsDir}`);
+console.log(`Decision counts: commit=${commit}, metadata=${metadata}, resume=${resume}, abort=${abort}`);
+console.log(`Reviewer findings: ${findings}`);
diff --git a/resumable-upload-checkpoint-guard/index.js b/resumable-upload-checkpoint-guard/index.js
new file mode 100644
index 00000000..be342e75
--- /dev/null
+++ b/resumable-upload-checkpoint-guard/index.js
@@ -0,0 +1,216 @@
+function normalizeList(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function uploadAction(type, target, reason) {
+ return {type, target, reason};
+}
+
+function missingChunkIndexes(expectedChunks, chunkIndexes) {
+ const received = new Set(chunkIndexes);
+ const missing = [];
+ for (let index = 0; index < expectedChunks; index += 1) {
+ if (!received.has(index)) {
+ missing.push(index);
+ }
+ }
+ return missing;
+}
+
+function severityCounts(findings) {
+ return findings.reduce((counts, finding) => {
+ counts[finding.severity] = (counts[finding.severity] || 0) + 1;
+ return counts;
+ }, {});
+}
+
+function evaluateUploadCheckpoint(input) {
+ const artifact = input.artifact || {};
+ const chunks = normalizeList(input.chunks);
+ const expectedChunks = Number(artifact.expectedChunks || 0);
+ const chunkIndexes = chunks.map((chunk) => chunk.index);
+ const uniqueChunkIndexes = [...new Set(chunkIndexes)];
+ const findings = [];
+ const requiredActions = [];
+ const missingChunks = missingChunkIndexes(expectedChunks, chunkIndexes);
+ const receivedBytes = chunks.reduce((sum, chunk) => sum + Number(chunk.sizeBytes || 0), 0);
+
+ if (missingChunks.length > 0) {
+ findings.push({
+ type: 'missing-upload-chunk',
+ severity: 'critical',
+ missingChunks,
+ message: `Upload checkpoint is missing chunk indexes ${missingChunks.join(', ')}`,
+ });
+ requiredActions.push(uploadAction(
+ 'abort_incomplete_checkpoint',
+ input.uploadId,
+ 'durable artifact commits require contiguous multipart coverage'
+ ));
+ }
+
+ const duplicateChunks = uniqueChunkIndexes.filter((index) =>
+ chunkIndexes.filter((chunkIndex) => chunkIndex === index).length > 1
+ );
+ if (duplicateChunks.length > 0) {
+ findings.push({
+ type: 'duplicate-upload-chunk',
+ severity: 'major',
+ duplicateChunks,
+ message: `Upload checkpoint repeats chunk indexes ${duplicateChunks.join(', ')}`,
+ });
+ requiredActions.push(uploadAction(
+ 'deduplicate_checkpoint_chunks',
+ input.uploadId,
+ 'resume state must have one authoritative checksum per chunk index'
+ ));
+ }
+
+ for (const chunk of chunks) {
+ if (chunk.declaredHash !== chunk.observedHash) {
+ findings.push({
+ type: 'chunk-checksum-mismatch',
+ severity: 'major',
+ chunk: chunk.index,
+ declaredHash: chunk.declaredHash,
+ observedHash: chunk.observedHash,
+ message: `Chunk ${chunk.index} declares ${chunk.declaredHash} but observed ${chunk.observedHash}`,
+ });
+ requiredActions.push(uploadAction(
+ 'reupload_chunk',
+ `${input.uploadId}:chunk-${chunk.index}`,
+ 'chunk checksum evidence must match before upload resume or artifact commit'
+ ));
+ }
+ }
+
+ if (artifact.expectedSizeBytes && receivedBytes !== artifact.expectedSizeBytes) {
+ findings.push({
+ type: 'upload-size-mismatch',
+ severity: 'major',
+ expectedSizeBytes: artifact.expectedSizeBytes,
+ receivedBytes,
+ message: `Received ${receivedBytes} bytes but artifact manifest expects ${artifact.expectedSizeBytes}`,
+ });
+ requiredActions.push(uploadAction(
+ 'reconcile_upload_size',
+ input.uploadId,
+ 'chunk byte totals must match the artifact manifest'
+ ));
+ }
+
+ if (!artifact.finalManifestHash) {
+ findings.push({
+ type: 'missing-final-manifest-hash',
+ severity: 'metadata',
+ message: 'Artifact lacks final manifest hash',
+ });
+ requiredActions.push(uploadAction(
+ 'record_final_manifest_hash',
+ artifact.path || input.uploadId,
+ 'durable commits need a stable manifest hash for replay and DOI metadata'
+ ));
+ }
+
+ if (!artifact.metadataSchema) {
+ findings.push({
+ type: 'missing-metadata-schema',
+ severity: 'metadata',
+ message: 'Artifact lacks DataCite/schema.org metadata schema evidence',
+ });
+ requiredActions.push(uploadAction(
+ 'attach_metadata_schema',
+ artifact.path || input.uploadId,
+ 'hosted research artifacts need machine-readable metadata before commit'
+ ));
+ }
+
+ if (input.generatedAt && input.expiresAt && Date.parse(input.generatedAt) > Date.parse(input.expiresAt)) {
+ findings.push({
+ type: 'stale-upload-checkpoint',
+ severity: 'critical',
+ expiresAt: input.expiresAt,
+ message: `Upload checkpoint expired at ${input.expiresAt}`,
+ });
+ requiredActions.push(uploadAction(
+ 'restart_expired_upload',
+ input.uploadId,
+ 'expired resume state cannot safely become a durable artifact'
+ ));
+ }
+
+ const counts = severityCounts(findings);
+ const criticalCount = counts.critical || 0;
+ const majorCount = counts.major || 0;
+ const metadataCount = counts.metadata || 0;
+ const decision = criticalCount > 0
+ ? 'abort-and-reupload'
+ : majorCount > 0
+ ? 'hold-resume'
+ : metadataCount > 0
+ ? 'hold-metadata'
+ : 'commit-artifact';
+ const integrityScore = Math.max(0, 100 - criticalCount * 40 - majorCount * 25 - metadataCount * 10);
+
+ return {
+ uploadId: input.uploadId,
+ generatedAt: input.generatedAt,
+ expiresAt: input.expiresAt,
+ artifactPath: artifact.path,
+ decision,
+ integrityScore,
+ findings,
+ requiredActions,
+ summary: {
+ expectedChunks,
+ receivedChunks: uniqueChunkIndexes.length,
+ coverage: expectedChunks === 0 ? 1 : uniqueChunkIndexes.length / expectedChunks,
+ expectedSizeBytes: artifact.expectedSizeBytes || 0,
+ receivedBytes,
+ severityCounts: counts,
+ },
+ };
+}
+
+function percent(value) {
+ return `${Math.round(value * 100)}%`;
+}
+
+function buildCheckpointReport(result) {
+ const lines = [
+ '# Resumable Upload Checkpoint Guard Report',
+ '',
+ `Upload: ${result.uploadId}`,
+ `Artifact: ${result.artifactPath}`,
+ `Generated: ${result.generatedAt}`,
+ `Expires: ${result.expiresAt}`,
+ `Decision: ${result.decision}`,
+ `Integrity score: ${result.integrityScore}`,
+ '',
+ '## Upload Coverage',
+ '',
+ `Chunks: ${result.summary.receivedChunks}/${result.summary.expectedChunks}`,
+ `Coverage: ${percent(result.summary.coverage)}`,
+ `Bytes: ${result.summary.receivedBytes}/${result.summary.expectedSizeBytes}`,
+ `Findings: ${result.findings.length}`,
+ '',
+ '## Findings',
+ '',
+ ...(result.findings.length
+ ? result.findings.map((finding) => `- ${finding.severity}: ${finding.type} - ${finding.message}`)
+ : ['- None']),
+ '',
+ '## Required Actions',
+ '',
+ ...(result.requiredActions.length
+ ? result.requiredActions.map((action) => `- ${action.type}: ${action.target} (${action.reason})`)
+ : ['- None']),
+ '',
+ ];
+ return lines.join('\n');
+}
+
+module.exports = {
+ evaluateUploadCheckpoint,
+ buildCheckpointReport,
+};
diff --git a/resumable-upload-checkpoint-guard/reports/checkpoint-report.md b/resumable-upload-checkpoint-guard/reports/checkpoint-report.md
new file mode 100644
index 00000000..d45b9439
--- /dev/null
+++ b/resumable-upload-checkpoint-guard/reports/checkpoint-report.md
@@ -0,0 +1,102 @@
+# Resumable Upload Checkpoint Guard Report
+
+Upload: upload-climate-parquet
+Artifact: datasets/climate-observations.parquet
+Generated: 2026-05-22T14:10:00Z
+Expires: 2026-05-23T00:00:00Z
+Decision: abort-and-reupload
+Integrity score: 35
+
+## Upload Coverage
+
+Chunks: 3/4
+Coverage: 75%
+Bytes: 3072/4096
+Findings: 2
+
+## Findings
+
+- critical: missing-upload-chunk - Upload checkpoint is missing chunk indexes 2
+- major: upload-size-mismatch - Received 3072 bytes but artifact manifest expects 4096
+
+## Required Actions
+
+- abort_incomplete_checkpoint: upload-climate-parquet (durable artifact commits require contiguous multipart coverage)
+- reconcile_upload_size: upload-climate-parquet (chunk byte totals must match the artifact manifest)
+
+---
+# Resumable Upload Checkpoint Guard Report
+
+Upload: upload-notebook
+Artifact: notebooks/reproduce.ipynb
+Generated: 2026-05-22T14:10:00Z
+Expires: 2026-05-23T00:00:00Z
+Decision: hold-resume
+Integrity score: 75
+
+## Upload Coverage
+
+Chunks: 2/2
+Coverage: 100%
+Bytes: 2048/2048
+Findings: 1
+
+## Findings
+
+- major: chunk-checksum-mismatch - Chunk 1 declares sha256:declared but observed sha256:observed
+
+## Required Actions
+
+- reupload_chunk: upload-notebook:chunk-1 (chunk checksum evidence must match before upload resume or artifact commit)
+
+---
+# Resumable Upload Checkpoint Guard Report
+
+Upload: upload-rna-counts
+Artifact: datasets/rna-counts.csv
+Generated: 2026-05-22T14:10:00Z
+Expires: 2026-05-23T00:00:00Z
+Decision: hold-metadata
+Integrity score: 80
+
+## Upload Coverage
+
+Chunks: 1/1
+Coverage: 100%
+Bytes: 1000/1000
+Findings: 2
+
+## Findings
+
+- metadata: missing-final-manifest-hash - Artifact lacks final manifest hash
+- metadata: missing-metadata-schema - Artifact lacks DataCite/schema.org metadata schema evidence
+
+## Required Actions
+
+- record_final_manifest_hash: datasets/rna-counts.csv (durable commits need a stable manifest hash for replay and DOI metadata)
+- attach_metadata_schema: datasets/rna-counts.csv (hosted research artifacts need machine-readable metadata before commit)
+
+---
+# Resumable Upload Checkpoint Guard Report
+
+Upload: upload-clean-supplement
+Artifact: supplements/source-data.zip
+Generated: 2026-05-22T14:10:00Z
+Expires: 2026-05-23T00:00:00Z
+Decision: commit-artifact
+Integrity score: 100
+
+## Upload Coverage
+
+Chunks: 3/3
+Coverage: 100%
+Bytes: 3072/3072
+Findings: 0
+
+## Findings
+
+- None
+
+## Required Actions
+
+- None
diff --git a/resumable-upload-checkpoint-guard/reports/demo.mp4 b/resumable-upload-checkpoint-guard/reports/demo.mp4
new file mode 100644
index 00000000..cd84fda5
Binary files /dev/null and b/resumable-upload-checkpoint-guard/reports/demo.mp4 differ
diff --git a/resumable-upload-checkpoint-guard/reports/summary.svg b/resumable-upload-checkpoint-guard/reports/summary.svg
new file mode 100644
index 00000000..cff70e8c
--- /dev/null
+++ b/resumable-upload-checkpoint-guard/reports/summary.svg
@@ -0,0 +1,28 @@
+
diff --git a/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json b/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json
new file mode 100644
index 00000000..95dcc316
--- /dev/null
+++ b/resumable-upload-checkpoint-guard/reports/upload-checkpoint-packet.json
@@ -0,0 +1,149 @@
+[
+ {
+ "scenario": "missing-chunk-abort",
+ "uploadId": "upload-climate-parquet",
+ "generatedAt": "2026-05-22T14:10:00Z",
+ "expiresAt": "2026-05-23T00:00:00Z",
+ "artifactPath": "datasets/climate-observations.parquet",
+ "decision": "abort-and-reupload",
+ "integrityScore": 35,
+ "findings": [
+ {
+ "type": "missing-upload-chunk",
+ "severity": "critical",
+ "missingChunks": [
+ 2
+ ],
+ "message": "Upload checkpoint is missing chunk indexes 2"
+ },
+ {
+ "type": "upload-size-mismatch",
+ "severity": "major",
+ "expectedSizeBytes": 4096,
+ "receivedBytes": 3072,
+ "message": "Received 3072 bytes but artifact manifest expects 4096"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "abort_incomplete_checkpoint",
+ "target": "upload-climate-parquet",
+ "reason": "durable artifact commits require contiguous multipart coverage"
+ },
+ {
+ "type": "reconcile_upload_size",
+ "target": "upload-climate-parquet",
+ "reason": "chunk byte totals must match the artifact manifest"
+ }
+ ],
+ "summary": {
+ "expectedChunks": 4,
+ "receivedChunks": 3,
+ "coverage": 0.75,
+ "expectedSizeBytes": 4096,
+ "receivedBytes": 3072,
+ "severityCounts": {
+ "critical": 1,
+ "major": 1
+ }
+ }
+ },
+ {
+ "scenario": "checksum-hold",
+ "uploadId": "upload-notebook",
+ "generatedAt": "2026-05-22T14:10:00Z",
+ "expiresAt": "2026-05-23T00:00:00Z",
+ "artifactPath": "notebooks/reproduce.ipynb",
+ "decision": "hold-resume",
+ "integrityScore": 75,
+ "findings": [
+ {
+ "type": "chunk-checksum-mismatch",
+ "severity": "major",
+ "chunk": 1,
+ "declaredHash": "sha256:declared",
+ "observedHash": "sha256:observed",
+ "message": "Chunk 1 declares sha256:declared but observed sha256:observed"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "reupload_chunk",
+ "target": "upload-notebook:chunk-1",
+ "reason": "chunk checksum evidence must match before upload resume or artifact commit"
+ }
+ ],
+ "summary": {
+ "expectedChunks": 2,
+ "receivedChunks": 2,
+ "coverage": 1,
+ "expectedSizeBytes": 2048,
+ "receivedBytes": 2048,
+ "severityCounts": {
+ "major": 1
+ }
+ }
+ },
+ {
+ "scenario": "metadata-hold",
+ "uploadId": "upload-rna-counts",
+ "generatedAt": "2026-05-22T14:10:00Z",
+ "expiresAt": "2026-05-23T00:00:00Z",
+ "artifactPath": "datasets/rna-counts.csv",
+ "decision": "hold-metadata",
+ "integrityScore": 80,
+ "findings": [
+ {
+ "type": "missing-final-manifest-hash",
+ "severity": "metadata",
+ "message": "Artifact lacks final manifest hash"
+ },
+ {
+ "type": "missing-metadata-schema",
+ "severity": "metadata",
+ "message": "Artifact lacks DataCite/schema.org metadata schema evidence"
+ }
+ ],
+ "requiredActions": [
+ {
+ "type": "record_final_manifest_hash",
+ "target": "datasets/rna-counts.csv",
+ "reason": "durable commits need a stable manifest hash for replay and DOI metadata"
+ },
+ {
+ "type": "attach_metadata_schema",
+ "target": "datasets/rna-counts.csv",
+ "reason": "hosted research artifacts need machine-readable metadata before commit"
+ }
+ ],
+ "summary": {
+ "expectedChunks": 1,
+ "receivedChunks": 1,
+ "coverage": 1,
+ "expectedSizeBytes": 1000,
+ "receivedBytes": 1000,
+ "severityCounts": {
+ "metadata": 2
+ }
+ }
+ },
+ {
+ "scenario": "clean-upload",
+ "uploadId": "upload-clean-supplement",
+ "generatedAt": "2026-05-22T14:10:00Z",
+ "expiresAt": "2026-05-23T00:00:00Z",
+ "artifactPath": "supplements/source-data.zip",
+ "decision": "commit-artifact",
+ "integrityScore": 100,
+ "findings": [],
+ "requiredActions": [],
+ "summary": {
+ "expectedChunks": 3,
+ "receivedChunks": 3,
+ "coverage": 1,
+ "expectedSizeBytes": 3072,
+ "receivedBytes": 3072,
+ "severityCounts": {}
+ }
+ }
+]
diff --git a/resumable-upload-checkpoint-guard/sample-data.js b/resumable-upload-checkpoint-guard/sample-data.js
new file mode 100644
index 00000000..052c5700
--- /dev/null
+++ b/resumable-upload-checkpoint-guard/sample-data.js
@@ -0,0 +1,73 @@
+const scenarios = [
+ {
+ name: 'missing-chunk-abort',
+ uploadId: 'upload-climate-parquet',
+ generatedAt: '2026-05-22T14:10:00Z',
+ expiresAt: '2026-05-23T00:00:00Z',
+ artifact: {
+ path: 'datasets/climate-observations.parquet',
+ expectedChunks: 4,
+ expectedSizeBytes: 4096,
+ finalManifestHash: 'sha256:manifest-ok',
+ metadataSchema: 'DataCite-4.5',
+ },
+ chunks: [
+ {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'},
+ {index: 1, sizeBytes: 1024, declaredHash: 'sha256:c1', observedHash: 'sha256:c1'},
+ {index: 3, sizeBytes: 1024, declaredHash: 'sha256:c3', observedHash: 'sha256:c3'},
+ ],
+ },
+ {
+ name: 'checksum-hold',
+ uploadId: 'upload-notebook',
+ generatedAt: '2026-05-22T14:10:00Z',
+ expiresAt: '2026-05-23T00:00:00Z',
+ artifact: {
+ path: 'notebooks/reproduce.ipynb',
+ expectedChunks: 2,
+ expectedSizeBytes: 2048,
+ finalManifestHash: 'sha256:manifest-ok',
+ metadataSchema: 'schema.org/Dataset',
+ },
+ chunks: [
+ {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'},
+ {index: 1, sizeBytes: 1024, declaredHash: 'sha256:declared', observedHash: 'sha256:observed'},
+ ],
+ },
+ {
+ name: 'metadata-hold',
+ uploadId: 'upload-rna-counts',
+ generatedAt: '2026-05-22T14:10:00Z',
+ expiresAt: '2026-05-23T00:00:00Z',
+ artifact: {
+ path: 'datasets/rna-counts.csv',
+ expectedChunks: 1,
+ expectedSizeBytes: 1000,
+ finalManifestHash: '',
+ metadataSchema: '',
+ },
+ chunks: [
+ {index: 0, sizeBytes: 1000, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'},
+ ],
+ },
+ {
+ name: 'clean-upload',
+ uploadId: 'upload-clean-supplement',
+ generatedAt: '2026-05-22T14:10:00Z',
+ expiresAt: '2026-05-23T00:00:00Z',
+ artifact: {
+ path: 'supplements/source-data.zip',
+ expectedChunks: 3,
+ expectedSizeBytes: 3072,
+ finalManifestHash: 'sha256:manifest-clean',
+ metadataSchema: 'DataCite-4.5',
+ },
+ chunks: [
+ {index: 0, sizeBytes: 1024, declaredHash: 'sha256:a', observedHash: 'sha256:a'},
+ {index: 1, sizeBytes: 1024, declaredHash: 'sha256:b', observedHash: 'sha256:b'},
+ {index: 2, sizeBytes: 1024, declaredHash: 'sha256:c', observedHash: 'sha256:c'},
+ ],
+ },
+];
+
+module.exports = {scenarios};
diff --git a/resumable-upload-checkpoint-guard/test.js b/resumable-upload-checkpoint-guard/test.js
new file mode 100644
index 00000000..b29143d0
--- /dev/null
+++ b/resumable-upload-checkpoint-guard/test.js
@@ -0,0 +1,112 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluateUploadCheckpoint,
+ buildCheckpointReport,
+} = require('./index');
+
+test('blocks commit when upload chunks are not contiguous', () => {
+ const result = evaluateUploadCheckpoint({
+ uploadId: 'upload-climate-parquet',
+ generatedAt: '2026-05-22T14:10:00Z',
+ expiresAt: '2026-05-23T00:00:00Z',
+ artifact: {
+ path: 'datasets/climate-observations.parquet',
+ expectedChunks: 4,
+ expectedSizeBytes: 4096,
+ finalManifestHash: 'sha256:manifest-ok',
+ metadataSchema: 'DataCite-4.5',
+ },
+ chunks: [
+ {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'},
+ {index: 1, sizeBytes: 1024, declaredHash: 'sha256:c1', observedHash: 'sha256:c1'},
+ {index: 3, sizeBytes: 1024, declaredHash: 'sha256:c3', observedHash: 'sha256:c3'},
+ ],
+ });
+
+ assert.equal(result.decision, 'abort-and-reupload');
+ assert.equal(result.findings[0].type, 'missing-upload-chunk');
+ assert.deepEqual(result.findings[0].missingChunks, [2]);
+ assert.equal(result.summary.receivedChunks, 3);
+});
+
+test('holds resume when a chunk checksum does not match checkpoint evidence', () => {
+ const result = evaluateUploadCheckpoint({
+ uploadId: 'upload-notebook',
+ generatedAt: '2026-05-22T14:10:00Z',
+ expiresAt: '2026-05-23T00:00:00Z',
+ artifact: {
+ path: 'notebooks/reproduce.ipynb',
+ expectedChunks: 2,
+ expectedSizeBytes: 2048,
+ finalManifestHash: 'sha256:manifest-ok',
+ metadataSchema: 'schema.org/Dataset',
+ },
+ chunks: [
+ {index: 0, sizeBytes: 1024, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'},
+ {index: 1, sizeBytes: 1024, declaredHash: 'sha256:declared', observedHash: 'sha256:observed'},
+ ],
+ });
+
+ assert.equal(result.decision, 'hold-resume');
+ assert.equal(result.findings.length, 1);
+ assert.equal(result.findings[0].type, 'chunk-checksum-mismatch');
+ assert.equal(result.requiredActions[0].type, 'reupload_chunk');
+});
+
+test('requires final manifest hash and metadata schema before durable artifact commit', () => {
+ const result = evaluateUploadCheckpoint({
+ uploadId: 'upload-rna-counts',
+ generatedAt: '2026-05-22T14:10:00Z',
+ expiresAt: '2026-05-23T00:00:00Z',
+ artifact: {
+ path: 'datasets/rna-counts.csv',
+ expectedChunks: 1,
+ expectedSizeBytes: 1000,
+ finalManifestHash: '',
+ metadataSchema: '',
+ },
+ chunks: [
+ {index: 0, sizeBytes: 1000, declaredHash: 'sha256:c0', observedHash: 'sha256:c0'},
+ ],
+ });
+
+ assert.equal(result.decision, 'hold-metadata');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ ['missing-final-manifest-hash', 'missing-metadata-schema']
+ );
+});
+
+test('approves complete checkpoint and builds deterministic reviewer report', () => {
+ const result = evaluateUploadCheckpoint({
+ uploadId: 'upload-clean-supplement',
+ generatedAt: '2026-05-22T14:10:00Z',
+ expiresAt: '2026-05-23T00:00:00Z',
+ artifact: {
+ path: 'supplements/source-data.zip',
+ expectedChunks: 3,
+ expectedSizeBytes: 3072,
+ finalManifestHash: 'sha256:manifest-clean',
+ metadataSchema: 'DataCite-4.5',
+ },
+ chunks: [
+ {index: 0, sizeBytes: 1024, declaredHash: 'sha256:a', observedHash: 'sha256:a'},
+ {index: 1, sizeBytes: 1024, declaredHash: 'sha256:b', observedHash: 'sha256:b'},
+ {index: 2, sizeBytes: 1024, declaredHash: 'sha256:c', observedHash: 'sha256:c'},
+ ],
+ });
+
+ assert.equal(result.decision, 'commit-artifact');
+ assert.equal(result.findings.length, 0);
+ assert.equal(result.integrityScore, 100);
+ assert.equal(result.summary.coverage, 1);
+
+ const report = buildCheckpointReport(result);
+ assert.match(report, /# Resumable Upload Checkpoint Guard Report/);
+ assert.match(report, /Upload: upload-clean-supplement/);
+ assert.match(report, /Decision: commit-artifact/);
+ assert.match(report, /Integrity score: 100/);
+ assert.match(report, /Findings: 0/);
+});