diff --git a/repository-retention-legal-hold-gate/README.md b/repository-retention-legal-hold-gate/README.md new file mode 100644 index 00000000..cc3f859a --- /dev/null +++ b/repository-retention-legal-hold-gate/README.md @@ -0,0 +1,35 @@ +# Repository Retention Legal Hold Gate + +Self-contained review slice for issue #10, Project Repository & Version Control. + +This module evaluates whether a scientific project repository version can be published, exported, or destructively changed while preserving retention rules and active legal holds. It focuses on repository lifecycle governance rather than access review, DOI tombstones, dependency licensing, merge queues, environment drift, or sensitive-artifact scanning. + +## What it checks + +- Active legal holds blocking destructive repository actions. +- Archive evidence for manuscript, dataset, code, and result components before release. +- Content hashes for reproducibility and rollback integrity. +- Export bundle manifest hashes for tagged versions. +- Deterministic reviewer reports for release decisions. + +## Files + +- `index.js` - retention and legal-hold evaluator. +- `sample-data.js` - synthetic repository scenarios only. +- `test.js` - Node.js built-in test suite. +- `demo.js` - generates reviewer artifacts in `reports/`. +- `reports/decision.json` - generated machine-readable decisions. +- `reports/report.md` - generated reviewer packet. +- `reports/retention-gate.svg` - generated visual summary. +- `reports/demo.mp4` - short demo video artifact for bounty review. + +## Run + +```bash +node repository-retention-legal-hold-gate/test.js +node repository-retention-legal-hold-gate/demo.js +``` + +## Safety + +The sample data is synthetic. The module performs no network calls, scans no real repositories, and contains no secrets, tokens, private dashboard data, payout information, or patient data. diff --git a/repository-retention-legal-hold-gate/demo.js b/repository-retention-legal-hold-gate/demo.js new file mode 100644 index 00000000..ee9cfe39 --- /dev/null +++ b/repository-retention-legal-hold-gate/demo.js @@ -0,0 +1,52 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const {evaluateRepositoryRetention, buildRetentionReport} = 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, + ...evaluateRepositoryRetention(scenario), +})); + +const report = evaluations.map(buildRetentionReport).join('\n---\n'); +const decisionJson = JSON.stringify(evaluations, null, 2); + +const approved = evaluations.filter((item) => item.decision === 'approved').length; +const blocked = evaluations.filter((item) => item.decision === 'blocked').length; +const remediation = evaluations.filter((item) => item.decision === 'needs-remediation').length; + +const svg = ` + + Repository Retention Legal Hold Gate + Synthetic review packet for project repository release governance + + + Approved + ${approved} + + + + Needs Remediation + ${remediation} + + + + Blocked + ${blocked} + + Checks: legal holds, archive evidence, content hashes, export manifest hashes + Outputs: decision.json, report.md, retention-gate.svg, demo video artifact + No real secrets, patient data, private repositories, or external service calls. + +`; + +fs.writeFileSync(path.join(reportsDir, 'decision.json'), `${decisionJson}\n`); +fs.writeFileSync(path.join(reportsDir, 'report.md'), report); +fs.writeFileSync(path.join(reportsDir, 'retention-gate.svg'), svg); + +console.log(`Wrote ${evaluations.length} retention evaluations to ${reportsDir}`); +console.log(`Decision counts: approved=${approved}, remediation=${remediation}, blocked=${blocked}`); diff --git a/repository-retention-legal-hold-gate/index.js b/repository-retention-legal-hold-gate/index.js new file mode 100644 index 00000000..554584f3 --- /dev/null +++ b/repository-retention-legal-hold-gate/index.js @@ -0,0 +1,150 @@ +const destructiveActions = new Set([ + 'delete-export-bundle', + 'delete-component', + 'purge-version', + 'rewrite-history', +]); + +function normalizeList(value) { + return Array.isArray(value) ? value : []; +} + +function actionRequiresArchive(action) { + return ['publish-version', 'export-bundle', 'mint-doi'].includes(action); +} + +function isPathInScope(path, scope) { + return normalizeList(scope).some((prefix) => path === prefix || path.startsWith(prefix)); +} + +function retentionAction(type, target, reason) { + return {type, target, reason}; +} + +function evaluateRepositoryRetention(input) { + const components = normalizeList(input.components); + const legalHolds = normalizeList(input.legalHolds); + const exportBundles = normalizeList(input.exportBundles); + const blockers = []; + const requiredActions = []; + + const activeHolds = legalHolds.filter((hold) => hold.active); + if (destructiveActions.has(input.requestedAction)) { + const affectedHold = activeHolds.find((hold) => + components.some((component) => isPathInScope(component.path, hold.scope)) + ); + if (affectedHold) { + blockers.push(`Active legal hold ${affectedHold.id} prevents ${input.requestedAction}`); + requiredActions.push(retentionAction( + 'preserve_legal_hold_scope', + affectedHold.id, + affectedHold.reason || 'active legal hold' + )); + } + } + + if (actionRequiresArchive(input.requestedAction)) { + for (const component of components) { + if (!component.archived) { + blockers.push(`${component.path} lacks archive evidence`); + requiredActions.push(retentionAction( + 'archive_component', + component.path, + 'version release requires durable archive evidence' + )); + } + if (!component.hash) { + blockers.push(`${component.path} lacks content hash`); + requiredActions.push(retentionAction( + 'record_component_hash', + component.path, + 'hash-based integrity is required for reproducibility' + )); + } + } + + const targetBundles = exportBundles.filter((bundle) => bundle.version === input.taggedVersion); + if (targetBundles.length === 0) { + blockers.push(`No export bundle exists for ${input.taggedVersion}`); + requiredActions.push(retentionAction( + 'create_export_bundle', + input.taggedVersion, + 'tagged version needs a durable export package' + )); + } + for (const bundle of targetBundles) { + if (!bundle.manifestHash) { + blockers.push(`${bundle.id} lacks a manifest hash`); + requiredActions.push(retentionAction( + 'record_bundle_manifest_hash', + bundle.id, + 'export bundles need reproducible manifest integrity' + )); + } + } + } + + const archivedCount = components.filter((component) => component.archived).length; + const decision = blockers.some((blocker) => /legal hold/i.test(blocker)) + ? 'blocked' + : blockers.length > 0 + ? 'needs-remediation' + : 'approved'; + + return { + repositoryId: input.repositoryId, + requestedAction: input.requestedAction, + taggedVersion: input.taggedVersion, + generatedAt: input.generatedAt, + decision, + blockers, + requiredActions, + activeLegalHoldCount: activeHolds.length, + coverage: { + componentCount: components.length, + archivedCount, + archiveCoverage: components.length === 0 ? 1 : archivedCount / components.length, + exportBundleCount: exportBundles.length, + }, + }; +} + +function percent(value) { + return `${Math.round(value * 100)}%`; +} + +function buildRetentionReport(result) { + const lines = [ + `# Repository Retention Legal Hold Gate Report`, + ``, + `Repository: ${result.repositoryId}`, + `Version: ${result.taggedVersion}`, + `Action: ${result.requestedAction}`, + `Generated: ${result.generatedAt}`, + `Decision: ${result.decision}`, + ``, + `## Coverage`, + ``, + `Component count: ${result.coverage.componentCount}`, + `Archive coverage: ${percent(result.coverage.archiveCoverage)}`, + `Export bundles: ${result.coverage.exportBundleCount}`, + `Active legal holds: ${result.activeLegalHoldCount}`, + ``, + `## Blockers`, + ``, + ...(result.blockers.length ? result.blockers.map((item) => `- ${item}`) : ['- None']), + ``, + `## Required Actions`, + ``, + ...(result.requiredActions.length + ? result.requiredActions.map((action) => `- ${action.type}: ${action.target} (${action.reason})`) + : ['- None']), + ``, + ]; + return lines.join('\n'); +} + +module.exports = { + evaluateRepositoryRetention, + buildRetentionReport, +}; diff --git a/repository-retention-legal-hold-gate/reports/decision.json b/repository-retention-legal-hold-gate/reports/decision.json new file mode 100644 index 00000000..93a0cf84 --- /dev/null +++ b/repository-retention-legal-hold-gate/reports/decision.json @@ -0,0 +1,75 @@ +[ + { + "scenario": "legal-hold-delete-block", + "repositoryId": "repo-climate-ice-core", + "requestedAction": "delete-export-bundle", + "taggedVersion": "v2.1.0", + "generatedAt": "2026-05-22T12:00:00Z", + "decision": "blocked", + "blockers": [ + "Active legal hold hold-ethics-review prevents delete-export-bundle" + ], + "requiredActions": [ + { + "type": "preserve_legal_hold_scope", + "target": "hold-ethics-review", + "reason": "ethics review pending" + } + ], + "activeLegalHoldCount": 1, + "coverage": { + "componentCount": 3, + "archivedCount": 3, + "archiveCoverage": 1, + "exportBundleCount": 1 + } + }, + { + "scenario": "release-needs-archive-evidence", + "repositoryId": "repo-open-assay", + "requestedAction": "publish-version", + "taggedVersion": "v1.0.0", + "generatedAt": "2026-05-22T12:00:00Z", + "decision": "needs-remediation", + "blockers": [ + "data/assay.csv lacks archive evidence", + "bundle-v1.0.0 lacks a manifest hash" + ], + "requiredActions": [ + { + "type": "archive_component", + "target": "data/assay.csv", + "reason": "version release requires durable archive evidence" + }, + { + "type": "record_bundle_manifest_hash", + "target": "bundle-v1.0.0", + "reason": "export bundles need reproducible manifest integrity" + } + ], + "activeLegalHoldCount": 0, + "coverage": { + "componentCount": 3, + "archivedCount": 2, + "archiveCoverage": 0.6666666666666666, + "exportBundleCount": 1 + } + }, + { + "scenario": "approved-release", + "repositoryId": "repo-compliant-release", + "requestedAction": "publish-version", + "taggedVersion": "v3.0.0", + "generatedAt": "2026-05-22T12:00:00Z", + "decision": "approved", + "blockers": [], + "requiredActions": [], + "activeLegalHoldCount": 0, + "coverage": { + "componentCount": 4, + "archivedCount": 4, + "archiveCoverage": 1, + "exportBundleCount": 1 + } + } +] diff --git a/repository-retention-legal-hold-gate/reports/demo.mp4 b/repository-retention-legal-hold-gate/reports/demo.mp4 new file mode 100644 index 00000000..bc25bb42 Binary files /dev/null and b/repository-retention-legal-hold-gate/reports/demo.mp4 differ diff --git a/repository-retention-legal-hold-gate/reports/report.md b/repository-retention-legal-hold-gate/reports/report.md new file mode 100644 index 00000000..d281c2d8 --- /dev/null +++ b/repository-retention-legal-hold-gate/reports/report.md @@ -0,0 +1,72 @@ +# Repository Retention Legal Hold Gate Report + +Repository: repo-climate-ice-core +Version: v2.1.0 +Action: delete-export-bundle +Generated: 2026-05-22T12:00:00Z +Decision: blocked + +## Coverage + +Component count: 3 +Archive coverage: 100% +Export bundles: 1 +Active legal holds: 1 + +## Blockers + +- Active legal hold hold-ethics-review prevents delete-export-bundle + +## Required Actions + +- preserve_legal_hold_scope: hold-ethics-review (ethics review pending) + +--- +# Repository Retention Legal Hold Gate Report + +Repository: repo-open-assay +Version: v1.0.0 +Action: publish-version +Generated: 2026-05-22T12:00:00Z +Decision: needs-remediation + +## Coverage + +Component count: 3 +Archive coverage: 67% +Export bundles: 1 +Active legal holds: 0 + +## Blockers + +- data/assay.csv lacks archive evidence +- bundle-v1.0.0 lacks a manifest hash + +## Required Actions + +- archive_component: data/assay.csv (version release requires durable archive evidence) +- record_bundle_manifest_hash: bundle-v1.0.0 (export bundles need reproducible manifest integrity) + +--- +# Repository Retention Legal Hold Gate Report + +Repository: repo-compliant-release +Version: v3.0.0 +Action: publish-version +Generated: 2026-05-22T12:00:00Z +Decision: approved + +## Coverage + +Component count: 4 +Archive coverage: 100% +Export bundles: 1 +Active legal holds: 0 + +## Blockers + +- None + +## Required Actions + +- None diff --git a/repository-retention-legal-hold-gate/reports/retention-gate.svg b/repository-retention-legal-hold-gate/reports/retention-gate.svg new file mode 100644 index 00000000..eea7c944 --- /dev/null +++ b/repository-retention-legal-hold-gate/reports/retention-gate.svg @@ -0,0 +1,23 @@ + + + Repository Retention Legal Hold Gate + Synthetic review packet for project repository release governance + + + Approved + 1 + + + + Needs Remediation + 1 + + + + Blocked + 1 + + Checks: legal holds, archive evidence, content hashes, export manifest hashes + Outputs: decision.json, report.md, retention-gate.svg, demo video artifact + No real secrets, patient data, private repositories, or external service calls. + diff --git a/repository-retention-legal-hold-gate/sample-data.js b/repository-retention-legal-hold-gate/sample-data.js new file mode 100644 index 00000000..2bd9873c --- /dev/null +++ b/repository-retention-legal-hold-gate/sample-data.js @@ -0,0 +1,55 @@ +const scenarios = [ + { + name: 'legal-hold-delete-block', + repositoryId: 'repo-climate-ice-core', + requestedAction: 'delete-export-bundle', + taggedVersion: 'v2.1.0', + generatedAt: '2026-05-22T12:00:00Z', + components: [ + {path: 'manuscript/main.md', kind: 'manuscript', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:manuscript'}, + {path: 'data/ice-core.csv', kind: 'dataset', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:data'}, + {path: 'results/model.bin', kind: 'result', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:model'}, + ], + legalHolds: [ + {id: 'hold-ethics-review', active: true, scope: ['data/', 'results/'], reason: 'ethics review pending'}, + ], + exportBundles: [ + {id: 'bundle-v2.1.0', version: 'v2.1.0', manifestHash: 'sha256:bundle', storage: ['doi', 'cold-archive']}, + ], + }, + { + name: 'release-needs-archive-evidence', + repositoryId: 'repo-open-assay', + requestedAction: 'publish-version', + taggedVersion: 'v1.0.0', + generatedAt: '2026-05-22T12:00:00Z', + components: [ + {path: 'manuscript/main.md', kind: 'manuscript', retainedUntil: '2031-01-01', archived: true, hash: 'sha256:manuscript'}, + {path: 'data/assay.csv', kind: 'dataset', retainedUntil: '2031-01-01', archived: false, hash: 'sha256:data'}, + {path: 'results/figure.svg', kind: 'result', retainedUntil: '2031-01-01', archived: true, hash: 'sha256:figure'}, + ], + legalHolds: [], + exportBundles: [ + {id: 'bundle-v1.0.0', version: 'v1.0.0', manifestHash: '', storage: ['doi']}, + ], + }, + { + name: 'approved-release', + repositoryId: 'repo-compliant-release', + requestedAction: 'publish-version', + taggedVersion: 'v3.0.0', + generatedAt: '2026-05-22T12:00:00Z', + components: [ + {path: 'manuscript/main.md', kind: 'manuscript', retainedUntil: '2035-01-01', archived: true, hash: 'sha256:a'}, + {path: 'data/raw.csv', kind: 'dataset', retainedUntil: '2035-01-01', archived: true, hash: 'sha256:b'}, + {path: 'code/reproduce.py', kind: 'code', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:c'}, + {path: 'results/model.bin', kind: 'result', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:d'}, + ], + legalHolds: [{id: 'hold-old', active: false, scope: ['data/'], reason: 'released'}], + exportBundles: [ + {id: 'bundle-v3.0.0', version: 'v3.0.0', manifestHash: 'sha256:bundle', storage: ['doi', 'cold-archive', 'institutional-copy']}, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/repository-retention-legal-hold-gate/test.js b/repository-retention-legal-hold-gate/test.js new file mode 100644 index 00000000..a9806014 --- /dev/null +++ b/repository-retention-legal-hold-gate/test.js @@ -0,0 +1,86 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateRepositoryRetention, + buildRetentionReport, +} = require('./index'); + +test('blocks destructive export deletion while legal hold is active', () => { + const result = evaluateRepositoryRetention({ + repositoryId: 'repo-climate-ice-core', + requestedAction: 'delete-export-bundle', + taggedVersion: 'v2.1.0', + generatedAt: '2026-05-22T12:00:00Z', + components: [ + {path: 'manuscript/main.md', kind: 'manuscript', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:manuscript'}, + {path: 'data/ice-core.csv', kind: 'dataset', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:data'}, + {path: 'code/analyze.py', kind: 'code', retainedUntil: '2030-01-01', archived: true, hash: 'sha256:code'}, + ], + legalHolds: [ + {id: 'hold-ethics-review', active: true, scope: ['data/', 'results/'], reason: 'ethics review pending'}, + ], + exportBundles: [ + {id: 'bundle-v2.1.0', version: 'v2.1.0', manifestHash: 'sha256:bundle', storage: ['doi', 'cold-archive']}, + ], + }); + + assert.equal(result.decision, 'blocked'); + assert.equal(result.blockers.length, 1); + assert.match(result.blockers[0], /legal hold/i); + assert.equal(result.requiredActions[0].type, 'preserve_legal_hold_scope'); +}); + +test('requires archive evidence before repository version release', () => { + const result = evaluateRepositoryRetention({ + repositoryId: 'repo-open-assay', + requestedAction: 'publish-version', + taggedVersion: 'v1.0.0', + generatedAt: '2026-05-22T12:00:00Z', + components: [ + {path: 'manuscript/main.md', kind: 'manuscript', retainedUntil: '2031-01-01', archived: true, hash: 'sha256:manuscript'}, + {path: 'data/assay.csv', kind: 'dataset', retainedUntil: '2031-01-01', archived: false, hash: 'sha256:data'}, + {path: 'results/figure.svg', kind: 'result', retainedUntil: '2031-01-01', archived: true, hash: 'sha256:figure'}, + ], + legalHolds: [], + exportBundles: [ + {id: 'bundle-v1.0.0', version: 'v1.0.0', manifestHash: '', storage: ['doi']}, + ], + }); + + assert.equal(result.decision, 'needs-remediation'); + assert.deepEqual(result.blockers, [ + 'data/assay.csv lacks archive evidence', + 'bundle-v1.0.0 lacks a manifest hash', + ]); + assert.equal(result.requiredActions.length, 2); +}); + +test('builds deterministic reviewer report for compliant repository release', () => { + const result = evaluateRepositoryRetention({ + repositoryId: 'repo-compliant-release', + requestedAction: 'publish-version', + taggedVersion: 'v3.0.0', + generatedAt: '2026-05-22T12:00:00Z', + components: [ + {path: 'manuscript/main.md', kind: 'manuscript', retainedUntil: '2035-01-01', archived: true, hash: 'sha256:a'}, + {path: 'data/raw.csv', kind: 'dataset', retainedUntil: '2035-01-01', archived: true, hash: 'sha256:b'}, + {path: 'code/reproduce.py', kind: 'code', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:c'}, + {path: 'results/model.bin', kind: 'result', retainedUntil: '2032-01-01', archived: true, hash: 'sha256:d'}, + ], + legalHolds: [{id: 'hold-old', active: false, scope: ['data/'], reason: 'released'}], + exportBundles: [ + {id: 'bundle-v3.0.0', version: 'v3.0.0', manifestHash: 'sha256:bundle', storage: ['doi', 'cold-archive', 'institutional-copy']}, + ], + }); + + assert.equal(result.decision, 'approved'); + assert.equal(result.blockers.length, 0); + assert.equal(result.coverage.componentCount, 4); + assert.equal(result.coverage.archiveCoverage, 1); + + const report = buildRetentionReport(result); + assert.match(report, /repo-compliant-release/); + assert.match(report, /Decision: approved/); + assert.match(report, /Archive coverage: 100%/); +});