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 = `
+`;
+
+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%/);
+});