From 8ce932b99aefef4ac3ca338854218cfc94cc9f9b Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 19:16:06 +0700 Subject: [PATCH] Add repository retention legal hold gate --- .../README.md | 35 ++++ repository-retention-legal-hold-gate/demo.js | 52 ++++++ repository-retention-legal-hold-gate/index.js | 150 ++++++++++++++++++ .../reports/decision.json | 75 +++++++++ .../reports/demo.mp4 | Bin 0 -> 21652 bytes .../reports/report.md | 72 +++++++++ .../reports/retention-gate.svg | 23 +++ .../sample-data.js | 55 +++++++ repository-retention-legal-hold-gate/test.js | 86 ++++++++++ 9 files changed, 548 insertions(+) create mode 100644 repository-retention-legal-hold-gate/README.md create mode 100644 repository-retention-legal-hold-gate/demo.js create mode 100644 repository-retention-legal-hold-gate/index.js create mode 100644 repository-retention-legal-hold-gate/reports/decision.json create mode 100644 repository-retention-legal-hold-gate/reports/demo.mp4 create mode 100644 repository-retention-legal-hold-gate/reports/report.md create mode 100644 repository-retention-legal-hold-gate/reports/retention-gate.svg create mode 100644 repository-retention-legal-hold-gate/sample-data.js create mode 100644 repository-retention-legal-hold-gate/test.js 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 0000000000000000000000000000000000000000..bc25bb42107f91eef955465376aa6d1362444e85 GIT binary patch literal 21652 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 + + 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%/); +});