Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions repository-retention-legal-hold-gate/README.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions repository-retention-legal-hold-gate/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#0f172a"/>
<text x="48" y="72" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="34" font-weight="700">Repository Retention Legal Hold Gate</text>
<text x="48" y="112" fill="#94a3b8" font-family="Arial, sans-serif" font-size="18">Synthetic review packet for project repository release governance</text>
<g transform="translate(48 160)">
<rect width="250" height="160" rx="14" fill="#14532d"/>
<text x="24" y="52" fill="#dcfce7" font-family="Arial, sans-serif" font-size="20" font-weight="700">Approved</text>
<text x="24" y="112" fill="#f0fdf4" font-family="Arial, sans-serif" font-size="58" font-weight="700">${approved}</text>
</g>
<g transform="translate(354 160)">
<rect width="250" height="160" rx="14" fill="#713f12"/>
<text x="24" y="52" fill="#fef3c7" font-family="Arial, sans-serif" font-size="20" font-weight="700">Needs Remediation</text>
<text x="24" y="112" fill="#fffbeb" font-family="Arial, sans-serif" font-size="58" font-weight="700">${remediation}</text>
</g>
<g transform="translate(660 160)">
<rect width="250" height="160" rx="14" fill="#7f1d1d"/>
<text x="24" y="52" fill="#fee2e2" font-family="Arial, sans-serif" font-size="20" font-weight="700">Blocked</text>
<text x="24" y="112" fill="#fef2f2" font-family="Arial, sans-serif" font-size="58" font-weight="700">${blocked}</text>
</g>
<text x="48" y="390" fill="#cbd5e1" font-family="Arial, sans-serif" font-size="22">Checks: legal holds, archive evidence, content hashes, export manifest hashes</text>
<text x="48" y="430" fill="#94a3b8" font-family="Arial, sans-serif" font-size="18">Outputs: decision.json, report.md, retention-gate.svg, demo video artifact</text>
<text x="48" y="478" fill="#64748b" font-family="Arial, sans-serif" font-size="16">No real secrets, patient data, private repositories, or external service calls.</text>
</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}`);
150 changes: 150 additions & 0 deletions repository-retention-legal-hold-gate/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
75 changes: 75 additions & 0 deletions repository-retention-legal-hold-gate/reports/decision.json
Original file line number Diff line number Diff line change
@@ -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
}
}
]
Binary file not shown.
72 changes: 72 additions & 0 deletions repository-retention-legal-hold-gate/reports/report.md
Original file line number Diff line number Diff line change
@@ -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
Loading