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
29 changes: 29 additions & 0 deletions enterprise-funder-reporting-export-gate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Enterprise Funder Reporting Export Gate

This module is a self-contained Enterprise Tooling slice for institutional research administrators. It evaluates whether a research project is ready to be exported to funder and repository reporting channels.

It uses synthetic data only and has no external dependencies.

## What It Checks

- Funder mandate evidence is present.
- Open-access status or embargo evidence is clear.
- Reproducibility scores meet the institutional threshold.
- Manuscript, dataset, code, and metadata artifacts have hashes and licenses.
- Repository routing matches funder expectations.
- Webhook-ready governance events receive deterministic signatures.

## Run

```bash
node enterprise-funder-reporting-export-gate/test.js
node enterprise-funder-reporting-export-gate/demo.js
```

## Files

- `index.js` contains the evaluator, export packet builder, and webhook event signer.
- `test.js` validates ready, blocked, warning, routing, artifact, portfolio, and digest behaviour.
- `demo.js` prints an admin-style readiness table plus webhook event payloads.
- `requirements-map.md` maps the issue requirements to implementation coverage.
- `acceptance-notes.md` describes how this slice fits issue 19.
26 changes: 26 additions & 0 deletions enterprise-funder-reporting-export-gate/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Acceptance Notes

This PR addresses issue 19 with a distinct Enterprise Tooling slice: an enterprise funder reporting export gate.

## Scope

- It does not duplicate previous dashboard, incident response, secret rotation, quota, connector certification, or API change submissions.
- It focuses on funder and repository export readiness for institutional admins.
- It stays self-contained, credential-free, and synthetic-data-only.

## Verification

```bash
node enterprise-funder-reporting-export-gate/test.js
node enterprise-funder-reporting-export-gate/demo.js
git diff --check
```

## Acceptance Checklist

- Admin-ready portfolio metrics are produced.
- Funder mandate and open-access compliance are evaluated.
- Reproducibility score gates can block export.
- Export destination routing is funder-aware.
- Artifact hashes and licenses are preserved in export packets.
- Webhook-ready governance events are deterministically signed.
30 changes: 30 additions & 0 deletions enterprise-funder-reporting-export-gate/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const { evaluatePortfolio, sampleProjects } = require("./index");

function runDemo() {
const portfolio = evaluatePortfolio(sampleProjects);
const rows = portfolio.results.map((result) => ({
project: result.projectId,
status: result.status,
destination: result.exportPacket.destination || "none",
blockers: result.blockers.length,
warnings: result.warnings.length,
digest: result.exportPacket.evidenceDigest.slice(0, 12),
}));

console.table(rows);
console.log(
JSON.stringify(
{
ready: portfolio.ready,
blocked: portfolio.blocked,
total: portfolio.total,
portfolioDigest: portfolio.portfolioDigest,
webhookEvents: portfolio.results.map((result) => result.webhookEvent),
},
null,
2,
),
);
}

runDemo();
22 changes: 22 additions & 0 deletions enterprise-funder-reporting-export-gate/demo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
238 changes: 238 additions & 0 deletions enterprise-funder-reporting-export-gate/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
const crypto = require("crypto");

const DEFAULT_DESTINATIONS = {
nih: ["pubmed-central", "institutional-repository"],
horizon: ["zenodo", "institutional-repository"],
ukri: ["institutional-repository", "zenodo"],
internal: ["institutional-repository"],
};

const REQUIRED_ARTIFACTS = ["manuscript", "dataset", "code", "metadata"];

const sampleProjects = [
{
id: "proj-ready-neuro-001",
title: "Open electrophysiology reproducibility pack",
funder: "nih",
mandateId: "NIH-OS-2026-15",
openAccess: true,
reproducibilityScore: 0.92,
embargoUntil: null,
destinations: ["pubmed-central", "institutional-repository"],
orcidCoverage: 1,
doiReserved: true,
dataUseAgreement: true,
artifacts: {
manuscript: { sha256: "a3b1", license: "cc-by-4.0" },
dataset: { sha256: "b4c2", license: "cc0-1.0" },
code: { sha256: "c5d3", license: "mit" },
metadata: { sha256: "d6e4", license: "cc0-1.0" },
},
},
{
id: "proj-embargo-gap-002",
title: "Clinical cohort embargo exception",
funder: "horizon",
mandateId: "HE-OPEN-2026-44",
openAccess: false,
reproducibilityScore: 0.86,
embargoUntil: "2026-08-01",
destinations: ["zenodo"],
orcidCoverage: 0.83,
doiReserved: true,
dataUseAgreement: true,
artifacts: {
manuscript: { sha256: "f7g5", license: "cc-by-4.0" },
dataset: { sha256: "g8h6", license: "restricted" },
code: { sha256: "h9i7", license: "apache-2.0" },
metadata: { sha256: "i1j8", license: "cc0-1.0" },
},
},
{
id: "proj-low-repro-003",
title: "Protein fold benchmark notebook",
funder: "ukri",
mandateId: "UKRI-FAIR-2026-09",
openAccess: true,
reproducibilityScore: 0.58,
embargoUntil: null,
destinations: ["institutional-repository"],
orcidCoverage: 0.95,
doiReserved: false,
dataUseAgreement: true,
artifacts: {
manuscript: { sha256: "j2k9", license: "cc-by-4.0" },
dataset: { sha256: "k3l0", license: "cc-by-4.0" },
code: { sha256: "l4m1", license: "mit" },
metadata: { sha256: "m5n2", license: "cc0-1.0" },
},
},
];

