diff --git a/repository-external-reference-pin-guard/.gitignore b/repository-external-reference-pin-guard/.gitignore new file mode 100644 index 00000000..eddc6484 --- /dev/null +++ b/repository-external-reference-pin-guard/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +reports/demo-frame.png +__pycache__/ +*.tmp diff --git a/repository-external-reference-pin-guard/README.md b/repository-external-reference-pin-guard/README.md new file mode 100644 index 00000000..6798860a --- /dev/null +++ b/repository-external-reference-pin-guard/README.md @@ -0,0 +1,32 @@ +# Repository External Reference Pin Guard + +Self-contained Project Repository & Version Control slice for issue #10. + +This module checks whether a scientific repository can safely publish a DOI, citation badge, API view, or export bundle when it depends on external references such as Git submodules, linked datasets, API snapshots, model weights, or external code/data pointers. + +## What It Checks + +- Git submodules and external code are pinned to immutable commit SHAs, rejecting null all-zero placeholders. +- Linked datasets and model weights have full-length SHA checksum, parseable DOI, or immutable version evidence; placeholders such as `pending` and floating aliases such as `latest` do not count. +- Supplied checksum and DOI metadata must be valid even when another durable identifier is present, so malformed evidence cannot slip into export or citation packets. +- The external-reference manifest itself must be an array, so object-shaped or missing reviewer data cannot be treated as a clean empty audit. +- Malformed external-reference entries create release-blocking repair actions instead of crashing assessment or disappearing from reviewer packets. +- API sources use parseable, non-future dated snapshots with full-length SHA checksum evidence instead of floating "latest" endpoints. +- Export bundles do not require authenticated external references. +- License and attribution metadata are present before DOI publication. +- Reference verification evidence is present, fresh enough for release, and not future-dated. + +## Commands + +```powershell +npm test +npm run demo +npm run video +npm run check +``` + +The demo writes deterministic JSON, Markdown, SVG, and MP4 reviewer artifacts under `reports/`. + +## Safety + +All records are synthetic. The module does not call external repositories, APIs, DOI registries, identity providers, storage systems, payment systems, or private research databases. diff --git a/repository-external-reference-pin-guard/acceptance-notes.md b/repository-external-reference-pin-guard/acceptance-notes.md new file mode 100644 index 00000000..7177cb8c --- /dev/null +++ b/repository-external-reference-pin-guard/acceptance-notes.md @@ -0,0 +1,34 @@ +# Acceptance Notes + +## Local Validation + +Run from `repository-external-reference-pin-guard/`: + +```powershell +npm test +npm run demo +npm run video +npm run check +``` + +Expected evidence: + +- `reports/blocked-packet.json` blocks repository release when external references are floating, authenticated only, stale, missing durable identifiers, or carrying malformed checksum/DOI evidence. +- All-zero Git commit placeholders are treated as unpinned references rather than immutable release evidence. +- Floating version aliases such as `latest` are blocked unless the reference also has checksum or DOI evidence. +- Invalid checksum placeholders such as `pending` do not count as durable identifier or API snapshot evidence and now produce explicit evidence-repair actions. +- Invalid DOI placeholders such as `pending` do not count as durable identifier evidence and now produce explicit evidence-repair actions. +- Truncated checksum values such as `sha256:abcdef` do not count as API snapshot evidence or export metadata even when another identifier is valid. +- Malformed object-shaped reference manifests produce `MALFORMED_REFERENCE_MANIFEST` blockers and `repair_reference_manifest:*` actions instead of being treated as empty clean audits. +- Malformed external-reference entries produce `MALFORMED_REFERENCE_ENTRY` blockers and `repair_reference_entry:*` actions instead of crashing or disappearing from reviewer packets. +- Future-dated API snapshots do not count as pinned snapshot evidence for DOI/export release. +- Otherwise pinned references without verification timestamps are blocked until verification evidence is refreshed. +- `reports/warning-packet.json` stages pinned references that still need license and attribution metadata. +- `reports/clean-packet.json` releases a repository with immutable external pins, fresh verification evidence, and exportable metadata. +- `reports/external-reference-report.md` summarizes lanes and finding codes. +- `reports/summary.svg` provides a visual reviewer packet. +- `reports/demo.mp4` is a short H.264 walkthrough generated from synthetic frames. + +## Safety Boundaries + +All records are synthetic. No external repositories, live APIs, DOI registries, object stores, credentials, private research data, identity providers, or payment systems are contacted. diff --git a/repository-external-reference-pin-guard/demo.js b/repository-external-reference-pin-guard/demo.js new file mode 100644 index 00000000..fd6c14e0 --- /dev/null +++ b/repository-external-reference-pin-guard/demo.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const path = require('path'); + +const { assessExternalReferences } = require('./index'); +const { + riskyRepository, + cleanRepository, + warningRepository, + malformedRepository, + malformedManifestRepository +} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const packets = [ + ['blocked-packet.json', assessExternalReferences(riskyRepository)], + ['malformed-manifest-packet.json', assessExternalReferences(malformedManifestRepository)], + ['malformed-packet.json', assessExternalReferences(malformedRepository)], + ['clean-packet.json', assessExternalReferences(cleanRepository)], + ['warning-packet.json', assessExternalReferences(warningRepository)] +]; + +for (const [fileName, packet] of packets) { + fs.writeFileSync(path.join(reportsDir, fileName), `${JSON.stringify(packet, null, 2)}\n`); + console.log(`${fileName}: ${packet.status}; findings=${packet.findings.length}; digest=${packet.auditDigest.slice(0, 12)}`); +} + +const markdown = [ + '# Repository External Reference Pin Guard Report', + '', + '| Packet | Status | DOI publication | Export bundle | API access | Findings |', + '| --- | --- | --- | --- | --- | --- |', + ...packets.map(([fileName, packet]) => `| ${fileName} | ${packet.status} | ${packet.releaseLanes.doiPublication} | ${packet.releaseLanes.exportBundle} | ${packet.releaseLanes.apiAccess} | ${packet.findings.map((finding) => finding.code).join(', ') || 'none'} |`), + '', + 'Synthetic data only. No external repositories, APIs, DOI registries, or private data sources are contacted.' +].join('\n'); +fs.writeFileSync(path.join(reportsDir, 'external-reference-report.md'), `${markdown}\n`); + +const rows = packets.map(([fileName, packet], index) => { + const y = 94 + index * 68; + const color = packet.status === 'hold_repository_release' ? '#b91c1c' : packet.status === 'stage_reference_metadata_revision' ? '#a16207' : '#047857'; + return ` + + + + ${packet.repositoryId} + ${fileName} | findings ${packet.findings.length} | digest ${packet.auditDigest.slice(0, 16)} + `; +}).join(''); + +const svgHeight = 408; +const svg = ` + + Repository External Reference Pin Guard + Issue #10 release/export gate for submodules, linked datasets, API sources, and model references +${rows} + +`; +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); diff --git a/repository-external-reference-pin-guard/index.js b/repository-external-reference-pin-guard/index.js new file mode 100644 index 00000000..ea4d621b --- /dev/null +++ b/repository-external-reference-pin-guard/index.js @@ -0,0 +1,312 @@ +const crypto = require('crypto'); + +const DEFAULT_POLICY = { + maxReferenceAgeDays: 180 +}; + +const CHECKSUM_HEX_LENGTHS = { + sha256: 64, + sha384: 96, + sha512: 128 +}; + +function assessExternalReferences(repository) { + const policy = { ...DEFAULT_POLICY, ...(repository.policy || {}) }; + const references = normalizeReferenceEntries(repository.references); + const findings = references + .flatMap((reference) => assessReference(reference, repository.assessedAt, policy)) + .sort(compareFindings); + + const blockerCount = findings.filter((finding) => finding.severity === 'blocker').length; + const warningCount = findings.filter((finding) => finding.severity === 'warning').length; + + const packet = { + repositoryId: repository.repositoryId, + status: chooseStatus(blockerCount, warningCount), + releaseLanes: chooseReleaseLanes(blockerCount, warningCount), + findings, + actions: buildActions(repository, findings), + referenceSignals: buildSignals(findings), + referenceSummary: summarizeReferences(references), + assessedAt: repository.assessedAt + }; + + packet.auditDigest = digestPacket(packet); + return packet; +} + +function assessReference(reference, assessedAt, policy) { + if (reference.__malformedReferenceManifest) { + return [ + finding(reference, 'MALFORMED_REFERENCE_MANIFEST', 'blocker', 'External reference manifest must be an array before release.') + ]; + } + + if (reference.__malformedReferenceEntry) { + return [ + finding(reference, 'MALFORMED_REFERENCE_ENTRY', 'blocker', 'External reference entry must be a structured object before release.') + ]; + } + + const findings = []; + + if (hasInvalidChecksumEvidence(reference)) { + findings.push(finding(reference, 'INVALID_CHECKSUM_EVIDENCE', 'blocker', 'Checksum evidence must use a supported algorithm and full-length hexadecimal digest.')); + } + + if (hasInvalidDoiEvidence(reference)) { + findings.push(finding(reference, 'INVALID_DOI_EVIDENCE', 'blocker', 'DOI evidence must be a parseable DOI or DOI URL before release.')); + } + + if (isGitReference(reference) && !hasPinnedCommit(reference)) { + findings.push(finding(reference, 'FLOATING_GIT_REFERENCE', 'blocker', 'Git reference must be pinned to an immutable commit SHA before release.')); + } + + if (reference.kind === 'api_source' && !hasSnapshotEvidence(reference, assessedAt)) { + findings.push(finding(reference, 'FLOATING_API_REFERENCE', 'blocker', 'API source must reference a dated snapshot with checksum evidence.')); + } + + if (needsDurableIdentifier(reference) && !hasDurableIdentifier(reference)) { + findings.push(finding(reference, 'MISSING_DURABLE_IDENTIFIER', 'blocker', 'External data or model reference needs a checksum, DOI, or immutable version.')); + } + + if (reference.authRequired) { + findings.push(finding(reference, 'AUTH_REQUIRED_REFERENCE', 'blocker', 'Export bundles cannot depend on authenticated external references.')); + } + + if (!hasText(reference.license)) { + findings.push(finding(reference, 'MISSING_LICENSE', 'warning', 'External reference needs license metadata before DOI publication.')); + } + + if (!hasText(reference.attribution)) { + findings.push(finding(reference, 'MISSING_ATTRIBUTION', 'warning', 'External reference needs attribution metadata before DOI publication.')); + } + + if (hasMissingVerificationEvidence(reference, findings)) { + findings.push(finding(reference, 'STALE_REFERENCE_EVIDENCE', 'blocker', 'External reference needs verification evidence before release.')); + } else if (isStale(reference.lastVerifiedAt, assessedAt, policy.maxReferenceAgeDays)) { + findings.push(finding(reference, 'STALE_REFERENCE_EVIDENCE', 'blocker', 'External reference verification is older than policy allows.')); + } + + return findings; +} + +function isGitReference(reference) { + return reference.kind === 'git_submodule' || reference.kind === 'external_code'; +} + +function hasPinnedCommit(reference) { + const commitSha = reference.commitSha || ''; + return /^[a-f0-9]{40}$/i.test(commitSha) && !/^0{40}$/.test(commitSha); +} + +function hasSnapshotEvidence(reference, assessedAt) { + if (!hasText(reference.snapshotDate) || !hasValidChecksum(reference.checksum)) return false; + const snapshot = Date.parse(reference.snapshotDate); + const assessed = Date.parse(assessedAt); + if (Number.isNaN(snapshot) || Number.isNaN(assessed)) return false; + return snapshot <= assessed; +} + +function needsDurableIdentifier(reference) { + return ['linked_dataset', 'model_weights', 'external_data'].includes(reference.kind); +} + +function normalizeReferenceEntries(value) { + if (!Array.isArray(value)) { + return [ + { + id: 'reference-manifest', + kind: 'unknown', + target: null, + __malformedReferenceManifest: true + } + ]; + } + + return value.map((reference, index) => { + if (isRecord(reference)) return reference; + + return { + id: `malformed-reference-entry-${index + 1}`, + kind: 'unknown', + target: null, + __malformedReferenceEntry: true + }; + }); +} + +function isRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + +function hasDurableIdentifier(reference) { + return hasValidChecksum(reference.checksum) || hasValidDoi(reference.doi) || hasImmutableVersion(reference.version); +} + +function hasValidChecksum(checksum) { + if (typeof checksum !== 'string') return false; + const match = checksum.trim().match(/^(sha256|sha384|sha512):([a-f0-9]+)$/i); + if (!match) return false; + + const algorithm = match[1].toLowerCase(); + return match[2].length === CHECKSUM_HEX_LENGTHS[algorithm]; +} + +function hasValidDoi(doi) { + if (!hasText(doi)) return false; + const normalized = doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//i, ''); + return /^10\.\d{4,9}\/\S+$/i.test(normalized); +} + +function hasInvalidChecksumEvidence(reference) { + return hasText(reference.checksum) && !hasValidChecksum(reference.checksum); +} + +function hasInvalidDoiEvidence(reference) { + return hasText(reference.doi) && !hasValidDoi(reference.doi); +} + +function hasImmutableVersion(version) { + if (!hasText(version)) return false; + return !/^(latest|main|master|head|current|stable|dev|nightly)$/i.test(version.trim()); +} + +function hasMissingVerificationEvidence(reference, findings) { + return !hasText(reference.lastVerifiedAt) + && !findings.some((item) => item.severity === 'blocker'); +} + +function isStale(lastVerifiedAt, assessedAt, maxAgeDays) { + if (!lastVerifiedAt || !assessedAt) return false; + const verified = Date.parse(lastVerifiedAt); + const assessed = Date.parse(assessedAt); + if (Number.isNaN(verified) || Number.isNaN(assessed)) return true; + if (verified > assessed) return true; + const ageDays = Math.max(0, (assessed - verified) / (24 * 60 * 60 * 1000)); + return ageDays > maxAgeDays; +} + +function chooseStatus(blockerCount, warningCount) { + if (blockerCount > 0) return 'hold_repository_release'; + if (warningCount > 0) return 'stage_reference_metadata_revision'; + return 'release_repository_references'; +} + +function chooseReleaseLanes(blockerCount, warningCount) { + if (blockerCount > 0) { + return { + doiPublication: 'blocked', + exportBundle: 'blocked', + apiAccess: 'metadata_only' + }; + } + + if (warningCount > 0) { + return { + doiPublication: 'metadata_revision', + exportBundle: 'draft_only', + apiAccess: 'allowed' + }; + } + + return { + doiPublication: 'allowed', + exportBundle: 'allowed', + apiAccess: 'allowed' + }; +} + +function buildActions(repository, findings) { + if (!findings.length) return [`release_with_reference_pin_monitoring:${repository.repositoryId}`]; + + const byReference = new Map(); + for (const item of findings) { + if (!byReference.has(item.referenceId)) byReference.set(item.referenceId, new Set()); + byReference.get(item.referenceId).add(item.code); + } + + const actions = new Set(); + for (const [referenceId, codes] of byReference.entries()) { + if (codes.has('MALFORMED_REFERENCE_MANIFEST')) actions.add(`repair_reference_manifest:${referenceId}`); + if (codes.has('MALFORMED_REFERENCE_ENTRY')) actions.add(`repair_reference_entry:${referenceId}`); + if (codes.has('AUTH_REQUIRED_REFERENCE')) actions.add(`replace_or_snapshot_auth_reference:${referenceId}`); + if (codes.has('FLOATING_GIT_REFERENCE') || codes.has('FLOATING_API_REFERENCE')) actions.add(`pin_external_reference:${referenceId}`); + if (codes.has('INVALID_CHECKSUM_EVIDENCE') || codes.has('INVALID_DOI_EVIDENCE')) actions.add(`repair_reference_evidence:${referenceId}`); + if (codes.has('MISSING_DURABLE_IDENTIFIER')) actions.add(`add_checksum_or_doi:${referenceId}`); + if (codes.has('STALE_REFERENCE_EVIDENCE')) actions.add(`refresh_reference_verification:${referenceId}`); + if (codes.has('MISSING_LICENSE') || codes.has('MISSING_ATTRIBUTION')) actions.add(`complete_license_attribution:${referenceId}`); + } + + return [...actions].sort(); +} + +function buildSignals(findings) { + const codes = new Set(findings.map((finding) => finding.code)); + return { + immutablePins: !codes.has('MALFORMED_REFERENCE_MANIFEST') + && !codes.has('MALFORMED_REFERENCE_ENTRY') + && !codes.has('FLOATING_GIT_REFERENCE') + && !codes.has('FLOATING_API_REFERENCE'), + exportable: !codes.has('MALFORMED_REFERENCE_MANIFEST') + && !codes.has('MALFORMED_REFERENCE_ENTRY') + && !codes.has('AUTH_REQUIRED_REFERENCE') + && !codes.has('MISSING_DURABLE_IDENTIFIER') + && !codes.has('INVALID_CHECKSUM_EVIDENCE') + && !codes.has('INVALID_DOI_EVIDENCE'), + attributionComplete: !codes.has('MALFORMED_REFERENCE_MANIFEST') + && !codes.has('MALFORMED_REFERENCE_ENTRY') + && !codes.has('MISSING_LICENSE') + && !codes.has('MISSING_ATTRIBUTION'), + verificationFresh: !codes.has('MALFORMED_REFERENCE_MANIFEST') + && !codes.has('MALFORMED_REFERENCE_ENTRY') + && !codes.has('STALE_REFERENCE_EVIDENCE') + }; +} + +function summarizeReferences(references = []) { + return references.reduce((summary, reference) => { + summary.total += 1; + summary.byKind[reference.kind] = (summary.byKind[reference.kind] || 0) + 1; + return summary; + }, { total: 0, byKind: {} }); +} + +function finding(reference, code, severity, message) { + return { + referenceId: reference.id, + kind: reference.kind, + target: reference.target, + code, + severity, + message + }; +} + +function compareFindings(left, right) { + return `${left.referenceId}:${left.code}`.localeCompare(`${right.referenceId}:${right.code}`); +} + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function digestPacket(packet) { + return crypto.createHash('sha256').update(stableStringify(packet)).digest('hex'); +} + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +module.exports = { + assessExternalReferences +}; diff --git a/repository-external-reference-pin-guard/make-demo-video.py b/repository-external-reference-pin-guard/make-demo-video.py new file mode 100644 index 00000000..4e22c1a0 --- /dev/null +++ b/repository-external-reference-pin-guard/make-demo-video.py @@ -0,0 +1,78 @@ +from pathlib import Path +import subprocess +import textwrap + +from PIL import Image, ImageDraw, ImageFont + + +ROOT = Path(__file__).resolve().parent +REPORTS = ROOT / "reports" +REPORTS.mkdir(exist_ok=True) +FRAME = REPORTS / "demo-frame.png" +VIDEO = REPORTS / "demo.mp4" + + +def font(size, bold=False): + candidates = [ + "C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf", + "C:/Windows/Fonts/segoeuib.ttf" if bold else "C:/Windows/Fonts/segoeui.ttf", + ] + for candidate in candidates: + if Path(candidate).exists(): + return ImageFont.truetype(candidate, size) + return ImageFont.load_default() + + +img = Image.new("RGB", (1280, 720), "#ffffff") +draw = ImageDraw.Draw(img) +title_font = font(44, True) +body_font = font(23) +small_font = font(18) + +draw.rectangle((0, 0, 1280, 92), fill="#0f172a") +draw.text((48, 26), "Repository External Reference Pin Guard", fill="#ffffff", font=title_font) + +cards = [ + ("MALFORMED_REFERENCE_MANIFEST", "#991b1b", "Blocks object-shaped or missing reference manifests before they pass as clean audits"), + ("MALFORMED_REFERENCE_ENTRY", "#991b1b", "Turns malformed external-reference entries into release-blocking repair evidence"), + ("hold_repository_release", "#991b1b", "Blocks floating git refs, auth-only APIs, stale dataset evidence"), + ("stage_reference_metadata_revision", "#a16207", "Stages pinned references that still need license or attribution"), + ("release_repository_references", "#047857", "Allows DOI/export release only with immutable pins, parseable DOIs, and checksums"), +] + +for index, (status, color, description) in enumerate(cards): + y = 112 + index * 90 + draw.rounded_rectangle((58, y, 1222, y + 72), radius=8, outline="#cbd5e1", width=2, fill="#f8fafc") + draw.ellipse((88, y + 18, 126, y + 56), fill=color) + draw.text((150, y + 12), status, fill="#111827", font=body_font) + for line_index, line in enumerate(textwrap.wrap(description, width=78)): + draw.text((150, y + 42 + line_index * 22), line, fill="#475569", font=small_font) + +draw.text((58, 596), "Synthetic evidence only. No external repositories, APIs, DOI registries, or private data sources are contacted.", fill="#334155", font=small_font) +img.save(FRAME) + +subprocess.run( + [ + "ffmpeg", + "-y", + "-loop", + "1", + "-i", + str(FRAME), + "-t", + "7.5", + "-r", + "24", + "-vf", + "format=yuv420p", + "-movflags", + "+faststart", + str(VIDEO), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, +) + +FRAME.unlink(missing_ok=True) +print(f"wrote {VIDEO}") diff --git a/repository-external-reference-pin-guard/package.json b/repository-external-reference-pin-guard/package.json new file mode 100644 index 00000000..fd485f57 --- /dev/null +++ b/repository-external-reference-pin-guard/package.json @@ -0,0 +1,12 @@ +{ + "name": "repository-external-reference-pin-guard", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "video": "python make-demo-video.py", + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && python -m py_compile make-demo-video.py" + } +} diff --git a/repository-external-reference-pin-guard/reports/blocked-packet.json b/repository-external-reference-pin-guard/reports/blocked-packet.json new file mode 100644 index 00000000..1eae1c9e --- /dev/null +++ b/repository-external-reference-pin-guard/reports/blocked-packet.json @@ -0,0 +1,91 @@ +{ + "repositoryId": "repo-reference-risk", + "status": "hold_repository_release", + "releaseLanes": { + "doiPublication": "blocked", + "exportBundle": "blocked", + "apiAccess": "metadata_only" + }, + "findings": [ + { + "referenceId": "api-weather-source", + "kind": "api_source", + "target": "https://api.example.invalid/weather/latest", + "code": "AUTH_REQUIRED_REFERENCE", + "severity": "blocker", + "message": "Export bundles cannot depend on authenticated external references." + }, + { + "referenceId": "api-weather-source", + "kind": "api_source", + "target": "https://api.example.invalid/weather/latest", + "code": "FLOATING_API_REFERENCE", + "severity": "blocker", + "message": "API source must reference a dated snapshot with checksum evidence." + }, + { + "referenceId": "dataset-lab-export", + "kind": "linked_dataset", + "target": "https://data.example.invalid/lab-export.csv", + "code": "INVALID_CHECKSUM_EVIDENCE", + "severity": "blocker", + "message": "Checksum evidence must use a supported algorithm and full-length hexadecimal digest." + }, + { + "referenceId": "dataset-lab-export", + "kind": "linked_dataset", + "target": "https://data.example.invalid/lab-export.csv", + "code": "INVALID_DOI_EVIDENCE", + "severity": "blocker", + "message": "DOI evidence must be a parseable DOI or DOI URL before release." + }, + { + "referenceId": "dataset-lab-export", + "kind": "linked_dataset", + "target": "https://data.example.invalid/lab-export.csv", + "code": "MISSING_DURABLE_IDENTIFIER", + "severity": "blocker", + "message": "External data or model reference needs a checksum, DOI, or immutable version." + }, + { + "referenceId": "dataset-lab-export", + "kind": "linked_dataset", + "target": "https://data.example.invalid/lab-export.csv", + "code": "STALE_REFERENCE_EVIDENCE", + "severity": "blocker", + "message": "External reference verification is older than policy allows." + }, + { + "referenceId": "submodule-analysis-tools", + "kind": "git_submodule", + "target": "https://github.com/example/analysis-tools", + "code": "FLOATING_GIT_REFERENCE", + "severity": "blocker", + "message": "Git reference must be pinned to an immutable commit SHA before release." + } + ], + "actions": [ + "add_checksum_or_doi:dataset-lab-export", + "pin_external_reference:api-weather-source", + "pin_external_reference:submodule-analysis-tools", + "refresh_reference_verification:dataset-lab-export", + "repair_reference_evidence:dataset-lab-export", + "replace_or_snapshot_auth_reference:api-weather-source" + ], + "referenceSignals": { + "immutablePins": false, + "exportable": false, + "attributionComplete": true, + "verificationFresh": false + }, + "referenceSummary": { + "total": 3, + "byKind": { + "git_submodule": 1, + "linked_dataset": 1, + "api_source": 1 + } + }, + "assessedAt": "2026-05-28T12:00:00Z", + "auditDigest": "0cb0a30094dfe5515aec52b275734cc495e22ef6e44921295fac7d26c6f21a52" +} diff --git a/repository-external-reference-pin-guard/reports/clean-packet.json b/repository-external-reference-pin-guard/reports/clean-packet.json new file mode 100644 index 00000000..452c118b --- /dev/null +++ b/repository-external-reference-pin-guard/reports/clean-packet.json @@ -0,0 +1,29 @@ +{ + "repositoryId": "repo-reference-clean", + "status": "release_repository_references", + "releaseLanes": { + "doiPublication": "allowed", + "exportBundle": "allowed", + "apiAccess": "allowed" + }, + "findings": [], + "actions": [ + "release_with_reference_pin_monitoring:repo-reference-clean" + ], + "referenceSignals": { + "immutablePins": true, + "exportable": true, + "attributionComplete": true, + "verificationFresh": true + }, + "referenceSummary": { + "total": 3, + "byKind": { + "git_submodule": 1, + "linked_dataset": 1, + "api_source": 1 + } + }, + "assessedAt": "2026-05-28T12:00:00Z", + "auditDigest": "5b1ede709bdfe56bb066cce64315055c0e3e87678a2bc86d7d8846ab17ae48a1" +} diff --git a/repository-external-reference-pin-guard/reports/demo.mp4 b/repository-external-reference-pin-guard/reports/demo.mp4 new file mode 100644 index 00000000..30f2129d Binary files /dev/null and b/repository-external-reference-pin-guard/reports/demo.mp4 differ diff --git a/repository-external-reference-pin-guard/reports/external-reference-report.md b/repository-external-reference-pin-guard/reports/external-reference-report.md new file mode 100644 index 00000000..0120cde8 --- /dev/null +++ b/repository-external-reference-pin-guard/reports/external-reference-report.md @@ -0,0 +1,11 @@ +# Repository External Reference Pin Guard Report + +| Packet | Status | DOI publication | Export bundle | API access | Findings | +| --- | --- | --- | --- | --- | --- | +| blocked-packet.json | hold_repository_release | blocked | blocked | metadata_only | AUTH_REQUIRED_REFERENCE, FLOATING_API_REFERENCE, INVALID_CHECKSUM_EVIDENCE, INVALID_DOI_EVIDENCE, MISSING_DURABLE_IDENTIFIER, STALE_REFERENCE_EVIDENCE, FLOATING_GIT_REFERENCE | +| malformed-manifest-packet.json | hold_repository_release | blocked | blocked | metadata_only | MALFORMED_REFERENCE_MANIFEST | +| malformed-packet.json | hold_repository_release | blocked | blocked | metadata_only | MALFORMED_REFERENCE_ENTRY | +| clean-packet.json | release_repository_references | allowed | allowed | allowed | none | +| warning-packet.json | stage_reference_metadata_revision | metadata_revision | draft_only | allowed | MISSING_ATTRIBUTION, MISSING_LICENSE | + +Synthetic data only. No external repositories, APIs, DOI registries, or private data sources are contacted. diff --git a/repository-external-reference-pin-guard/reports/malformed-manifest-packet.json b/repository-external-reference-pin-guard/reports/malformed-manifest-packet.json new file mode 100644 index 00000000..384be8e7 --- /dev/null +++ b/repository-external-reference-pin-guard/reports/malformed-manifest-packet.json @@ -0,0 +1,36 @@ +{ + "repositoryId": "repo-reference-malformed-manifest", + "status": "hold_repository_release", + "releaseLanes": { + "doiPublication": "blocked", + "exportBundle": "blocked", + "apiAccess": "metadata_only" + }, + "findings": [ + { + "referenceId": "reference-manifest", + "kind": "unknown", + "target": null, + "code": "MALFORMED_REFERENCE_MANIFEST", + "severity": "blocker", + "message": "External reference manifest must be an array before release." + } + ], + "actions": [ + "repair_reference_manifest:reference-manifest" + ], + "referenceSignals": { + "immutablePins": false, + "exportable": false, + "attributionComplete": false, + "verificationFresh": false + }, + "referenceSummary": { + "total": 1, + "byKind": { + "unknown": 1 + } + }, + "assessedAt": "2026-05-28T12:00:00Z", + "auditDigest": "cd76d8b327ffca47048a3b054d92a71799bde2685529573670e07a3362ac5e69" +} diff --git a/repository-external-reference-pin-guard/reports/malformed-packet.json b/repository-external-reference-pin-guard/reports/malformed-packet.json new file mode 100644 index 00000000..3c052d7c --- /dev/null +++ b/repository-external-reference-pin-guard/reports/malformed-packet.json @@ -0,0 +1,36 @@ +{ + "repositoryId": "repo-reference-malformed-entry", + "status": "hold_repository_release", + "releaseLanes": { + "doiPublication": "blocked", + "exportBundle": "blocked", + "apiAccess": "metadata_only" + }, + "findings": [ + { + "referenceId": "malformed-reference-entry-1", + "kind": "unknown", + "target": null, + "code": "MALFORMED_REFERENCE_ENTRY", + "severity": "blocker", + "message": "External reference entry must be a structured object before release." + } + ], + "actions": [ + "repair_reference_entry:malformed-reference-entry-1" + ], + "referenceSignals": { + "immutablePins": false, + "exportable": false, + "attributionComplete": false, + "verificationFresh": false + }, + "referenceSummary": { + "total": 1, + "byKind": { + "unknown": 1 + } + }, + "assessedAt": "2026-05-28T12:00:00Z", + "auditDigest": "5712c8d01b35d778254650adc9a25bd0b08e9268a8a0a9bfc4d36f8ddafae41a" +} diff --git a/repository-external-reference-pin-guard/reports/summary.svg b/repository-external-reference-pin-guard/reports/summary.svg new file mode 100644 index 00000000..d7715810 --- /dev/null +++ b/repository-external-reference-pin-guard/reports/summary.svg @@ -0,0 +1,36 @@ + + + Repository External Reference Pin Guard + Issue #10 release/export gate for submodules, linked datasets, API sources, and model references + + + + + repo-reference-risk + blocked-packet.json | findings 7 | digest 0cb0a30094dfe551 + + + + + repo-reference-malformed-manifest + malformed-manifest-packet.json | findings 1 | digest cd76d8b327ffca47 + + + + + repo-reference-malformed-entry + malformed-packet.json | findings 1 | digest 5712c8d01b35d778 + + + + + repo-reference-clean + clean-packet.json | findings 0 | digest 5b1ede709bdfe56b + + + + + repo-reference-warning + warning-packet.json | findings 2 | digest 2bcec8202016cc03 + + diff --git a/repository-external-reference-pin-guard/reports/warning-packet.json b/repository-external-reference-pin-guard/reports/warning-packet.json new file mode 100644 index 00000000..a707e1c7 --- /dev/null +++ b/repository-external-reference-pin-guard/reports/warning-packet.json @@ -0,0 +1,44 @@ +{ + "repositoryId": "repo-reference-warning", + "status": "stage_reference_metadata_revision", + "releaseLanes": { + "doiPublication": "metadata_revision", + "exportBundle": "draft_only", + "apiAccess": "allowed" + }, + "findings": [ + { + "referenceId": "model-weights", + "kind": "model_weights", + "target": "https://models.example.invalid/model-v3.bin", + "code": "MISSING_ATTRIBUTION", + "severity": "warning", + "message": "External reference needs attribution metadata before DOI publication." + }, + { + "referenceId": "model-weights", + "kind": "model_weights", + "target": "https://models.example.invalid/model-v3.bin", + "code": "MISSING_LICENSE", + "severity": "warning", + "message": "External reference needs license metadata before DOI publication." + } + ], + "actions": [ + "complete_license_attribution:model-weights" + ], + "referenceSignals": { + "immutablePins": true, + "exportable": true, + "attributionComplete": false, + "verificationFresh": true + }, + "referenceSummary": { + "total": 1, + "byKind": { + "model_weights": 1 + } + }, + "assessedAt": "2026-05-28T12:00:00Z", + "auditDigest": "2bcec8202016cc03a70f2a1bd751d8f07518e31cd76900ca9f428e638a168afc" +} diff --git a/repository-external-reference-pin-guard/requirements-map.md b/repository-external-reference-pin-guard/requirements-map.md new file mode 100644 index 00000000..11b12135 --- /dev/null +++ b/repository-external-reference-pin-guard/requirements-map.md @@ -0,0 +1,15 @@ +# Requirements Map + +Issue #10 asks for robust project repositories with versioned files, collaboration, reproducibility, identifiers, citations, programmatic access, and export bundles. + +| Issue #10 area | Coverage in this slice | +| --- | --- | +| File and metadata versioning | Holds releases when Git submodules or external code are not pinned to immutable commits, including null all-zero commit placeholders. | +| Hash-based integrity | Requires full-length SHA checksums, parseable DOI evidence, or non-floating immutable versions for external datasets and model weights, and blocks malformed checksum evidence even when another identifier is present. | +| Computation-aware reproducibility | Blocks reproducibility/export lanes when API data sources are floating, authenticated only, or backed by malformed/future snapshot dates, invalid checksum evidence, or truncated checksum evidence. | +| Repository identifiers and citation | Prevents DOI publication when external references lack verification timestamps, are stale, future-dated, contain malformed DOI evidence, or lack license/attribution metadata. | +| Programmatic access and export | Separates API metadata-only access from export-bundle and DOI publication release lanes, and blocks malformed reference manifests or entries with explicit repair actions. | + +## Non-Overlap + +This contribution is distinct from broad repository ledgers, release engines, structured diffs, provenance attestations, release embargo controls, notebook replay, schema migration, citation impact, API/export contract verification, merge queue governance, environment drift, access review, DOI tombstone handling, metadata readiness, branch hypothesis lineage, sensitive-artifact scanning, dependency-license checks, legal hold, component-owner approval, restore rehearsal, compute sandbox policy, and semantic version-tag governance. It focuses specifically on immutable external reference pins and exportable citation evidence for submodules, linked datasets, API sources, model weights, and external code/data pointers. diff --git a/repository-external-reference-pin-guard/sample-data.js b/repository-external-reference-pin-guard/sample-data.js new file mode 100644 index 00000000..041a8ef7 --- /dev/null +++ b/repository-external-reference-pin-guard/sample-data.js @@ -0,0 +1,120 @@ +const riskyRepository = { + repositoryId: 'repo-reference-risk', + assessedAt: '2026-05-28T12:00:00Z', + policy: { + maxReferenceAgeDays: 180 + }, + references: [ + { + id: 'submodule-analysis-tools', + kind: 'git_submodule', + target: 'https://github.com/example/analysis-tools', + branch: 'main', + commitSha: '', + license: 'MIT', + attribution: 'Example Analysis Tools', + lastVerifiedAt: '2026-05-20T08:00:00Z' + }, + { + id: 'dataset-lab-export', + kind: 'linked_dataset', + target: 'https://data.example.invalid/lab-export.csv', + checksum: 'pending', + doi: 'pending', + license: 'CC-BY-4.0', + attribution: 'Example Lab', + lastVerifiedAt: '2025-01-15T08:00:00Z' + }, + { + id: 'api-weather-source', + kind: 'api_source', + target: 'https://api.example.invalid/weather/latest', + snapshotDate: '', + checksum: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + authRequired: true, + license: 'CC0-1.0', + attribution: 'Example Weather API', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] +}; + +const cleanRepository = { + repositoryId: 'repo-reference-clean', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'submodule-analysis-tools', + kind: 'git_submodule', + target: 'https://github.com/example/analysis-tools', + commitSha: '7f9c2d6c8e0f4b1a2d3c5e6f708192a3b4c5d6e7', + license: 'MIT', + attribution: 'Example Analysis Tools', + lastVerifiedAt: '2026-05-20T08:00:00Z' + }, + { + id: 'dataset-lab-export', + kind: 'linked_dataset', + target: 'https://doi.org/10.5281/zenodo.1234567', + checksum: 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + doi: '10.5281/zenodo.1234567', + license: 'CC-BY-4.0', + attribution: 'Example Lab', + lastVerifiedAt: '2026-05-20T08:00:00Z' + }, + { + id: 'api-weather-source', + kind: 'api_source', + target: 'https://api.example.invalid/weather/snapshots/2026-05-01.json', + snapshotDate: '2026-05-01', + checksum: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + authRequired: false, + license: 'CC0-1.0', + attribution: 'Example Weather API', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] +}; + +const warningRepository = { + repositoryId: 'repo-reference-warning', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'model-weights', + kind: 'model_weights', + target: 'https://models.example.invalid/model-v3.bin', + checksum: 'sha256:feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface', + version: 'v3.0.1', + license: '', + attribution: '', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] +}; + +const malformedRepository = { + repositoryId: 'repo-reference-malformed-entry', + assessedAt: '2026-05-28T12:00:00Z', + references: [null] +}; + +const malformedManifestRepository = { + repositoryId: 'repo-reference-malformed-manifest', + assessedAt: '2026-05-28T12:00:00Z', + references: { + dataset: { + id: 'dataset-not-in-list', + kind: 'linked_dataset', + target: 'https://doi.org/10.5281/zenodo.4567890' + } + } +}; + +module.exports = { + riskyRepository, + cleanRepository, + warningRepository, + malformedRepository, + malformedManifestRepository +}; diff --git a/repository-external-reference-pin-guard/test.js b/repository-external-reference-pin-guard/test.js new file mode 100644 index 00000000..1405818f --- /dev/null +++ b/repository-external-reference-pin-guard/test.js @@ -0,0 +1,489 @@ +const assert = require('assert'); + +const { assessExternalReferences } = require('./index'); + +function findingCodes(packet) { + return packet.findings.map((finding) => finding.code).sort(); +} + +function testBlocksFloatingAndNonExportableExternalReferences() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-risk', + assessedAt: '2026-05-28T12:00:00Z', + policy: { + maxReferenceAgeDays: 180 + }, + references: [ + { + id: 'submodule-analysis-tools', + kind: 'git_submodule', + target: 'https://github.com/example/analysis-tools', + branch: 'main', + commitSha: '', + license: 'MIT', + attribution: 'Example Analysis Tools' + }, + { + id: 'dataset-lab-export', + kind: 'linked_dataset', + target: 'https://data.example.invalid/lab-export.csv', + checksum: '', + doi: '', + license: 'CC-BY-4.0', + attribution: 'Example Lab', + lastVerifiedAt: '2025-01-15T08:00:00Z' + }, + { + id: 'api-weather-source', + kind: 'api_source', + target: 'https://api.example.invalid/weather/latest', + snapshotDate: '', + checksum: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + authRequired: true, + license: 'CC0-1.0', + attribution: 'Example Weather API' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.equal(packet.releaseLanes.doiPublication, 'blocked'); + assert.equal(packet.releaseLanes.exportBundle, 'blocked'); + assert.deepEqual(findingCodes(packet), [ + 'AUTH_REQUIRED_REFERENCE', + 'FLOATING_API_REFERENCE', + 'FLOATING_GIT_REFERENCE', + 'MISSING_DURABLE_IDENTIFIER', + 'STALE_REFERENCE_EVIDENCE' + ]); + assert.ok(packet.actions.includes('pin_external_reference:submodule-analysis-tools')); + assert.ok(packet.actions.includes('add_checksum_or_doi:dataset-lab-export')); + assert.ok(packet.actions.includes('replace_or_snapshot_auth_reference:api-weather-source')); + assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); +} + +function testAllowsPinnedExportableReferences() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-clean', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'submodule-analysis-tools', + kind: 'git_submodule', + target: 'https://github.com/example/analysis-tools', + commitSha: '7f9c2d6c8e0f4b1a2d3c5e6f708192a3b4c5d6e7', + license: 'MIT', + attribution: 'Example Analysis Tools', + lastVerifiedAt: '2026-05-20T08:00:00Z' + }, + { + id: 'dataset-lab-export', + kind: 'linked_dataset', + target: 'https://doi.org/10.5281/zenodo.1234567', + checksum: 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + doi: '10.5281/zenodo.1234567', + license: 'CC-BY-4.0', + attribution: 'Example Lab', + lastVerifiedAt: '2026-05-20T08:00:00Z' + }, + { + id: 'api-weather-source', + kind: 'api_source', + target: 'https://api.example.invalid/weather/snapshots/2026-05-01.json', + snapshotDate: '2026-05-01', + checksum: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + authRequired: false, + license: 'CC0-1.0', + attribution: 'Example Weather API', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'release_repository_references'); + assert.equal(packet.releaseLanes.doiPublication, 'allowed'); + assert.equal(packet.releaseLanes.exportBundle, 'allowed'); + assert.deepEqual(packet.findings, []); + assert.deepEqual(packet.actions, ['release_with_reference_pin_monitoring:repo-reference-clean']); + assert.equal(packet.referenceSignals.immutablePins, true); + assert.equal(packet.referenceSignals.exportable, true); +} + +function testStagesReferencesMissingLicenseAttributionOnly() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-warning', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'model-weights', + kind: 'model_weights', + target: 'https://models.example.invalid/model-v3.bin', + checksum: 'sha256:feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface', + version: 'v3.0.1', + license: '', + attribution: '', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'stage_reference_metadata_revision'); + assert.equal(packet.releaseLanes.doiPublication, 'metadata_revision'); + assert.equal(packet.releaseLanes.exportBundle, 'draft_only'); + assert.deepEqual(findingCodes(packet), [ + 'MISSING_ATTRIBUTION', + 'MISSING_LICENSE' + ]); + assert.deepEqual(packet.actions, ['complete_license_attribution:model-weights']); +} + +function testFloatingVersionAliasDoesNotCountAsDurableIdentifier() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-floating-version', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'model-weights-latest', + kind: 'model_weights', + target: 'https://models.example.invalid/model.bin', + version: 'latest', + checksum: '', + doi: '', + license: 'Apache-2.0', + attribution: 'Example Model Lab', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), ['MISSING_DURABLE_IDENTIFIER']); + assert.ok(packet.actions.includes('add_checksum_or_doi:model-weights-latest')); + assert.equal(packet.referenceSignals.exportable, false); +} + +function testInvalidChecksumDoesNotCountAsDurableIdentifier() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-invalid-checksum', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'dataset-invalid-checksum', + kind: 'linked_dataset', + target: 'https://data.example.invalid/lab-export.csv', + checksum: 'pending', + doi: '', + version: '', + license: 'CC-BY-4.0', + attribution: 'Example Lab', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), [ + 'INVALID_CHECKSUM_EVIDENCE', + 'MISSING_DURABLE_IDENTIFIER' + ]); + assert.ok(packet.actions.includes('add_checksum_or_doi:dataset-invalid-checksum')); + assert.ok(packet.actions.includes('repair_reference_evidence:dataset-invalid-checksum')); + assert.equal(packet.referenceSignals.exportable, false); +} + +function testDoiPlaceholderDoesNotCountAsDurableIdentifier() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-invalid-doi', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'dataset-invalid-doi', + kind: 'linked_dataset', + target: 'https://data.example.invalid/lab-export.csv', + checksum: '', + doi: 'pending', + version: '', + license: 'CC-BY-4.0', + attribution: 'Example Lab', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), [ + 'INVALID_DOI_EVIDENCE', + 'MISSING_DURABLE_IDENTIFIER' + ]); + assert.ok(packet.actions.includes('add_checksum_or_doi:dataset-invalid-doi')); + assert.ok(packet.actions.includes('repair_reference_evidence:dataset-invalid-doi')); + assert.equal(packet.referenceSignals.exportable, false); +} + +function testFutureDatedVerificationEvidenceIsNotFresh() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-future-verification', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'dataset-future-verified', + kind: 'linked_dataset', + target: 'https://doi.org/10.5281/zenodo.7654321', + checksum: 'sha256:feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface', + doi: '10.5281/zenodo.7654321', + license: 'CC-BY-4.0', + attribution: 'Example Lab', + lastVerifiedAt: '2026-05-29T12:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), ['STALE_REFERENCE_EVIDENCE']); + assert.ok(packet.actions.includes('refresh_reference_verification:dataset-future-verified')); + assert.equal(packet.referenceSignals.verificationFresh, false); +} + +function testFutureDatedApiSnapshotDoesNotCountAsPinnedEvidence() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-future-api-snapshot', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'api-future-snapshot', + kind: 'api_source', + target: 'https://api.example.invalid/weather/snapshots/2026-06-01.json', + snapshotDate: '2026-06-01', + checksum: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + authRequired: false, + license: 'CC0-1.0', + attribution: 'Example Weather API', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), ['FLOATING_API_REFERENCE']); + assert.ok(packet.actions.includes('pin_external_reference:api-future-snapshot')); + assert.equal(packet.referenceSignals.immutablePins, false); +} + +function testInvalidApiSnapshotChecksumDoesNotCountAsPinnedEvidence() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-invalid-api-checksum', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'api-invalid-checksum', + kind: 'api_source', + target: 'https://api.example.invalid/weather/snapshots/2026-05-01.json', + snapshotDate: '2026-05-01', + checksum: 'pending', + authRequired: false, + license: 'CC0-1.0', + attribution: 'Example Weather API', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), [ + 'FLOATING_API_REFERENCE', + 'INVALID_CHECKSUM_EVIDENCE' + ]); + assert.ok(packet.actions.includes('pin_external_reference:api-invalid-checksum')); + assert.ok(packet.actions.includes('repair_reference_evidence:api-invalid-checksum')); + assert.equal(packet.referenceSignals.immutablePins, false); +} + +function testTruncatedApiSnapshotChecksumDoesNotCountAsPinnedEvidence() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-truncated-api-checksum', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'api-truncated-checksum', + kind: 'api_source', + target: 'https://api.example.invalid/weather/snapshots/2026-05-01.json', + snapshotDate: '2026-05-01', + checksum: 'sha256:abcdef', + authRequired: false, + license: 'CC0-1.0', + attribution: 'Example Weather API', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), [ + 'FLOATING_API_REFERENCE', + 'INVALID_CHECKSUM_EVIDENCE' + ]); + assert.ok(packet.actions.includes('pin_external_reference:api-truncated-checksum')); + assert.ok(packet.actions.includes('repair_reference_evidence:api-truncated-checksum')); + assert.equal(packet.referenceSignals.immutablePins, false); +} + +function testNullGitCommitShaDoesNotCountAsImmutablePin() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-null-git-pin', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'submodule-null-sha', + kind: 'git_submodule', + target: 'https://github.com/example/analysis-tools', + commitSha: '0000000000000000000000000000000000000000', + license: 'MIT', + attribution: 'Example Analysis Tools', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), ['FLOATING_GIT_REFERENCE']); + assert.ok(packet.actions.includes('pin_external_reference:submodule-null-sha')); + assert.equal(packet.referenceSignals.immutablePins, false); +} + +function testMissingVerificationEvidenceBlocksOtherwisePinnedReference() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-missing-verification', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'dataset-needs-verification', + kind: 'linked_dataset', + target: 'https://doi.org/10.5281/zenodo.2345678', + checksum: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + doi: '10.5281/zenodo.2345678', + license: 'CC-BY-4.0', + attribution: 'Example Lab' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), ['STALE_REFERENCE_EVIDENCE']); + assert.ok(packet.actions.includes('refresh_reference_verification:dataset-needs-verification')); + assert.equal(packet.referenceSignals.verificationFresh, false); +} + +function testMalformedOptionalEvidenceBlocksEvenWhenAnotherIdentifierIsValid() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-poisoned-evidence', + assessedAt: '2026-05-28T12:00:00Z', + references: [ + { + id: 'dataset-poisoned-evidence', + kind: 'linked_dataset', + target: 'https://doi.org/10.5281/zenodo.3456789', + checksum: 'sha256:abcdef', + doi: '10.5281/zenodo.3456789', + license: 'CC-BY-4.0', + attribution: 'Example Lab', + lastVerifiedAt: '2026-05-20T08:00:00Z' + }, + { + id: 'model-poisoned-citation', + kind: 'model_weights', + target: 'https://models.example.invalid/model-v4.bin', + checksum: 'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + doi: 'pending', + version: 'v4.0.0', + license: 'Apache-2.0', + attribution: 'Example Model Lab', + lastVerifiedAt: '2026-05-20T08:00:00Z' + } + ] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), [ + 'INVALID_CHECKSUM_EVIDENCE', + 'INVALID_DOI_EVIDENCE' + ]); + assert.ok(packet.actions.includes('repair_reference_evidence:dataset-poisoned-evidence')); + assert.ok(packet.actions.includes('repair_reference_evidence:model-poisoned-citation')); + assert.equal(packet.referenceSignals.exportable, false); +} + +function testMalformedReferenceEntriesBlockReleaseInsteadOfCrashing() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-malformed-entry', + assessedAt: '2026-05-28T12:00:00Z', + references: [null] + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), ['MALFORMED_REFERENCE_ENTRY']); + assert.equal(packet.findings[0].referenceId, 'malformed-reference-entry-1'); + assert.ok(packet.actions.includes('repair_reference_entry:malformed-reference-entry-1')); + assert.equal(packet.referenceSignals.immutablePins, false); + assert.equal(packet.referenceSignals.exportable, false); + assert.equal(packet.referenceSignals.attributionComplete, false); + assert.equal(packet.referenceSignals.verificationFresh, false); + assert.deepEqual(packet.referenceSummary, { + total: 1, + byKind: { + unknown: 1 + } + }); +} + +function testMalformedReferenceManifestBlocksRelease() { + const packet = assessExternalReferences({ + repositoryId: 'repo-reference-malformed-manifest', + assessedAt: '2026-05-28T12:00:00Z', + references: { + dataset: { + id: 'dataset-not-in-list', + kind: 'linked_dataset', + target: 'https://doi.org/10.5281/zenodo.4567890' + } + } + }); + + assert.equal(packet.status, 'hold_repository_release'); + assert.deepEqual(findingCodes(packet), ['MALFORMED_REFERENCE_MANIFEST']); + assert.equal(packet.findings[0].referenceId, 'reference-manifest'); + assert.ok(packet.actions.includes('repair_reference_manifest:reference-manifest')); + assert.equal(packet.referenceSignals.immutablePins, false); + assert.equal(packet.referenceSignals.exportable, false); + assert.equal(packet.referenceSignals.attributionComplete, false); + assert.equal(packet.referenceSignals.verificationFresh, false); + assert.deepEqual(packet.referenceSummary, { + total: 1, + byKind: { + unknown: 1 + } + }); +} + +const tests = [ + testBlocksFloatingAndNonExportableExternalReferences, + testAllowsPinnedExportableReferences, + testStagesReferencesMissingLicenseAttributionOnly, + testFloatingVersionAliasDoesNotCountAsDurableIdentifier, + testInvalidChecksumDoesNotCountAsDurableIdentifier, + testDoiPlaceholderDoesNotCountAsDurableIdentifier, + testFutureDatedVerificationEvidenceIsNotFresh, + testFutureDatedApiSnapshotDoesNotCountAsPinnedEvidence, + testInvalidApiSnapshotChecksumDoesNotCountAsPinnedEvidence, + testTruncatedApiSnapshotChecksumDoesNotCountAsPinnedEvidence, + testNullGitCommitShaDoesNotCountAsImmutablePin, + testMissingVerificationEvidenceBlocksOtherwisePinnedReference, + testMalformedOptionalEvidenceBlocksEvenWhenAnotherIdentifierIsValid, + testMalformedReferenceEntriesBlockReleaseInsteadOfCrashing, + testMalformedReferenceManifestBlocksRelease +]; + +for (const test of tests) { + test(); +} + +console.log(`repository-external-reference-pin-guard tests passed (${tests.length})`);