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 = ` + + Resumable Upload Checkpoint Guard + Synthetic multipart upload safety packet for scientific artifacts + + + Commit + ${commit} + + + + Metadata + ${metadata} + + + + Resume Hold + ${resume} + + + + Abort + ${abort} + + Checks: contiguous chunks, checksum evidence, manifest hash, metadata schema, expiry + Reviewer findings: ${findings}. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No private datasets, credentials, storage provider calls, or network access. + +`; + +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 @@ + + + Resumable Upload Checkpoint Guard + Synthetic multipart upload safety packet for scientific artifacts + + + Commit + 1 + + + + Metadata + 1 + + + + Resume Hold + 1 + + + + Abort + 1 + + Checks: contiguous chunks, checksum evidence, manifest hash, metadata schema, expiry + Reviewer findings: 5. Outputs: JSON packet, Markdown report, SVG summary, MP4 artifact. + Synthetic data only. No private datasets, credentials, storage provider calls, or network access. + 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/); +});