function stableJson(value) {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(",")}]`;
}

if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(",")}}`;
}

return JSON.stringify(value);
}

function digest(value) {
return crypto.createHash("sha256").update(stableJson(value)).digest("hex");
}

function getSupportedDestinations(funder, destinations = DEFAULT_DESTINATIONS) {
return destinations[funder] || destinations.internal;
}

function chooseDestination(project, destinations = DEFAULT_DESTINATIONS) {
const supported = getSupportedDestinations(project.funder, destinations);
const selected = project.destinations.find((destination) =>
supported.includes(destination),
);

return selected || null;
}

function collectBlockers(project, options = {}) {
const minReproducibilityScore = options.minReproducibilityScore ?? 0.8;
const blockers = [];
const warnings = [];

if (!project.mandateId) {
blockers.push("Missing funder mandate identifier.");
}

if (!project.openAccess) {
if (project.embargoUntil) {
warnings.push(`Open access delayed by embargo until ${project.embargoUntil}.`);
} else {
blockers.push("Open access requirement is not satisfied.");
}
}

if (project.reproducibilityScore < minReproducibilityScore) {
blockers.push(
`Reproducibility score ${project.reproducibilityScore} is below ${minReproducibilityScore}.`,
);
}

for (const artifact of REQUIRED_ARTIFACTS) {
if (!project.artifacts[artifact]?.sha256) {
blockers.push(`Missing ${artifact} artifact hash.`);
}
}

if (project.orcidCoverage < 0.9) {
warnings.push("ORCID coverage is below institutional reporting target.");
}

if (!project.doiReserved) {
warnings.push("DOI has not been reserved yet.");
}

if (!project.dataUseAgreement) {
blockers.push("Missing data-use agreement evidence.");
}

if (!chooseDestination(project, options.destinations)) {
blockers.push(`No supported export destination for funder ${project.funder}.`);
}

return { blockers, warnings };
}

function buildExportPacket(project, options = {}) {
const destination = chooseDestination(project, options.destinations);
const artifactManifest = REQUIRED_ARTIFACTS.map((artifact) => ({
type: artifact,
sha256: project.artifacts[artifact]?.sha256 || null,
license: project.artifacts[artifact]?.license || null,
}));
const packet = {
projectId: project.id,
title: project.title,
funder: project.funder,
mandateId: project.mandateId,
destination,
openAccess: project.openAccess,
reproducibilityScore: project.reproducibilityScore,
artifacts: artifactManifest,
reportingVersion: "2026.05",
};

return {
...packet,
evidenceDigest: digest(packet),
};
}

function buildWebhookEvent(project, gateResult) {
const event = {
type: "enterprise.funder_export_gate.evaluated",
projectId: project.id,
status: gateResult.status,
destination: gateResult.exportPacket.destination,
blockerCount: gateResult.blockers.length,
warningCount: gateResult.warnings.length,
evidenceDigest: gateResult.exportPacket.evidenceDigest,
};

return {
...event,
signature: digest(event),
};
}

function evaluateProject(project, options = {}) {
const { blockers, warnings } = collectBlockers(project, options);
const exportPacket = buildExportPacket(project, options);
const result = {
projectId: project.id,
status: blockers.length === 0 ? "ready" : "blocked",
blockers,
warnings,
exportPacket,
};

return {
...result,
webhookEvent: buildWebhookEvent(project, result),
};
}

function evaluatePortfolio(projects, options = {}) {
const results = projects.map((project) => evaluateProject(project, options));
const ready = results.filter((result) => result.status === "ready").length;
const blocked = results.length - ready;

return {
ready,
blocked,
total: results.length,
results,
portfolioDigest: digest(results.map((result) => result.webhookEvent)),
};
}

module.exports = {
DEFAULT_DESTINATIONS,
REQUIRED_ARTIFACTS,
sampleProjects,
stableJson,
digest,
getSupportedDestinations,
chooseDestination,
collectBlockers,
buildExportPacket,
buildWebhookEvent,
evaluateProject,
evaluatePortfolio,
};
11 changes: 11 additions & 0 deletions enterprise-funder-reporting-export-gate/requirements-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Requirements Map

| Issue 19 area | Implementation coverage | Evidence |
| --- | --- | --- |
| Admin dashboards | Portfolio summary reports ready, blocked, and total projects with blocker and warning counts. | `evaluatePortfolio()` and `demo.js` table output |
| Compliance tracking | Funder mandate, open access, embargo, reproducibility score, DOI, ORCID, and data-use checks are evaluated per project. | `collectBlockers()` and `test.js` |
| API and webhooks | Each evaluation emits a signed webhook-ready event with status, destination, and evidence digest. | `buildWebhookEvent()` |
| Export pipelines | Projects route to supported funder destinations such as PubMed Central, Zenodo, or institutional repositories. | `chooseDestination()` and `buildExportPacket()` |
| Metadata preservation | Export packets include artifact hashes, licenses, mandate id, destination, and reproducibility score. | `buildExportPacket()` |
| Institutional scale | Portfolio digest gives admins one immutable digest for a batch of evaluated projects. | `evaluatePortfolio()` |
| Local verification | Dependency-free tests cover ready, warning, blocked, routing, missing artifact, portfolio, and deterministic digest cases. | `node enterprise-funder-reporting-export-gate/test.js` |
Loading