diff --git a/README.md b/README.md index d338cf68..ae6fddbe 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # deepevents.ai deepevents.ai main codebase + +- `enterprise-grant-portfolio-compliance/` adds deterministic grant and funder mandate compliance tracking for institutional research portfolios. diff --git a/enterprise-grant-portfolio-compliance/README.md b/enterprise-grant-portfolio-compliance/README.md new file mode 100644 index 00000000..23410491 --- /dev/null +++ b/enterprise-grant-portfolio-compliance/README.md @@ -0,0 +1,39 @@ +# Enterprise Grant Portfolio Compliance + +Institutional research offices need a grant-level view of funder obligations before they certify exports or submit reports. This module turns synthetic grant records into deterministic compliance decisions, admin metrics, action queues, export readiness, and signed event payloads. + +## What It Covers + +- Funder mandate checks for open access, data management plans, repository deposits, DOI evidence, ORCID coverage, and report deadlines. +- Portfolio metrics for institutional admins by department and funder. +- Export readiness decisions for grant and funder portal workflows. +- HMAC-signed event payloads that can be forwarded to institutional integration layers. +- Synthetic sample data only; no credentials or real institution records. + +## Run It + +```bash +npm run check +npm test +npm run demo +``` + +## Demo Output + +```text +Enterprise grant portfolio compliance demo for Northbridge Institute of Translational Science +As of 2026-05-16: 1 ready, 1 needs review, 1 blocked +Average score: 63 +NIH-R01-NEURO-142: blocked score=0 blockers=6 warnings=4 +HORIZON-CLIMATE-782: ready score=100 blockers=0 warnings=0 +UKRI-QUANTUM-008: needs_review score=88 blockers=0 warnings=1 +Signed event sample: NIH-R01-NEURO-142 digest=... signature=... +``` + +## Files + +- `src/grant-portfolio-compliance.js` - dependency-free mandate evaluator and signing helpers. +- `data/sample-grant-portfolio.json` - synthetic institutional grant portfolio. +- `test/grant-portfolio-compliance.test.js` - coverage for decisions, obligations, dates, and deterministic event signatures. +- `docs/requirement-map.md` - issue #19 requirement mapping and non-overlap notes. +- `docs/demo.svg` and `docs/demo.gif` - short visual demo artifacts for review. diff --git a/enterprise-grant-portfolio-compliance/data/sample-grant-portfolio.json b/enterprise-grant-portfolio-compliance/data/sample-grant-portfolio.json new file mode 100644 index 00000000..5052afa2 --- /dev/null +++ b/enterprise-grant-portfolio-compliance/data/sample-grant-portfolio.json @@ -0,0 +1,277 @@ +{ + "asOf": "2026-05-16", + "institution": { + "id": "inst-northbridge", + "name": "Northbridge Institute of Translational Science", + "adminOffice": "Research Integrity and Sponsored Programs", + "timezone": "America/New_York" + }, + "grants": [ + { + "grantId": "NIH-R01-NEURO-142", + "awardNumber": "R01NS014200", + "funder": "National Institutes of Health", + "program": "BRAIN Initiative", + "principalInvestigator": "Dr. Mara Okafor", + "department": "Neuroscience", + "tags": ["GRANT-TRACKED", "HUMAN-SUBJECTS"], + "mandates": { + "openAccess": { + "required": true, + "deadlineDaysAfterPublication": 30 + }, + "dataManagementPlan": { + "required": true, + "acceptedStatuses": ["approved", "exempt"] + }, + "repositoryDeposit": { + "required": true, + "allowedRepositories": ["PubMed Central", "Zenodo", "Institutional DSpace"], + "deadlineDaysAfterPublication": 30 + }, + "persistentIdentifiers": { + "doiRequiredFor": ["article", "dataset"], + "orcidForContributors": true + }, + "reports": [ + { + "type": "annual-progress", + "dueDate": "2026-05-30", + "status": "draft" + }, + { + "type": "public-access-final", + "dueDate": "2026-05-10", + "status": "not_started" + } + ] + }, + "projects": [ + { + "projectId": "proj-neuro-atlas", + "title": "Atlas of recovery markers after neural stimulation", + "contributors": [ + { + "name": "Mara Okafor", + "role": "PI", + "orcid": "0000-0002-0182-0097" + }, + { + "name": "Jin Park", + "role": "Data steward", + "orcid": "" + } + ], + "dataManagementPlan": { + "status": "missing", + "lastReviewedAt": null + }, + "outputs": [ + { + "outputId": "out-neuro-preprint", + "kind": "article", + "title": "Recovery markers in cortical stimulation cohorts", + "publicationDate": "2026-04-01", + "doi": "", + "openAccess": { + "status": "closed", + "scheduledDate": null + }, + "repositoryDeposits": [] + }, + { + "outputId": "out-neuro-signals", + "kind": "dataset", + "title": "Synthetic cortical signal feature table", + "publicationDate": "2026-04-01", + "doi": "", + "openAccess": { + "status": "restricted", + "scheduledDate": null + }, + "repositoryDeposits": [ + { + "repository": "Lab SharePoint", + "status": "internal_only", + "depositedAt": null + } + ] + } + ] + } + ] + }, + { + "grantId": "HORIZON-CLIMATE-782", + "awardNumber": "HORIZON-RIA-782", + "funder": "Horizon Europe", + "program": "Climate Resilience Missions", + "principalInvestigator": "Dr. Leila Varga", + "department": "Earth Systems", + "tags": ["GRANT-TRACKED", "EU-FUNDER"], + "mandates": { + "openAccess": { + "required": true, + "deadlineDaysAfterPublication": 0 + }, + "dataManagementPlan": { + "required": true, + "acceptedStatuses": ["approved"] + }, + "repositoryDeposit": { + "required": true, + "allowedRepositories": ["Zenodo", "Institutional DSpace"], + "deadlineDaysAfterPublication": 0 + }, + "persistentIdentifiers": { + "doiRequiredFor": ["article", "dataset", "software"], + "orcidForContributors": true + }, + "reports": [ + { + "type": "periodic-technical", + "dueDate": "2026-06-30", + "status": "submitted" + } + ] + }, + "projects": [ + { + "projectId": "proj-climate-resilience", + "title": "City-scale resilience projections", + "contributors": [ + { + "name": "Leila Varga", + "role": "PI", + "orcid": "0000-0003-2088-7711" + }, + { + "name": "Tomas Weber", + "role": "Model lead", + "orcid": "0000-0001-4455-9304" + } + ], + "dataManagementPlan": { + "status": "approved", + "lastReviewedAt": "2026-04-20" + }, + "outputs": [ + { + "outputId": "out-climate-model", + "kind": "software", + "title": "Synthetic flood-risk model package", + "publicationDate": "2026-05-01", + "doi": "10.5281/zenodo.7821001", + "openAccess": { + "status": "open", + "scheduledDate": "2026-05-01" + }, + "repositoryDeposits": [ + { + "repository": "Zenodo", + "status": "deposited", + "depositedAt": "2026-05-01" + } + ] + }, + { + "outputId": "out-climate-dataset", + "kind": "dataset", + "title": "Synthetic urban sensor panel", + "publicationDate": "2026-05-01", + "doi": "10.5281/zenodo.7821002", + "openAccess": { + "status": "open", + "scheduledDate": "2026-05-01" + }, + "repositoryDeposits": [ + { + "repository": "Zenodo", + "status": "deposited", + "depositedAt": "2026-05-01" + } + ] + } + ] + } + ] + }, + { + "grantId": "UKRI-QUANTUM-008", + "awardNumber": "EP/X008008/1", + "funder": "UK Research and Innovation", + "program": "Quantum Technologies", + "principalInvestigator": "Dr. Imogen Shaw", + "department": "Physics", + "tags": ["DOCTORAL-WORK", "GRANT-TRACKED"], + "mandates": { + "openAccess": { + "required": true, + "deadlineDaysAfterPublication": 90 + }, + "dataManagementPlan": { + "required": true, + "acceptedStatuses": ["approved", "exempt"] + }, + "repositoryDeposit": { + "required": true, + "allowedRepositories": ["Institutional DSpace", "Zenodo"], + "deadlineDaysAfterPublication": 90 + }, + "persistentIdentifiers": { + "doiRequiredFor": ["article", "dataset"], + "orcidForContributors": true + }, + "reports": [ + { + "type": "impact-statement", + "dueDate": "2026-05-22", + "status": "draft" + } + ] + }, + "projects": [ + { + "projectId": "proj-quantum-calibration", + "title": "Calibration traces for quantum sensor benches", + "contributors": [ + { + "name": "Imogen Shaw", + "role": "PI", + "orcid": "0000-0002-7744-1119" + }, + { + "name": "Ari Patel", + "role": "Doctoral researcher", + "orcid": "0000-0002-1010-4040" + } + ], + "dataManagementPlan": { + "status": "approved", + "lastReviewedAt": "2026-05-05" + }, + "outputs": [ + { + "outputId": "out-quantum-paper", + "kind": "article", + "title": "Calibration drift in synthetic quantum sensors", + "publicationDate": "2026-05-11", + "doi": "10.5555/ukri.quantum.008", + "openAccess": { + "status": "scheduled", + "scheduledDate": "2026-06-15" + }, + "repositoryDeposits": [ + { + "repository": "Institutional DSpace", + "status": "scheduled", + "depositedAt": "2026-06-15" + } + ] + } + ] + } + ] + } + ] +} diff --git a/enterprise-grant-portfolio-compliance/docs/demo.gif b/enterprise-grant-portfolio-compliance/docs/demo.gif new file mode 100644 index 00000000..1d8e528b Binary files /dev/null and b/enterprise-grant-portfolio-compliance/docs/demo.gif differ diff --git a/enterprise-grant-portfolio-compliance/docs/demo.svg b/enterprise-grant-portfolio-compliance/docs/demo.svg new file mode 100644 index 00000000..ea8694f5 --- /dev/null +++ b/enterprise-grant-portfolio-compliance/docs/demo.svg @@ -0,0 +1,37 @@ + + Enterprise grant portfolio compliance demo + A static demo frame showing grant compliance scores, export decisions, and signed event evidence. + + + Grant Portfolio Compliance + Northbridge Institute - as of 2026-05-16 + + + Ready + 1 + + Needs review + 1 + + Blocked + 1 + Grant + Decision + Score + Primary action + + HORIZON-CLIMATE-782 + ready + 100 + Export package can be certified + UKRI-QUANTUM-008 + needs_review + 88 + Impact statement due in 6 days + NIH-R01-NEURO-142 + blocked + 0 + Resolve overdue report and evidence gaps + + Signed event digest sample is produced by npm run demo for webhook-ready integrations. + diff --git a/enterprise-grant-portfolio-compliance/docs/requirement-map.md b/enterprise-grant-portfolio-compliance/docs/requirement-map.md new file mode 100644 index 00000000..636505a8 --- /dev/null +++ b/enterprise-grant-portfolio-compliance/docs/requirement-map.md @@ -0,0 +1,14 @@ +# Enterprise Grant Portfolio Compliance Requirement Map + +| Issue #19 capability | Implementation evidence | +| --- | --- | +| Organization-wide dashboard for admins | `evaluatePortfolio()` returns `summary`, `adminMetrics.byDepartment`, `adminMetrics.byFunder`, and `topRisks` for a research office portfolio view. | +| Compliance tracking for funder mandates and open access status | `src/grant-portfolio-compliance.js` evaluates open access, repository deposit, data management plan, DOI, ORCID, and report due-date obligations. | +| Export pipelines to grant and funder portals | `buildExportManifest()` produces ready, blocked, and review grant sets plus required evidence fields for institutional export workflows. | +| Secure API and webhook support | `signEvent()` creates deterministic SHA-256 digests and HMAC signatures for downstream webhook/event delivery without external credentials. | +| Research operations analytics | The module calculates compliance scores, risk levels, blockers, warnings, action owners, and top-risk grants from synthetic portfolio records. | +| Reviewer-ready proof | `npm run check`, `npm test`, `npm run demo`, `docs/demo.svg`, and `docs/demo.gif` provide local verification and a short demo artifact. | + +## Non-overlap Notes + +This slice focuses on grant-portfolio obligations and funder mandate certification. It does not reimplement broad admin dashboards, export package builders, webhook replay ledgers, identity provisioning drift, legal holds, or data residency controls submitted in earlier issue #19 PRs. diff --git a/enterprise-grant-portfolio-compliance/package.json b/enterprise-grant-portfolio-compliance/package.json new file mode 100644 index 00000000..98240d3a --- /dev/null +++ b/enterprise-grant-portfolio-compliance/package.json @@ -0,0 +1,20 @@ +{ + "name": "enterprise-grant-portfolio-compliance", + "version": "1.0.0", + "description": "Deterministic grant and funder mandate compliance tooling for institutional research portfolios.", + "main": "src/grant-portfolio-compliance.js", + "scripts": { + "check": "node --check src/grant-portfolio-compliance.js && node --check scripts/demo.js && node --check scripts/build-demo-gif.js && node --check test/grant-portfolio-compliance.test.js", + "test": "node --test test/*.test.js", + "demo": "node scripts/demo.js", + "demo:gif": "node scripts/build-demo-gif.js" + }, + "keywords": [ + "enterprise", + "grants", + "compliance", + "funder-mandates", + "research-operations" + ], + "license": "MIT" +} diff --git a/enterprise-grant-portfolio-compliance/scripts/build-demo-gif.js b/enterprise-grant-portfolio-compliance/scripts/build-demo-gif.js new file mode 100644 index 00000000..194634ad --- /dev/null +++ b/enterprise-grant-portfolio-compliance/scripts/build-demo-gif.js @@ -0,0 +1,190 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const width = 480; +const height = 270; +const colors = [ + [246, 247, 249], + [255, 255, 255], + [21, 93, 54], + [115, 89, 0], + [138, 31, 24], + [205, 212, 223], + [83, 96, 113], + [29, 36, 51] +]; + +function u16(value) { + return Buffer.from([value & 255, (value >> 8) & 255]); +} + +function subBlocks(data) { + const parts = []; + + for (let index = 0; index < data.length; index += 255) { + const chunk = data.subarray(index, index + 255); + parts.push(Buffer.from([chunk.length]), chunk); + } + + parts.push(Buffer.from([0])); + return Buffer.concat(parts); +} + +function rect(pixels, x, y, w, h, color) { + const left = Math.max(0, x); + const top = Math.max(0, y); + const right = Math.min(width, x + w); + const bottom = Math.min(height, y + h); + + for (let yy = top; yy < bottom; yy += 1) { + for (let xx = left; xx < right; xx += 1) { + pixels[yy * width + xx] = color; + } + } +} + +function digits(pixels, x, y, value, color) { + const glyphs = { + 0: ["111", "101", "101", "101", "111"], + 1: ["010", "110", "010", "010", "111"], + 2: ["111", "001", "111", "100", "111"], + 3: ["111", "001", "111", "001", "111"], + 4: ["101", "101", "111", "001", "001"], + 5: ["111", "100", "111", "001", "111"], + 6: ["111", "100", "111", "101", "111"], + 7: ["111", "001", "010", "010", "010"], + 8: ["111", "101", "111", "101", "111"], + 9: ["111", "101", "111", "001", "111"] + }; + + String(value) + .split("") + .forEach((digit, index) => { + glyphs[digit].forEach((row, rowIndex) => { + row.split("").forEach((bit, colIndex) => { + if (bit === "1") { + rect(pixels, x + index * 18 + colIndex * 5, y + rowIndex * 5, 4, 4, color); + } + }); + }); + }); +} + +function bar(pixels, x, y, value, color) { + rect(pixels, x, y, 260, 18, 5); + rect(pixels, x + 2, y + 2, Math.round(256 * value), 14, color); +} + +function frame(step) { + const pixels = new Uint8Array(width * height).fill(0); + rect(pixels, 24, 20, 432, 230, 1); + rect(pixels, 24, 20, 432, 2, 5); + rect(pixels, 24, 248, 432, 2, 5); + rect(pixels, 24, 20, 2, 230, 5); + rect(pixels, 454, 20, 2, 230, 5); + rect(pixels, 44, 46, 300, 10, 7); + rect(pixels, 44, 64, 220, 5, 6); + + [ + [44, 88, 2, "1"], + [184, 88, 3, "1"], + [324, 88, 4, "1"] + ].forEach(([x, y, color, label], index) => { + rect(pixels, x, y, 108, 46, step >= index ? color : 5); + rect(pixels, x + 4, y + 4, 100, 38, step >= index ? 1 : 0); + digits(pixels, x + 42, y + 12, label, color); + }); + + rect(pixels, 44, 156, 56, 10, 7); + rect(pixels, 44, 184, 56, 10, 7); + rect(pixels, 44, 212, 56, 10, 7); + bar(pixels, 118, 156, step >= 0 ? 1 : 0.15, 2); + bar(pixels, 118, 184, step >= 1 ? 0.88 : 0.15, 3); + bar(pixels, 118, 212, step >= 2 ? 0.12 : 0.15, 4); + digits(pixels, 392, 152, step >= 0 ? "100" : "0", step >= 0 ? 2 : 6); + digits(pixels, 404, 180, step >= 1 ? "88" : "0", step >= 1 ? 3 : 6); + digits(pixels, 416, 208, "0", step >= 2 ? 4 : 6); + return pixels; +} + +function lzwPlain(indices) { + const minCodeSize = 3; + const clearCode = 1 << minCodeSize; + const endCode = clearCode + 1; + const codes = [clearCode]; + + for (const index of indices) { + codes.push(index, clearCode); + } + + codes.push(endCode); + return { minCodeSize, data: packCodes(codes, minCodeSize + 1) }; +} + +function packCodes(codes, codeSize) { + const bytes = []; + let bitBuffer = 0; + let bitCount = 0; + + for (const code of codes) { + bitBuffer |= code << bitCount; + bitCount += codeSize; + + while (bitCount >= 8) { + bytes.push(bitBuffer & 255); + bitBuffer >>= 8; + bitCount -= 8; + } + } + + if (bitCount > 0) { + bytes.push(bitBuffer & 255); + } + + return Buffer.from(bytes); +} + +function buildGif() { + const parts = [ + Buffer.from("GIF89a", "ascii"), + u16(width), + u16(height), + Buffer.from([0b11100010, 0, 0]) + ]; + + for (const color of colors) { + parts.push(Buffer.from(color)); + } + + parts.push( + Buffer.from([0x21, 0xff, 0x0b]), + Buffer.from("NETSCAPE2.0", "ascii"), + Buffer.from([0x03, 0x01, 0x00, 0x00, 0x00]) + ); + + for (let step = 0; step < 3; step += 1) { + const encoded = lzwPlain(frame(step)); + parts.push( + Buffer.from([0x21, 0xf9, 0x04, 0x04]), + u16(step === 2 ? 140 : 80), + Buffer.from([0, 0]), + Buffer.from([0x2c]), + u16(0), + u16(0), + u16(width), + u16(height), + Buffer.from([0]), + Buffer.from([encoded.minCodeSize]), + subBlocks(encoded.data) + ); + } + + parts.push(Buffer.from([0x3b])); + return Buffer.concat(parts); +} + +const outputPath = path.join(__dirname, "..", "docs", "demo.gif"); +fs.writeFileSync(outputPath, buildGif()); +console.log(`Wrote ${path.relative(process.cwd(), outputPath)}`); diff --git a/enterprise-grant-portfolio-compliance/scripts/demo.js b/enterprise-grant-portfolio-compliance/scripts/demo.js new file mode 100644 index 00000000..3086c91c --- /dev/null +++ b/enterprise-grant-portfolio-compliance/scripts/demo.js @@ -0,0 +1,21 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { evaluatePortfolio } = require("../src/grant-portfolio-compliance"); + +const samplePath = path.join(__dirname, "..", "data", "sample-grant-portfolio.json"); +const portfolio = JSON.parse(fs.readFileSync(samplePath, "utf8")); +const result = evaluatePortfolio(portfolio); + +console.log(`Enterprise grant portfolio compliance demo for ${result.institution.name}`); +console.log(`As of ${result.asOf}: ${result.summary.readyForExport} ready, ${result.summary.needsReview} needs review, ${result.summary.blocked} blocked`); +console.log(`Average score: ${result.summary.averageComplianceScore}`); + +for (const grant of result.grants) { + console.log( + `${grant.grantId}: ${grant.exportReadiness.decision} score=${grant.complianceScore} blockers=${grant.obligationCounts.blocker} warnings=${grant.obligationCounts.warning}` + ); +} + +console.log(`Signed event sample: ${result.events[0].grantId} digest=${result.events[0].digest.slice(0, 16)} signature=${result.events[0].signature.slice(0, 16)}`); diff --git a/enterprise-grant-portfolio-compliance/src/grant-portfolio-compliance.js b/enterprise-grant-portfolio-compliance/src/grant-portfolio-compliance.js new file mode 100644 index 00000000..5c8409cc --- /dev/null +++ b/enterprise-grant-portfolio-compliance/src/grant-portfolio-compliance.js @@ -0,0 +1,565 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const DEFAULT_SECRET = "scibase-enterprise-grant-portfolio-demo-secret"; +const SATISFIED_REPORT_STATUSES = new Set(["submitted", "accepted", "complete", "completed"]); +const SATISFIED_DMP_STATUSES = new Set(["approved", "exempt"]); +const SATISFIED_DEPOSIT_STATUSES = new Set(["deposited", "scheduled"]); +const SATISFIED_OPEN_ACCESS_STATUSES = new Set(["open", "scheduled"]); +const PENALTY_BY_SEVERITY = { + blocker: 28, + warning: 12, + todo: 5 +}; + +function normalizeDate(value, fieldName = "date") { + const date = value instanceof Date ? value : new Date(`${value}T00:00:00.000Z`); + + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid ${fieldName}: ${value}`); + } + + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function daysUntil(asOf, dueDate) { + return Math.ceil((normalizeDate(dueDate).getTime() - asOf.getTime()) / MS_PER_DAY); +} + +function addObligation(obligations, obligation) { + obligations.push({ + severity: obligation.severity, + state: obligation.state || stateFromSeverity(obligation.severity), + scope: obligation.scope, + grantId: obligation.grantId, + projectId: obligation.projectId || null, + outputId: obligation.outputId || null, + dueDate: obligation.dueDate || null, + daysUntilDue: obligation.dueDate ? daysUntil(obligation.asOf, obligation.dueDate) : null, + title: obligation.title, + evidence: obligation.evidence || [], + remediation: obligation.remediation + }); +} + +function stateFromSeverity(severity) { + if (severity === "blocker") { + return "blocked"; + } + + if (severity === "warning") { + return "needs_attention"; + } + + return "open"; +} + +function evaluatePortfolio(portfolio, options = {}) { + if (!portfolio || typeof portfolio !== "object") { + throw new Error("A grant portfolio object is required."); + } + + const asOf = normalizeDate(portfolio.asOf || new Date().toISOString().slice(0, 10), "asOf"); + const grants = (portfolio.grants || []).map((grant) => evaluateGrant(grant, asOf)); + const summary = summarizeGrants(grants); + const adminMetrics = buildAdminMetrics(grants); + const exportManifest = buildExportManifest(portfolio, grants); + const events = grants.map((grant) => + signEvent( + { + type: "grant.compliance.evaluated", + asOf: formatDate(asOf), + grantId: grant.grantId, + funder: grant.funder, + status: grant.status, + complianceScore: grant.complianceScore, + blockers: grant.obligationCounts.blocker, + warnings: grant.obligationCounts.warning, + exportDecision: grant.exportReadiness.decision + }, + options.secret || DEFAULT_SECRET + ) + ); + + return { + schemaVersion: "2026-05-16.enterprise-grant-portfolio-compliance.v1", + asOf: formatDate(asOf), + institution: portfolio.institution || null, + summary, + adminMetrics, + grants, + exportManifest, + events + }; +} + +function evaluateGrant(grant, asOf) { + const mandates = grant.mandates || {}; + const projects = grant.projects || []; + const obligations = []; + + for (const project of projects) { + evaluateDataManagementPlan(grant, project, mandates, asOf, obligations); + evaluateContributorIdentifiers(grant, project, mandates, asOf, obligations); + + for (const output of project.outputs || []) { + evaluateOpenAccess(grant, project, output, mandates, asOf, obligations); + evaluateRepositoryDeposit(grant, project, output, mandates, asOf, obligations); + evaluatePersistentIdentifiers(grant, project, output, mandates, asOf, obligations); + } + } + + evaluateReports(grant, mandates, asOf, obligations); + + const obligationCounts = countBySeverity(obligations); + const complianceScore = calculateComplianceScore(obligations); + const riskLevel = calculateRiskLevel(obligationCounts, complianceScore); + const exportReadiness = calculateExportReadiness(obligations, complianceScore); + const actionQueue = obligations + .filter((obligation) => obligation.severity !== "todo") + .map((obligation) => ({ + severity: obligation.severity, + scope: obligation.scope, + title: obligation.title, + owner: routeOwner(obligation.scope), + dueDate: obligation.dueDate, + remediation: obligation.remediation + })); + + return { + grantId: grant.grantId, + awardNumber: grant.awardNumber, + funder: grant.funder, + program: grant.program, + principalInvestigator: grant.principalInvestigator, + department: grant.department, + tags: grant.tags || [], + status: exportReadiness.decision === "ready" ? "compliant" : exportReadiness.decision, + riskLevel, + complianceScore, + obligationCounts, + exportReadiness, + obligations, + actionQueue + }; +} + +function evaluateDataManagementPlan(grant, project, mandates, asOf, obligations) { + const dmpMandate = mandates.dataManagementPlan || {}; + + if (!dmpMandate.required) { + return; + } + + const acceptedStatuses = new Set(dmpMandate.acceptedStatuses || Array.from(SATISFIED_DMP_STATUSES)); + const status = project.dataManagementPlan && project.dataManagementPlan.status; + + if (!acceptedStatuses.has(status)) { + addObligation(obligations, { + asOf, + severity: "blocker", + scope: "data-management-plan", + grantId: grant.grantId, + projectId: project.projectId, + title: `Approve a data management plan for ${project.title}`, + evidence: [`Current DMP status: ${status || "missing"}`], + remediation: "Route the project DMP through the research office before funder export." + }); + } +} + +function evaluateContributorIdentifiers(grant, project, mandates, asOf, obligations) { + if (!mandates.persistentIdentifiers || !mandates.persistentIdentifiers.orcidForContributors) { + return; + } + + const missing = (project.contributors || []).filter((contributor) => !contributor.orcid); + + if (missing.length > 0) { + addObligation(obligations, { + asOf, + severity: "warning", + scope: "orcid", + grantId: grant.grantId, + projectId: project.projectId, + title: `Collect ORCID identifiers for ${missing.length} contributor(s)`, + evidence: missing.map((contributor) => `${contributor.name} (${contributor.role})`), + remediation: "Send ORCID sync tasks before generating the institutional funder report." + }); + } +} + +function evaluateOpenAccess(grant, project, output, mandates, asOf, obligations) { + const openAccessMandate = mandates.openAccess || {}; + + if (!openAccessMandate.required || !isPublicationOutput(output)) { + return; + } + + const status = output.openAccess && output.openAccess.status; + + if (SATISFIED_OPEN_ACCESS_STATUSES.has(status)) { + const scheduledDate = output.openAccess.scheduledDate; + + if (status === "scheduled" && scheduledDate) { + const publicationDate = output.publicationDate || scheduledDate; + const deadline = addDays(publicationDate, openAccessMandate.deadlineDaysAfterPublication || 0); + + if (normalizeDate(scheduledDate).getTime() > normalizeDate(deadline).getTime()) { + addObligation(obligations, { + asOf, + severity: "warning", + scope: "open-access", + grantId: grant.grantId, + projectId: project.projectId, + outputId: output.outputId, + dueDate: deadline, + title: `Open access release for ${output.title} is scheduled after the funder deadline`, + evidence: [`Scheduled release: ${scheduledDate}`, `Deadline: ${deadline}`], + remediation: "Move the release date earlier or capture an approved embargo exception." + }); + } + } + + return; + } + + const dueDate = addDays(output.publicationDate || formatDate(asOf), openAccessMandate.deadlineDaysAfterPublication || 0); + addObligation(obligations, { + asOf, + severity: daysUntil(asOf, dueDate) < 0 ? "blocker" : "warning", + scope: "open-access", + grantId: grant.grantId, + projectId: project.projectId, + outputId: output.outputId, + dueDate, + title: `Publish or schedule open access for ${output.title}`, + evidence: [`Current open access status: ${status || "missing"}`], + remediation: "Schedule repository or journal open access before the mandate deadline." + }); +} + +function evaluateRepositoryDeposit(grant, project, output, mandates, asOf, obligations) { + const depositMandate = mandates.repositoryDeposit || {}; + + if (!depositMandate.required) { + return; + } + + const deposits = output.repositoryDeposits || []; + const allowedRepositories = new Set(depositMandate.allowedRepositories || []); + const matchingDeposit = deposits.find( + (deposit) => + SATISFIED_DEPOSIT_STATUSES.has(deposit.status) && + (allowedRepositories.size === 0 || allowedRepositories.has(deposit.repository)) + ); + + if (matchingDeposit) { + return; + } + + const dueDate = addDays(output.publicationDate || formatDate(asOf), depositMandate.deadlineDaysAfterPublication || 0); + const attemptedRepositories = deposits.length + ? deposits.map((deposit) => `${deposit.repository}:${deposit.status}`) + : ["no repository deposit"]; + + addObligation(obligations, { + asOf, + severity: daysUntil(asOf, dueDate) < 0 ? "blocker" : "warning", + scope: "repository-deposit", + grantId: grant.grantId, + projectId: project.projectId, + outputId: output.outputId, + dueDate, + title: `Deposit ${output.title} in an approved repository`, + evidence: attemptedRepositories, + remediation: `Use one of: ${Array.from(allowedRepositories).join(", ") || "the configured funder repositories"}.` + }); +} + +function evaluatePersistentIdentifiers(grant, project, output, mandates, asOf, obligations) { + const idMandate = mandates.persistentIdentifiers || {}; + const requiredKinds = new Set(idMandate.doiRequiredFor || []); + + if (!requiredKinds.has(output.kind) || output.doi) { + return; + } + + addObligation(obligations, { + asOf, + severity: "warning", + scope: "doi", + grantId: grant.grantId, + projectId: project.projectId, + outputId: output.outputId, + title: `Register DOI evidence for ${output.title}`, + evidence: [`Output kind ${output.kind} requires a DOI.`], + remediation: "Mint or import the DOI before exporting to funder portals." + }); +} + +function evaluateReports(grant, mandates, asOf, obligations) { + for (const report of mandates.reports || []) { + if (SATISFIED_REPORT_STATUSES.has(report.status)) { + continue; + } + + const days = daysUntil(asOf, report.dueDate); + + if (days < 0) { + addObligation(obligations, { + asOf, + severity: "blocker", + scope: "funder-report", + grantId: grant.grantId, + dueDate: report.dueDate, + title: `${report.type} report is overdue`, + evidence: [`Status: ${report.status}`, `${Math.abs(days)} day(s) overdue`], + remediation: "Escalate to the PI and research office before any new export is certified." + }); + continue; + } + + if (days <= 14) { + addObligation(obligations, { + asOf, + severity: "warning", + scope: "funder-report", + grantId: grant.grantId, + dueDate: report.dueDate, + title: `${report.type} report is due in ${days} day(s)`, + evidence: [`Status: ${report.status}`], + remediation: "Finish evidence collection and submit the report before the due date." + }); + continue; + } + + addObligation(obligations, { + asOf, + severity: "todo", + scope: "funder-report", + grantId: grant.grantId, + dueDate: report.dueDate, + title: `${report.type} report is scheduled`, + evidence: [`Status: ${report.status}`, `Due in ${days} day(s)`], + remediation: "Keep the report in the grant office calendar." + }); + } +} + +function buildAdminMetrics(grants) { + const byDepartment = {}; + const byFunder = {}; + + for (const grant of grants) { + incrementMetric(byDepartment, grant.department, grant); + incrementMetric(byFunder, grant.funder, grant); + } + + return { + byDepartment, + byFunder, + topRisks: grants + .filter((grant) => grant.riskLevel !== "clear") + .sort((left, right) => left.complianceScore - right.complianceScore) + .slice(0, 5) + .map((grant) => ({ + grantId: grant.grantId, + riskLevel: grant.riskLevel, + complianceScore: grant.complianceScore, + blockers: grant.obligationCounts.blocker, + warnings: grant.obligationCounts.warning + })) + }; +} + +function incrementMetric(target, key, grant) { + const metricKey = key || "unknown"; + target[metricKey] ||= { + grants: 0, + ready: 0, + blocked: 0, + needsReview: 0, + averageScore: 0 + }; + + const metric = target[metricKey]; + metric.grants += 1; + metric.ready += grant.exportReadiness.decision === "ready" ? 1 : 0; + metric.blocked += grant.exportReadiness.decision === "blocked" ? 1 : 0; + metric.needsReview += grant.exportReadiness.decision === "needs_review" ? 1 : 0; + metric.averageScore = Math.round(((metric.averageScore * (metric.grants - 1)) + grant.complianceScore) / metric.grants); +} + +function buildExportManifest(portfolio, grants) { + return { + generatedFor: portfolio.institution ? portfolio.institution.id : "unknown-institution", + asOf: portfolio.asOf, + readyGrantIds: grants.filter((grant) => grant.exportReadiness.decision === "ready").map((grant) => grant.grantId), + blockedGrantIds: grants.filter((grant) => grant.exportReadiness.decision === "blocked").map((grant) => grant.grantId), + needsReviewGrantIds: grants + .filter((grant) => grant.exportReadiness.decision === "needs_review") + .map((grant) => grant.grantId), + requiredEvidence: [ + "approved data management plan", + "open access release or scheduled release", + "approved repository deposit", + "DOI for funder-scoped outputs", + "ORCID coverage for grant contributors", + "current funder report status" + ] + }; +} + +function summarizeGrants(grants) { + const summary = { + totalGrants: grants.length, + readyForExport: 0, + needsReview: 0, + blocked: 0, + averageComplianceScore: 0, + obligationCounts: { + blocker: 0, + warning: 0, + todo: 0 + } + }; + + let scoreTotal = 0; + for (const grant of grants) { + scoreTotal += grant.complianceScore; + summary.readyForExport += grant.exportReadiness.decision === "ready" ? 1 : 0; + summary.needsReview += grant.exportReadiness.decision === "needs_review" ? 1 : 0; + summary.blocked += grant.exportReadiness.decision === "blocked" ? 1 : 0; + summary.obligationCounts.blocker += grant.obligationCounts.blocker; + summary.obligationCounts.warning += grant.obligationCounts.warning; + summary.obligationCounts.todo += grant.obligationCounts.todo; + } + + summary.averageComplianceScore = grants.length ? Math.round(scoreTotal / grants.length) : 100; + return summary; +} + +function countBySeverity(obligations) { + return obligations.reduce( + (counts, obligation) => { + counts[obligation.severity] += 1; + return counts; + }, + { blocker: 0, warning: 0, todo: 0 } + ); +} + +function calculateComplianceScore(obligations) { + const penalty = obligations.reduce( + (total, obligation) => total + (PENALTY_BY_SEVERITY[obligation.severity] || 0), + 0 + ); + + return Math.max(0, 100 - penalty); +} + +function calculateRiskLevel(counts, complianceScore) { + if (counts.blocker > 0 || complianceScore < 60) { + return "critical"; + } + + if (counts.warning >= 2 || complianceScore < 80) { + return "elevated"; + } + + if (counts.warning > 0 || counts.todo > 0) { + return "watch"; + } + + return "clear"; +} + +function calculateExportReadiness(obligations, complianceScore) { + const blockers = obligations.filter((obligation) => obligation.severity === "blocker"); + const warnings = obligations.filter((obligation) => obligation.severity === "warning"); + + if (blockers.length > 0) { + return { + decision: "blocked", + reason: `${blockers.length} blocker(s) must be resolved before funder export.` + }; + } + + if (warnings.length > 0 || complianceScore < 90) { + return { + decision: "needs_review", + reason: "No blockers, but research office review is required before certification." + }; + } + + return { + decision: "ready", + reason: "All configured funder mandates are satisfied." + }; +} + +function signEvent(payload, secret = DEFAULT_SECRET) { + const canonicalPayload = stableStringify(payload); + const digest = crypto.createHash("sha256").update(canonicalPayload).digest("hex"); + const signature = crypto.createHmac("sha256", secret).update(digest).digest("hex"); + + return { + ...payload, + digest, + signature + }; +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + +function routeOwner(scope) { + const owners = { + "data-management-plan": "Research data services", + orcid: "Identity and profile sync", + "open-access": "Scholarly communications", + "repository-deposit": "Repository operations", + doi: "Library publishing", + "funder-report": "Sponsored programs" + }; + + return owners[scope] || "Research office"; +} + +function isPublicationOutput(output) { + return ["article", "preprint", "manuscript", "dataset", "software"].includes(output.kind); +} + +function addDays(dateValue, days) { + const date = normalizeDate(dateValue); + date.setUTCDate(date.getUTCDate() + days); + return formatDate(date); +} + +function formatDate(date) { + return date.toISOString().slice(0, 10); +} + +module.exports = { + evaluatePortfolio, + evaluateGrant, + signEvent, + stableStringify, + daysUntil, + addDays +}; diff --git a/enterprise-grant-portfolio-compliance/test/grant-portfolio-compliance.test.js b/enterprise-grant-portfolio-compliance/test/grant-portfolio-compliance.test.js new file mode 100644 index 00000000..7b2980b5 --- /dev/null +++ b/enterprise-grant-portfolio-compliance/test/grant-portfolio-compliance.test.js @@ -0,0 +1,84 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const { + addDays, + daysUntil, + evaluatePortfolio, + signEvent, + stableStringify +} = require("../src/grant-portfolio-compliance"); + +const sample = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "data", "sample-grant-portfolio.json"), "utf8") +); + +test("evaluates grant portfolio summary and export decisions", () => { + const result = evaluatePortfolio(sample); + + assert.equal(result.summary.totalGrants, 3); + assert.equal(result.summary.readyForExport, 1); + assert.equal(result.summary.needsReview, 1); + assert.equal(result.summary.blocked, 1); + assert.deepEqual(result.exportManifest.readyGrantIds, ["HORIZON-CLIMATE-782"]); + assert.deepEqual(result.exportManifest.blockedGrantIds, ["NIH-R01-NEURO-142"]); + assert.deepEqual(result.exportManifest.needsReviewGrantIds, ["UKRI-QUANTUM-008"]); +}); + +test("surfaces blocker and warning obligations for an at-risk grant", () => { + const result = evaluatePortfolio(sample); + const grant = result.grants.find((candidate) => candidate.grantId === "NIH-R01-NEURO-142"); + + assert.equal(grant.exportReadiness.decision, "blocked"); + assert.equal(grant.riskLevel, "critical"); + assert.equal(grant.obligationCounts.blocker, 6); + assert.equal(grant.obligationCounts.warning, 4); + assert.ok(grant.obligations.some((obligation) => obligation.scope === "data-management-plan")); + assert.ok(grant.obligations.some((obligation) => obligation.scope === "repository-deposit")); + assert.ok(grant.actionQueue.every((action) => action.owner)); +}); + +test("keeps soon-due funder reports in review without blocking export", () => { + const result = evaluatePortfolio(sample); + const grant = result.grants.find((candidate) => candidate.grantId === "UKRI-QUANTUM-008"); + + assert.equal(grant.exportReadiness.decision, "needs_review"); + assert.equal(grant.obligationCounts.blocker, 0); + assert.equal(grant.obligationCounts.warning, 1); + assert.equal(grant.obligations[0].daysUntilDue, 6); + assert.match(grant.obligations[0].title, /due in 6 day/); +}); + +test("creates deterministic signed events for webhook-ready integrations", () => { + const payload = { + type: "grant.compliance.evaluated", + grantId: "HORIZON-CLIMATE-782", + status: "compliant", + complianceScore: 100 + }; + + const first = signEvent(payload, "test-secret"); + const second = signEvent( + { + complianceScore: 100, + status: "compliant", + grantId: "HORIZON-CLIMATE-782", + type: "grant.compliance.evaluated" + }, + "test-secret" + ); + + assert.equal(first.digest, second.digest); + assert.equal(first.signature, second.signature); + assert.equal(first.digest.length, 64); + assert.equal(first.signature.length, 64); +}); + +test("normalizes date helpers for mandate due-date calculations", () => { + assert.equal(daysUntil(new Date("2026-05-16T00:00:00.000Z"), "2026-05-22"), 6); + assert.equal(addDays("2026-05-16", 30), "2026-06-15"); + assert.equal(stableStringify({ b: 2, a: 1 }), "{\"a\":1,\"b\":2}"); +});