diff --git a/artifact-replica-consistency-guard/README.md b/artifact-replica-consistency-guard/README.md new file mode 100644 index 00000000..42e4ae45 --- /dev/null +++ b/artifact-replica-consistency-guard/README.md @@ -0,0 +1,32 @@ +# Artifact Replica Consistency Guard + +Self-contained reviewer module for issue #14, Scientific/Engineering Data & Code Hosting. + +The guard validates hosted artifact replicas before persistent links, previews, export bundles, or reproduce buttons are enabled. It focuses on storage-mirror consistency for datasets, notebooks, model weights, and other scientific artifacts. + +## What It Does + +- Compares replica checksums against the canonical artifact manifest. +- Verifies manifest version alignment across primary storage, archive copies, institutional mirrors, and public landing pages. +- Detects access-policy mismatches that could expose controlled data or hide open artifacts. +- Flags stale mirror verification and routes safe preview-only cases to repair review. +- Checks DataCite DOI and schema.org landing-page consistency before export/publication. +- Generates deterministic JSON, Markdown, SVG, and MP4 artifacts from synthetic fixtures only. + +## Files + +- `index.js` - dependency-free replica evaluator and Markdown packet builder. +- `sample-data.js` - synthetic artifacts for held, repair-review, and publish decisions. +- `test.js` - Node tests for blocking, review-only, and publication-ready replica states. +- `demo.js` - report generator and optional MP4 artifact writer. +- `requirements-map.md` - issue requirement mapping. +- `reports/` - generated reviewer artifacts. + +## Run + +```bash +node --test artifact-replica-consistency-guard/test.js +FFMPEG_PATH=/path/to/ffmpeg node artifact-replica-consistency-guard/demo.js +``` + +No storage provider, DOI registry, external mirror, credential, private artifact, or network call is used. diff --git a/artifact-replica-consistency-guard/demo.js b/artifact-replica-consistency-guard/demo.js new file mode 100644 index 00000000..a3ec4ed3 --- /dev/null +++ b/artifact-replica-consistency-guard/demo.js @@ -0,0 +1,147 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const {spawnSync} = require('node:child_process'); + +const { + evaluateArtifactReplicaConsistency, + buildReplicaConsistencyPacket, +} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +const framesDir = path.join(reportsDir, 'frames'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateArtifactReplicaConsistency(scenario), +})); + +const decisionCounts = evaluations.reduce((counts, item) => { + counts[item.decision] = (counts[item.decision] || 0) + 1; + return counts; +}, {}); +const totals = evaluations.reduce( + (sum, item) => { + sum.findings += item.summary.findingCount; + sum.blocking += item.summary.blockingFindingCount; + sum.review += item.summary.reviewFindingCount; + sum.gated += item.gatedActions.length; + return sum; + }, + {findings: 0, blocking: 0, review: 0, gated: 0} +); + +const packetJson = JSON.stringify(evaluations, null, 2); +const reviewerPacket = evaluations.map(buildReplicaConsistencyPacket).join('\n---\n'); +const svg = ` + + Artifact Replica Consistency Guard + Checks hosted artifact mirrors before previews, exports, persistent links, and reproduce buttons are enabled + + + Held + ${decisionCounts['hold-artifact'] || 0} + + + + Repair Review + ${decisionCounts['repair-review'] || 0} + + + + Publish + ${decisionCounts['publish-artifact'] || 0} + + Findings: ${totals.findings} | Blocking: ${totals.blocking} | Review: ${totals.review} | Gated actions: ${totals.gated} + Checks: checksums, manifest versions, access policy parity, mirror freshness, DataCite and schema.org landing-page consistency + Synthetic artifact manifests only. No storage provider, DOI registry, external mirror, credential, or network calls. + +`; + +fs.writeFileSync(path.join(reportsDir, 'replica-consistency-packet.json'), `${packetJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'replica-consistency-review.md'), reviewerPacket); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) { + const [r, g, b] = color; + const height = Math.floor(buffer.length / (width * 3)); + const x1 = Math.min(width, x0 + rectWidth); + const y1 = Math.min(height, y0 + rectHeight); + for (let y = Math.max(0, y0); y < y1; y += 1) { + for (let x = Math.max(0, x0); x < x1; x += 1) { + const offset = (y * width + x) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; + } + } +} + +function writePpmFrame(filePath, frameIndex, frameCount) { + const width = 640; + const height = 360; + const buffer = Buffer.alloc(width * height * 3); + for (let i = 0; i < width * height; i += 1) { + buffer[i * 3] = 23; + buffer[i * 3 + 1] = 32; + buffer[i * 3 + 2] = 42; + } + + const progress = frameIndex / Math.max(1, frameCount - 1); + fillRect(buffer, width, 42, 42, 556, 42, [248, 250, 252]); + fillRect(buffer, width, 42, 112, 150, 118, [127, 29, 29]); + fillRect(buffer, width, 245, 112, 150, 118, [133, 77, 14]); + fillRect(buffer, width, 448, 112, 150, 118, [22, 101, 52]); + fillRect(buffer, width, 78, 260, 112, 30, [248, 113, 113]); + fillRect(buffer, width, 264, 260, 112, 30, [251, 191, 36]); + fillRect(buffer, width, 450, 260, 112, 30, [74, 222, 128]); + fillRect(buffer, width, 42, 318, Math.round(556 * progress), 18, [191, 219, 254]); + fillRect(buffer, width, 42 + Math.round(508 * progress), 309, 48, 36, [241, 245, 249]); + + const header = Buffer.from(`P6\n${width} ${height}\n255\n`); + fs.writeFileSync(filePath, Buffer.concat([header, buffer])); +} + +function createDemoVideo() { + const ffmpegPath = process.env.FFMPEG_PATH; + if (!ffmpegPath) { + console.log('FFMPEG_PATH not set; skipped MP4 generation.'); + return; + } + + fs.rmSync(framesDir, {recursive: true, force: true}); + fs.mkdirSync(framesDir, {recursive: true}); + const frameCount = 72; + for (let index = 0; index < frameCount; index += 1) { + writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount); + } + + const output = path.join(reportsDir, 'demo.mp4'); + const result = spawnSync(ffmpegPath, [ + '-y', + '-framerate', + '24', + '-i', + path.join(framesDir, 'frame-%03d.ppm'), + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + output, + ], {encoding: 'utf8'}); + + fs.rmSync(framesDir, {recursive: true, force: true}); + if (result.status !== 0) { + throw new Error(result.stderr || 'ffmpeg failed'); + } +} + +createDemoVideo(); + +console.log(JSON.stringify({ + scenarios: evaluations.length, + reportsDir, + decisions: decisionCounts, + totals, +}, null, 2)); diff --git a/artifact-replica-consistency-guard/index.js b/artifact-replica-consistency-guard/index.js new file mode 100644 index 00000000..d060b75d --- /dev/null +++ b/artifact-replica-consistency-guard/index.js @@ -0,0 +1,262 @@ +function list(value) { + return Array.isArray(value) ? value : []; +} + +function clean(value) { + return String(value || '').trim(); +} + +function normalize(value) { + return clean(value).toLowerCase(); +} + +function hoursBetween(older, newer) { + const olderTime = Date.parse(older); + const newerTime = Date.parse(newer); + if (!Number.isFinite(olderTime) || !Number.isFinite(newerTime)) { + return Infinity; + } + return Math.floor((newerTime - olderTime) / 3600000); +} + +function finding(type, severity, target, message) { + return {type, severity, target, message}; +} + +function action(type, target, reason) { + return {type, target, reason}; +} + +function compareField(input, findings, repairPlan, field, type, label) { + const canonicalValue = normalize(input.canonical?.[field]); + for (const replica of list(input.replicas)) { + const replicaValue = normalize(replica[field]); + if (canonicalValue && replicaValue && canonicalValue !== replicaValue) { + findings.push( + finding( + type, + 'block', + replica.id || 'unknown-replica', + `${replica.id || 'Replica'} ${label} ${replica[field]} does not match canonical ${input.canonical[field]}.` + ) + ); + repairPlan.push( + action( + `repair-${field}`, + replica.id || 'unknown-replica', + `Reconcile ${replica.id || 'replica'} ${label} against canonical manifest before exposing durable artifact actions.` + ) + ); + } + } +} + +function validateReplicaFreshness(input, findings, repairPlan) { + const maxLag = Number.isFinite(input.maxMirrorLagHours) ? input.maxMirrorLagHours : 168; + for (const replica of list(input.replicas)) { + const age = hoursBetween(replica.lastVerifiedAt, input.generatedAt); + if (age > maxLag) { + const severity = normalize(replica.tier) === 'mirror' ? 'review' : 'block'; + findings.push( + finding( + 'replica-verification-stale', + severity, + replica.id || 'unknown-replica', + `${replica.id || 'Replica'} was last verified ${age} hours ago, above the ${maxLag}-hour freshness threshold.` + ) + ); + repairPlan.push( + action( + 'refresh-replica-verification', + replica.id || 'unknown-replica', + `Refresh checksum and manifest verification for ${replica.id || 'replica'} before export or reproduce actions continue.` + ) + ); + } + } +} + +function validateLandingPage(input, findings, repairPlan) { + const landing = input.landingPage || {}; + const canonical = input.canonical || {}; + if (normalize(landing.dataciteDoi) !== normalize(canonical.dataciteDoi)) { + findings.push( + finding( + 'landing-page-datacite-mismatch', + 'block', + 'landing-page', + `Landing page DataCite DOI ${landing.dataciteDoi || 'missing'} does not match canonical DOI ${canonical.dataciteDoi || 'missing'}.` + ) + ); + repairPlan.push( + action( + 'repair-landing-page-datacite', + 'landing-page', + 'Update landing page DataCite metadata before persistent links or exports are enabled.' + ) + ); + } + if (normalize(landing.schemaOrgUrl) !== normalize(canonical.schemaOrgUrl)) { + findings.push( + finding( + 'landing-page-schemaorg-mismatch', + 'block', + 'landing-page', + `Landing page schema.org URL ${landing.schemaOrgUrl || 'missing'} does not match canonical URL ${canonical.schemaOrgUrl || 'missing'}.` + ) + ); + repairPlan.push( + action( + 'repair-landing-page-schemaorg', + 'landing-page', + 'Update schema.org URL metadata before discovery crawlers see this artifact.' + ) + ); + } + if (normalize(landing.accessPolicy) !== normalize(canonical.accessPolicy)) { + findings.push( + finding( + 'landing-page-access-policy-mismatch', + 'block', + 'landing-page', + `Landing page access policy ${landing.accessPolicy || 'missing'} does not match canonical policy ${canonical.accessPolicy || 'missing'}.` + ) + ); + repairPlan.push( + action( + 'repair-landing-page-access-policy', + 'landing-page', + 'Align landing page access policy with canonical storage policy before public previews are trusted.' + ) + ); + } +} + +function summarize(findings) { + const blockingFindingCount = findings.filter((item) => item.severity === 'block').length; + const reviewFindingCount = findings.filter((item) => item.severity !== 'block').length; + return { + findingCount: findings.length, + blockingFindingCount, + reviewFindingCount, + }; +} + +function decideVisibleActions(input, decision, findings) { + const requested = list(input.actionsRequested); + if (decision === 'publish-artifact') { + return {visibleActions: requested, gatedActions: []}; + } + if (decision === 'hold-artifact') { + return {visibleActions: [], gatedActions: requested}; + } + const hasOnlyFreshnessReview = findings.every((item) => item.type === 'replica-verification-stale' && item.severity === 'review'); + if (hasOnlyFreshnessReview) { + return { + visibleActions: requested.filter((item) => item === 'enable-preview'), + gatedActions: requested.filter((item) => item !== 'enable-preview'), + }; + } + return { + visibleActions: [], + gatedActions: requested, + }; +} + +function evaluateArtifactReplicaConsistency(input = {}) { + const generatedAt = input.generatedAt || new Date(0).toISOString(); + const normalizedInput = {...input, generatedAt}; + const findings = []; + const repairPlan = []; + + compareField(normalizedInput, findings, repairPlan, 'checksum', 'replica-checksum-mismatch', 'checksum'); + compareField(normalizedInput, findings, repairPlan, 'manifestVersion', 'manifest-version-mismatch', 'manifest version'); + compareField(normalizedInput, findings, repairPlan, 'accessPolicy', 'access-policy-mismatch', 'access policy'); + validateReplicaFreshness(normalizedInput, findings, repairPlan); + validateLandingPage(normalizedInput, findings, repairPlan); + + const summary = summarize(findings); + const decision = summary.blockingFindingCount > 0 + ? 'hold-artifact' + : summary.reviewFindingCount > 0 + ? 'repair-review' + : 'publish-artifact'; + const {visibleActions, gatedActions} = decideVisibleActions(normalizedInput, decision, findings); + + return { + generatedAt, + artifactId: input.artifactId || 'unknown-artifact', + artifactType: input.artifactType || 'unknown-type', + decision, + summary, + findings, + repairPlan, + visibleActions, + gatedActions, + publicationPacket: { + artifactId: input.artifactId || 'unknown-artifact', + artifactType: input.artifactType || 'unknown-type', + decision, + dataciteDoi: input.canonical?.dataciteDoi || '', + schemaOrgUrl: input.canonical?.schemaOrgUrl || '', + replicaCount: list(input.replicas).length, + gatedActions, + visibleActions, + }, + }; +} + +function buildReplicaConsistencyPacket(result) { + const lines = [ + `# Artifact Replica Consistency Guard: ${result.artifactId}`, + '', + `Artifact type: ${result.artifactType}`, + `Decision: ${result.decision}`, + `Generated: ${result.generatedAt}`, + `Replicas: ${result.publicationPacket.replicaCount}`, + '', + '## Visible Actions', + ]; + + if (result.visibleActions.length === 0) { + lines.push('- None'); + } else { + for (const item of result.visibleActions) { + lines.push(`- ${item}`); + } + } + + lines.push('', '## Gated Actions'); + if (result.gatedActions.length === 0) { + lines.push('- None'); + } else { + for (const item of result.gatedActions) { + lines.push(`- ${item}`); + } + } + + lines.push('', '## Findings'); + if (result.findings.length === 0) { + lines.push('- None'); + } else { + for (const item of result.findings) { + lines.push(`- ${item.type}: ${item.target} - ${item.message}`); + } + } + + lines.push('', '## Repair Plan'); + if (result.repairPlan.length === 0) { + lines.push('- No repair action required'); + } else { + for (const item of result.repairPlan) { + lines.push(`- ${item.type}: ${item.target} - ${item.reason}`); + } + } + + return `${lines.join('\n')}\n`; +} + +module.exports = { + evaluateArtifactReplicaConsistency, + buildReplicaConsistencyPacket, +}; diff --git a/artifact-replica-consistency-guard/reports/demo.mp4 b/artifact-replica-consistency-guard/reports/demo.mp4 new file mode 100644 index 00000000..d8d69958 Binary files /dev/null and b/artifact-replica-consistency-guard/reports/demo.mp4 differ diff --git a/artifact-replica-consistency-guard/reports/replica-consistency-packet.json b/artifact-replica-consistency-guard/reports/replica-consistency-packet.json new file mode 100644 index 00000000..4019dd48 --- /dev/null +++ b/artifact-replica-consistency-guard/reports/replica-consistency-packet.json @@ -0,0 +1,185 @@ +[ + { + "scenario": "Controlled dataset has drifted archive and public landing-page policy", + "generatedAt": "2026-05-22T18:00:00Z", + "artifactId": "dataset-cellatlas-v4", + "artifactType": "dataset", + "decision": "hold-artifact", + "summary": { + "findingCount": 6, + "blockingFindingCount": 5, + "reviewFindingCount": 1 + }, + "findings": [ + { + "type": "replica-checksum-mismatch", + "severity": "block", + "target": "cold-archive", + "message": "cold-archive checksum sha256:archive-222 does not match canonical sha256:canonical-111." + }, + { + "type": "manifest-version-mismatch", + "severity": "block", + "target": "cold-archive", + "message": "cold-archive manifest version 4.1.0 does not match canonical 4.2.0." + }, + { + "type": "access-policy-mismatch", + "severity": "block", + "target": "institutional-mirror", + "message": "institutional-mirror access policy public does not match canonical controlled." + }, + { + "type": "replica-verification-stale", + "severity": "review", + "target": "institutional-mirror", + "message": "institutional-mirror was last verified 288 hours ago, above the 168-hour freshness threshold." + }, + { + "type": "landing-page-datacite-mismatch", + "severity": "block", + "target": "landing-page", + "message": "Landing page DataCite DOI 10.1234/cellatlas.v3 does not match canonical DOI 10.1234/cellatlas.v4." + }, + { + "type": "landing-page-access-policy-mismatch", + "severity": "block", + "target": "landing-page", + "message": "Landing page access policy public does not match canonical policy controlled." + } + ], + "repairPlan": [ + { + "type": "repair-checksum", + "target": "cold-archive", + "reason": "Reconcile cold-archive checksum against canonical manifest before exposing durable artifact actions." + }, + { + "type": "repair-manifestVersion", + "target": "cold-archive", + "reason": "Reconcile cold-archive manifest version against canonical manifest before exposing durable artifact actions." + }, + { + "type": "repair-accessPolicy", + "target": "institutional-mirror", + "reason": "Reconcile institutional-mirror access policy against canonical manifest before exposing durable artifact actions." + }, + { + "type": "refresh-replica-verification", + "target": "institutional-mirror", + "reason": "Refresh checksum and manifest verification for institutional-mirror before export or reproduce actions continue." + }, + { + "type": "repair-landing-page-datacite", + "target": "landing-page", + "reason": "Update landing page DataCite metadata before persistent links or exports are enabled." + }, + { + "type": "repair-landing-page-access-policy", + "target": "landing-page", + "reason": "Align landing page access policy with canonical storage policy before public previews are trusted." + } + ], + "visibleActions": [], + "gatedActions": [ + "enable-preview", + "enable-export", + "enable-reproduce" + ], + "publicationPacket": { + "artifactId": "dataset-cellatlas-v4", + "artifactType": "dataset", + "decision": "hold-artifact", + "dataciteDoi": "10.1234/cellatlas.v4", + "schemaOrgUrl": "https://scibase.example/artifacts/dataset-cellatlas-v4", + "replicaCount": 3, + "gatedActions": [ + "enable-preview", + "enable-export", + "enable-reproduce" + ], + "visibleActions": [] + } + }, + { + "scenario": "Notebook mirror lag needs repair review", + "generatedAt": "2026-05-22T18:10:00Z", + "artifactId": "notebook-rerun-pack", + "artifactType": "notebook", + "decision": "repair-review", + "summary": { + "findingCount": 1, + "blockingFindingCount": 0, + "reviewFindingCount": 1 + }, + "findings": [ + { + "type": "replica-verification-stale", + "severity": "review", + "target": "public-mirror", + "message": "public-mirror was last verified 73 hours ago, above the 48-hour freshness threshold." + } + ], + "repairPlan": [ + { + "type": "refresh-replica-verification", + "target": "public-mirror", + "reason": "Refresh checksum and manifest verification for public-mirror before export or reproduce actions continue." + } + ], + "visibleActions": [ + "enable-preview" + ], + "gatedActions": [ + "enable-export" + ], + "publicationPacket": { + "artifactId": "notebook-rerun-pack", + "artifactType": "notebook", + "decision": "repair-review", + "dataciteDoi": "10.1234/notebook.rerun", + "schemaOrgUrl": "https://scibase.example/artifacts/notebook-rerun-pack", + "replicaCount": 2, + "gatedActions": [ + "enable-export" + ], + "visibleActions": [ + "enable-preview" + ] + } + }, + { + "scenario": "Model capsule replicas are ready for publication", + "generatedAt": "2026-05-22T18:20:00Z", + "artifactId": "model-weight-capsule", + "artifactType": "model", + "decision": "publish-artifact", + "summary": { + "findingCount": 0, + "blockingFindingCount": 0, + "reviewFindingCount": 0 + }, + "findings": [], + "repairPlan": [], + "visibleActions": [ + "enable-preview", + "enable-export", + "enable-reproduce" + ], + "gatedActions": [], + "publicationPacket": { + "artifactId": "model-weight-capsule", + "artifactType": "model", + "decision": "publish-artifact", + "dataciteDoi": "10.1234/model.weight", + "schemaOrgUrl": "https://scibase.example/artifacts/model-weight-capsule", + "replicaCount": 3, + "gatedActions": [], + "visibleActions": [ + "enable-preview", + "enable-export", + "enable-reproduce" + ] + } + } +] diff --git a/artifact-replica-consistency-guard/reports/replica-consistency-review.md b/artifact-replica-consistency-guard/reports/replica-consistency-review.md new file mode 100644 index 00000000..5330e6d0 --- /dev/null +++ b/artifact-replica-consistency-guard/reports/replica-consistency-review.md @@ -0,0 +1,72 @@ +# Artifact Replica Consistency Guard: dataset-cellatlas-v4 + +Artifact type: dataset +Decision: hold-artifact +Generated: 2026-05-22T18:00:00Z +Replicas: 3 + +## Visible Actions +- None + +## Gated Actions +- enable-preview +- enable-export +- enable-reproduce + +## Findings +- replica-checksum-mismatch: cold-archive - cold-archive checksum sha256:archive-222 does not match canonical sha256:canonical-111. +- manifest-version-mismatch: cold-archive - cold-archive manifest version 4.1.0 does not match canonical 4.2.0. +- access-policy-mismatch: institutional-mirror - institutional-mirror access policy public does not match canonical controlled. +- replica-verification-stale: institutional-mirror - institutional-mirror was last verified 288 hours ago, above the 168-hour freshness threshold. +- landing-page-datacite-mismatch: landing-page - Landing page DataCite DOI 10.1234/cellatlas.v3 does not match canonical DOI 10.1234/cellatlas.v4. +- landing-page-access-policy-mismatch: landing-page - Landing page access policy public does not match canonical policy controlled. + +## Repair Plan +- repair-checksum: cold-archive - Reconcile cold-archive checksum against canonical manifest before exposing durable artifact actions. +- repair-manifestVersion: cold-archive - Reconcile cold-archive manifest version against canonical manifest before exposing durable artifact actions. +- repair-accessPolicy: institutional-mirror - Reconcile institutional-mirror access policy against canonical manifest before exposing durable artifact actions. +- refresh-replica-verification: institutional-mirror - Refresh checksum and manifest verification for institutional-mirror before export or reproduce actions continue. +- repair-landing-page-datacite: landing-page - Update landing page DataCite metadata before persistent links or exports are enabled. +- repair-landing-page-access-policy: landing-page - Align landing page access policy with canonical storage policy before public previews are trusted. + +--- +# Artifact Replica Consistency Guard: notebook-rerun-pack + +Artifact type: notebook +Decision: repair-review +Generated: 2026-05-22T18:10:00Z +Replicas: 2 + +## Visible Actions +- enable-preview + +## Gated Actions +- enable-export + +## Findings +- replica-verification-stale: public-mirror - public-mirror was last verified 73 hours ago, above the 48-hour freshness threshold. + +## Repair Plan +- refresh-replica-verification: public-mirror - Refresh checksum and manifest verification for public-mirror before export or reproduce actions continue. + +--- +# Artifact Replica Consistency Guard: model-weight-capsule + +Artifact type: model +Decision: publish-artifact +Generated: 2026-05-22T18:20:00Z +Replicas: 3 + +## Visible Actions +- enable-preview +- enable-export +- enable-reproduce + +## Gated Actions +- None + +## Findings +- None + +## Repair Plan +- No repair action required diff --git a/artifact-replica-consistency-guard/reports/summary.svg b/artifact-replica-consistency-guard/reports/summary.svg new file mode 100644 index 00000000..531f1488 --- /dev/null +++ b/artifact-replica-consistency-guard/reports/summary.svg @@ -0,0 +1,23 @@ + + + Artifact Replica Consistency Guard + Checks hosted artifact mirrors before previews, exports, persistent links, and reproduce buttons are enabled + + + Held + 1 + + + + Repair Review + 1 + + + + Publish + 1 + + Findings: 7 | Blocking: 5 | Review: 2 | Gated actions: 4 + Checks: checksums, manifest versions, access policy parity, mirror freshness, DataCite and schema.org landing-page consistency + Synthetic artifact manifests only. No storage provider, DOI registry, external mirror, credential, or network calls. + diff --git a/artifact-replica-consistency-guard/requirements-map.md b/artifact-replica-consistency-guard/requirements-map.md new file mode 100644 index 00000000..aa0d4d64 --- /dev/null +++ b/artifact-replica-consistency-guard/requirements-map.md @@ -0,0 +1,19 @@ +# Requirements Map + +Issue #14 asks for scalable storage, structured metadata, FAIR compliance, executable environments, previews, persistent access, and versioned artifacts. This slice focuses on replica consistency as a storage safety layer. + +| Issue requirement | Implementation coverage | +| --- | --- | +| Scalable storage engine | `replicas[]` models primary storage, cold archive, institutional mirror, and public mirror states. | +| Major artifact types | Synthetic scenarios cover datasets, notebooks, and model capsules. | +| Metadata-aware previews | `actionsRequested` and `visibleActions` decide when previews are safe despite mirror repair needs. | +| Upload versioning and diffing | `manifestVersion` comparison catches replica drift before versions are exposed. | +| JSON-LD/DataCite/schema.org readiness | `landingPage` checks validate DataCite DOI and schema.org URL alignment against canonical metadata. | +| FAIR accessibility and reusability | Access-policy parity checks protect persistent links, previews, exports, and reproduce actions. | +| Executable environments and reproduce buttons | `enable-reproduce` is gated when replica evidence is inconsistent. | +| Reviewer-ready demo | `demo.js` writes JSON, Markdown, SVG, and H.264 MP4 artifacts in `reports/`. | +| Safe local verification | `test.js` covers hold, repair-review, and publish outcomes with no external calls or secrets. | + +## Distinctness + +This module avoids duplicating existing issue #14 slices by staying narrow: it does not implement a broad FAIR manifest, access/compute governance, executable-environment drift, provenance chain, quarantine/rerun guard, storage quota/dedupe ledger, resumable upload checkpoint guard, artifact package integrity gate, preview cache guard, raw-instrument preview gate, notebook preview gate, retention/tombstone ledger, model-card lineage gate, license compatibility gate, sensitive-redaction gate, schema-evolution checker, data dictionary release gate, persistent-ID guard, or SBOM quarantine gate. It validates whether already-hosted replicas agree enough to safely expose artifact actions. diff --git a/artifact-replica-consistency-guard/sample-data.js b/artifact-replica-consistency-guard/sample-data.js new file mode 100644 index 00000000..ac12d2f1 --- /dev/null +++ b/artifact-replica-consistency-guard/sample-data.js @@ -0,0 +1,76 @@ +const scenarios = [ + { + name: 'Controlled dataset has drifted archive and public landing-page policy', + generatedAt: '2026-05-22T18:00:00Z', + artifactId: 'dataset-cellatlas-v4', + artifactType: 'dataset', + canonical: { + checksum: 'sha256:canonical-111', + manifestVersion: '4.2.0', + accessPolicy: 'controlled', + dataciteDoi: '10.1234/cellatlas.v4', + schemaOrgUrl: 'https://scibase.example/artifacts/dataset-cellatlas-v4', + }, + replicas: [ + {id: 'primary-object-store', tier: 'primary', checksum: 'sha256:canonical-111', manifestVersion: '4.2.0', accessPolicy: 'controlled', lastVerifiedAt: '2026-05-21T18:00:00Z'}, + {id: 'cold-archive', tier: 'archive', checksum: 'sha256:archive-222', manifestVersion: '4.1.0', accessPolicy: 'controlled', lastVerifiedAt: '2026-05-18T18:00:00Z'}, + {id: 'institutional-mirror', tier: 'mirror', checksum: 'sha256:canonical-111', manifestVersion: '4.2.0', accessPolicy: 'public', lastVerifiedAt: '2026-05-10T18:00:00Z'}, + ], + landingPage: { + dataciteDoi: '10.1234/cellatlas.v3', + schemaOrgUrl: 'https://scibase.example/artifacts/dataset-cellatlas-v4', + accessPolicy: 'public', + }, + actionsRequested: ['enable-preview', 'enable-export', 'enable-reproduce'], + }, + { + name: 'Notebook mirror lag needs repair review', + generatedAt: '2026-05-22T18:10:00Z', + artifactId: 'notebook-rerun-pack', + artifactType: 'notebook', + maxMirrorLagHours: 48, + canonical: { + checksum: 'sha256:notebook-555', + manifestVersion: '2.0.1', + accessPolicy: 'public', + dataciteDoi: '10.1234/notebook.rerun', + schemaOrgUrl: 'https://scibase.example/artifacts/notebook-rerun-pack', + }, + replicas: [ + {id: 'primary-object-store', tier: 'primary', checksum: 'sha256:notebook-555', manifestVersion: '2.0.1', accessPolicy: 'public', lastVerifiedAt: '2026-05-22T17:00:00Z'}, + {id: 'public-mirror', tier: 'mirror', checksum: 'sha256:notebook-555', manifestVersion: '2.0.1', accessPolicy: 'public', lastVerifiedAt: '2026-05-19T17:00:00Z'}, + ], + landingPage: { + dataciteDoi: '10.1234/notebook.rerun', + schemaOrgUrl: 'https://scibase.example/artifacts/notebook-rerun-pack', + accessPolicy: 'public', + }, + actionsRequested: ['enable-preview', 'enable-export'], + }, + { + name: 'Model capsule replicas are ready for publication', + generatedAt: '2026-05-22T18:20:00Z', + artifactId: 'model-weight-capsule', + artifactType: 'model', + canonical: { + checksum: 'sha256:model-777', + manifestVersion: '1.3.0', + accessPolicy: 'open', + dataciteDoi: '10.1234/model.weight', + schemaOrgUrl: 'https://scibase.example/artifacts/model-weight-capsule', + }, + replicas: [ + {id: 'primary-object-store', tier: 'primary', checksum: 'sha256:model-777', manifestVersion: '1.3.0', accessPolicy: 'open', lastVerifiedAt: '2026-05-22T17:30:00Z'}, + {id: 'cold-archive', tier: 'archive', checksum: 'sha256:model-777', manifestVersion: '1.3.0', accessPolicy: 'open', lastVerifiedAt: '2026-05-22T14:30:00Z'}, + {id: 'institutional-mirror', tier: 'mirror', checksum: 'sha256:model-777', manifestVersion: '1.3.0', accessPolicy: 'open', lastVerifiedAt: '2026-05-22T16:45:00Z'}, + ], + landingPage: { + dataciteDoi: '10.1234/model.weight', + schemaOrgUrl: 'https://scibase.example/artifacts/model-weight-capsule', + accessPolicy: 'open', + }, + actionsRequested: ['enable-preview', 'enable-export', 'enable-reproduce'], + }, +]; + +module.exports = {scenarios}; diff --git a/artifact-replica-consistency-guard/test.js b/artifact-replica-consistency-guard/test.js new file mode 100644 index 00000000..643e1bac --- /dev/null +++ b/artifact-replica-consistency-guard/test.js @@ -0,0 +1,115 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateArtifactReplicaConsistency, + buildReplicaConsistencyPacket, +} = require('./index'); + +test('holds public access when replicas disagree on checksum, manifest version, and access policy', () => { + const result = evaluateArtifactReplicaConsistency({ + generatedAt: '2026-05-22T18:00:00Z', + artifactId: 'dataset-cellatlas-v4', + artifactType: 'dataset', + canonical: { + checksum: 'sha256:canonical-111', + manifestVersion: '4.2.0', + accessPolicy: 'controlled', + dataciteDoi: '10.1234/cellatlas.v4', + schemaOrgUrl: 'https://scibase.example/artifacts/dataset-cellatlas-v4', + }, + replicas: [ + {id: 'primary-object-store', tier: 'primary', checksum: 'sha256:canonical-111', manifestVersion: '4.2.0', accessPolicy: 'controlled', lastVerifiedAt: '2026-05-21T18:00:00Z'}, + {id: 'cold-archive', tier: 'archive', checksum: 'sha256:archive-222', manifestVersion: '4.1.0', accessPolicy: 'controlled', lastVerifiedAt: '2026-05-18T18:00:00Z'}, + {id: 'institutional-mirror', tier: 'mirror', checksum: 'sha256:canonical-111', manifestVersion: '4.2.0', accessPolicy: 'public', lastVerifiedAt: '2026-05-10T18:00:00Z'}, + ], + landingPage: { + dataciteDoi: '10.1234/cellatlas.v3', + schemaOrgUrl: 'https://scibase.example/artifacts/dataset-cellatlas-v4', + accessPolicy: 'public', + }, + actionsRequested: ['enable-preview', 'enable-export', 'enable-reproduce'], + }); + + assert.equal(result.decision, 'hold-artifact'); + assert.deepEqual( + result.findings.map((finding) => finding.type), + [ + 'replica-checksum-mismatch', + 'manifest-version-mismatch', + 'access-policy-mismatch', + 'replica-verification-stale', + 'landing-page-datacite-mismatch', + 'landing-page-access-policy-mismatch', + ] + ); + assert.equal(result.summary.blockingFindingCount, 5); + assert.equal(result.gatedActions.sort().join(','), 'enable-export,enable-preview,enable-reproduce'); + assert.match(result.repairPlan[0].reason, /cold-archive/); +}); + +test('routes stale mirror lag to repair review while keeping safe previews visible', () => { + const result = evaluateArtifactReplicaConsistency({ + generatedAt: '2026-05-22T18:10:00Z', + artifactId: 'notebook-rerun-pack', + artifactType: 'notebook', + maxMirrorLagHours: 48, + canonical: { + checksum: 'sha256:notebook-555', + manifestVersion: '2.0.1', + accessPolicy: 'public', + dataciteDoi: '10.1234/notebook.rerun', + schemaOrgUrl: 'https://scibase.example/artifacts/notebook-rerun-pack', + }, + replicas: [ + {id: 'primary-object-store', tier: 'primary', checksum: 'sha256:notebook-555', manifestVersion: '2.0.1', accessPolicy: 'public', lastVerifiedAt: '2026-05-22T17:00:00Z'}, + {id: 'public-mirror', tier: 'mirror', checksum: 'sha256:notebook-555', manifestVersion: '2.0.1', accessPolicy: 'public', lastVerifiedAt: '2026-05-19T17:00:00Z'}, + ], + landingPage: { + dataciteDoi: '10.1234/notebook.rerun', + schemaOrgUrl: 'https://scibase.example/artifacts/notebook-rerun-pack', + accessPolicy: 'public', + }, + actionsRequested: ['enable-preview', 'enable-export'], + }); + + assert.equal(result.decision, 'repair-review'); + assert.deepEqual(result.findings.map((finding) => finding.type), ['replica-verification-stale']); + assert.equal(result.summary.reviewFindingCount, 1); + assert.deepEqual(result.gatedActions, ['enable-export']); + assert.deepEqual(result.visibleActions, ['enable-preview']); +}); + +test('approves consistent replicas and builds a publication-ready packet', () => { + const result = evaluateArtifactReplicaConsistency({ + generatedAt: '2026-05-22T18:20:00Z', + artifactId: 'model-weight-capsule', + artifactType: 'model', + canonical: { + checksum: 'sha256:model-777', + manifestVersion: '1.3.0', + accessPolicy: 'open', + dataciteDoi: '10.1234/model.weight', + schemaOrgUrl: 'https://scibase.example/artifacts/model-weight-capsule', + }, + replicas: [ + {id: 'primary-object-store', tier: 'primary', checksum: 'sha256:model-777', manifestVersion: '1.3.0', accessPolicy: 'open', lastVerifiedAt: '2026-05-22T17:30:00Z'}, + {id: 'cold-archive', tier: 'archive', checksum: 'sha256:model-777', manifestVersion: '1.3.0', accessPolicy: 'open', lastVerifiedAt: '2026-05-22T14:30:00Z'}, + {id: 'institutional-mirror', tier: 'mirror', checksum: 'sha256:model-777', manifestVersion: '1.3.0', accessPolicy: 'open', lastVerifiedAt: '2026-05-22T16:45:00Z'}, + ], + landingPage: { + dataciteDoi: '10.1234/model.weight', + schemaOrgUrl: 'https://scibase.example/artifacts/model-weight-capsule', + accessPolicy: 'open', + }, + actionsRequested: ['enable-preview', 'enable-export', 'enable-reproduce'], + }); + const packet = buildReplicaConsistencyPacket(result); + + assert.equal(result.decision, 'publish-artifact'); + assert.equal(result.summary.findingCount, 0); + assert.deepEqual(result.gatedActions, []); + assert.deepEqual(result.visibleActions.sort(), ['enable-export', 'enable-preview', 'enable-reproduce']); + assert.match(packet, /model-weight-capsule/); + assert.match(packet, /No repair action required/); +});