From 3f8ed1b47d03f313e12ad8940c2a9e630469db72 Mon Sep 17 00:00:00 2001 From: Da7-Tech <286182457+Da7-Tech@users.noreply.github.com> Date: Wed, 20 May 2026 08:00:22 +0300 Subject: [PATCH] Add enterprise funder reporting export gate --- .../README.md | 29 +++ .../acceptance-notes.md | 26 ++ .../demo.js | 30 +++ .../demo.svg | 22 ++ .../index.js | 238 ++++++++++++++++++ .../requirements-map.md | 11 + .../test.js | 70 ++++++ 7 files changed, 426 insertions(+) create mode 100644 enterprise-funder-reporting-export-gate/README.md create mode 100644 enterprise-funder-reporting-export-gate/acceptance-notes.md create mode 100644 enterprise-funder-reporting-export-gate/demo.js create mode 100644 enterprise-funder-reporting-export-gate/demo.svg create mode 100644 enterprise-funder-reporting-export-gate/index.js create mode 100644 enterprise-funder-reporting-export-gate/requirements-map.md create mode 100644 enterprise-funder-reporting-export-gate/test.js diff --git a/enterprise-funder-reporting-export-gate/README.md b/enterprise-funder-reporting-export-gate/README.md new file mode 100644 index 0000000..6081d86 --- /dev/null +++ b/enterprise-funder-reporting-export-gate/README.md @@ -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. diff --git a/enterprise-funder-reporting-export-gate/acceptance-notes.md b/enterprise-funder-reporting-export-gate/acceptance-notes.md new file mode 100644 index 0000000..e824fce --- /dev/null +++ b/enterprise-funder-reporting-export-gate/acceptance-notes.md @@ -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. diff --git a/enterprise-funder-reporting-export-gate/demo.js b/enterprise-funder-reporting-export-gate/demo.js new file mode 100644 index 0000000..e24fae8 --- /dev/null +++ b/enterprise-funder-reporting-export-gate/demo.js @@ -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(); diff --git a/enterprise-funder-reporting-export-gate/demo.svg b/enterprise-funder-reporting-export-gate/demo.svg new file mode 100644 index 0000000..0d85807 --- /dev/null +++ b/enterprise-funder-reporting-export-gate/demo.svg @@ -0,0 +1,22 @@ + + Enterprise funder reporting export gate demo + A static demo frame showing two ready projects and one blocked project with signed export evidence. + + + Enterprise funder reporting export gate + Issue 19 slice: funder mandate evidence, export readiness, and signed webhook events. + + 2 + ready exports + + 1 + blocked export + + sha + signed events + + Checks + Mandate id, open access, reproducibility score, artifact hashes, destination routing. + Outputs portfolio digest, export packet digest, and webhook-ready governance payloads. + Run: node enterprise-funder-reporting-export-gate/test.js and node enterprise-funder-reporting-export-gate/demo.js + diff --git a/enterprise-funder-reporting-export-gate/index.js b/enterprise-funder-reporting-export-gate/index.js new file mode 100644 index 0000000..8eb1290 --- /dev/null +++ b/enterprise-funder-reporting-export-gate/index.js @@ -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, +}; diff --git a/enterprise-funder-reporting-export-gate/requirements-map.md b/enterprise-funder-reporting-export-gate/requirements-map.md new file mode 100644 index 0000000..6b23d60 --- /dev/null +++ b/enterprise-funder-reporting-export-gate/requirements-map.md @@ -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` | diff --git a/enterprise-funder-reporting-export-gate/test.js b/enterprise-funder-reporting-export-gate/test.js new file mode 100644 index 0000000..fca91a7 --- /dev/null +++ b/enterprise-funder-reporting-export-gate/test.js @@ -0,0 +1,70 @@ +const assert = require("assert"); + +const { + chooseDestination, + collectBlockers, + digest, + evaluatePortfolio, + evaluateProject, + sampleProjects, +} = require("./index"); + +function run() { + const readyProject = sampleProjects[0]; + const embargoProject = sampleProjects[1]; + const lowReproProject = sampleProjects[2]; + + const readyResult = evaluateProject(readyProject); + assert.strictEqual(readyResult.status, "ready"); + assert.deepStrictEqual(readyResult.blockers, []); + assert.strictEqual(readyResult.exportPacket.destination, "pubmed-central"); + assert.strictEqual(readyResult.webhookEvent.blockerCount, 0); + + const embargoResult = evaluateProject(embargoProject); + assert.strictEqual(embargoResult.status, "ready"); + assert.ok( + embargoResult.warnings.some((warning) => warning.includes("embargo until")), + ); + assert.strictEqual(embargoResult.exportPacket.destination, "zenodo"); + + const lowReproResult = evaluateProject(lowReproProject); + assert.strictEqual(lowReproResult.status, "blocked"); + assert.ok( + lowReproResult.blockers.some((blocker) => + blocker.includes("Reproducibility score"), + ), + ); + assert.ok(lowReproResult.warnings.some((warning) => warning.includes("DOI"))); + + assert.strictEqual(chooseDestination(readyProject), "pubmed-central"); + assert.strictEqual( + chooseDestination({ ...readyProject, funder: "unknown", destinations: [] }), + null, + ); + + const missingArtifact = { + ...readyProject, + artifacts: { + ...readyProject.artifacts, + dataset: {}, + }, + }; + assert.ok( + collectBlockers(missingArtifact).blockers.includes( + "Missing dataset artifact hash.", + ), + ); + + const portfolio = evaluatePortfolio(sampleProjects); + assert.strictEqual(portfolio.total, 3); + assert.strictEqual(portfolio.ready, 2); + assert.strictEqual(portfolio.blocked, 1); + + const firstDigest = digest(readyResult.webhookEvent); + const secondDigest = digest(evaluateProject(readyProject).webhookEvent); + assert.strictEqual(firstDigest, secondDigest); + + console.log("enterprise funder reporting export gate tests passed"); +} + +run();