From 856a2ac77e2c7041e979ea1607cd914aa68aee44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szab=C3=B3=20D=C3=A1vid?= Date: Sat, 27 Jun 2026 10:46:44 +0200 Subject: [PATCH] Add enterprise export maintenance guard --- enterprise-export-maintenance-guard/README.md | 35 ++++ enterprise-export-maintenance-guard/demo.js | 29 ++++ .../make-demo-video.js | 50 ++++++ .../package.json | 13 ++ .../reports/demo.mp4 | Bin 0 -> 23635 bytes .../reports/ready-export.json | 11 ++ .../reports/risky-export.json | 48 ++++++ .../reports/risky-export.md | 22 +++ .../reports/summary.svg | 8 + .../src/guard.js | 150 ++++++++++++++++++ .../src/sampleJobs.js | 39 +++++ enterprise-export-maintenance-guard/test.js | 22 +++ 12 files changed, 427 insertions(+) create mode 100644 enterprise-export-maintenance-guard/README.md create mode 100644 enterprise-export-maintenance-guard/demo.js create mode 100644 enterprise-export-maintenance-guard/make-demo-video.js create mode 100644 enterprise-export-maintenance-guard/package.json create mode 100644 enterprise-export-maintenance-guard/reports/demo.mp4 create mode 100644 enterprise-export-maintenance-guard/reports/ready-export.json create mode 100644 enterprise-export-maintenance-guard/reports/risky-export.json create mode 100644 enterprise-export-maintenance-guard/reports/risky-export.md create mode 100644 enterprise-export-maintenance-guard/reports/summary.svg create mode 100644 enterprise-export-maintenance-guard/src/guard.js create mode 100644 enterprise-export-maintenance-guard/src/sampleJobs.js create mode 100644 enterprise-export-maintenance-guard/test.js diff --git a/enterprise-export-maintenance-guard/README.md b/enterprise-export-maintenance-guard/README.md new file mode 100644 index 00000000..3fdc8dd4 --- /dev/null +++ b/enterprise-export-maintenance-guard/README.md @@ -0,0 +1,35 @@ +# Enterprise Export Maintenance Guard + +Self-contained reviewer artifact for SCIBASE issue #19, focused on institutional export destination readiness before pushing research outputs to repositories, LMS, ELN, or funder systems. + +The guard evaluates synthetic export jobs for: + +- active destination maintenance windows +- institutional blackout calendars +- degraded destination health +- stale status evidence +- retry windows that exceed enterprise policy +- missing export-owner acknowledgement for risky timing +- embargo-sensitive payloads that must be held instead of automatically retried + +It emits deterministic `SEND`, `DEFER`, or `HOLD` decisions with JSON, Markdown, SVG, and MP4 artifacts. It uses no credentials, no private tenant data, no external APIs, no live status pages, and no payment systems. + +## Requirement Mapping + +| Issue #19 capability | Covered here | +| --- | --- | +| Export pipelines | Gates export jobs before delivery to institutional destinations | +| API and integration safety | Checks destination health and retry policy before external transmission | +| Admin governance | Produces audit-ready findings and owner acknowledgement requirements | +| Compliance reporting | Holds embargo-sensitive exports during unsafe destination timing | + +## Local Validation + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +Generated artifacts are written to `reports/`. diff --git a/enterprise-export-maintenance-guard/demo.js b/enterprise-export-maintenance-guard/demo.js new file mode 100644 index 00000000..03bdb37d --- /dev/null +++ b/enterprise-export-maintenance-guard/demo.js @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; +import { readyExportJob, riskyExportJob } from "./src/sampleJobs.js"; +import { evaluateExportMaintenanceJob, renderMarkdownReport } from "./src/guard.js"; + +const reportsDir = path.join(process.cwd(), "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const readyResult = evaluateExportMaintenanceJob(readyExportJob); +const riskyResult = evaluateExportMaintenanceJob(riskyExportJob); + +fs.writeFileSync(path.join(reportsDir, "ready-export.json"), `${JSON.stringify(readyResult, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-export.json"), `${JSON.stringify(riskyResult, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-export.md"), renderMarkdownReport(riskyExportJob, riskyResult)); + +const svg = ` + + Enterprise Export Maintenance Guard + Decision: ${riskyResult.decision} + Findings: ${riskyResult.findingCount} | High: ${riskyResult.highCount} | Medium: ${riskyResult.mediumCount} + Checks maintenance windows, degraded health, stale evidence, and retry policy. + Audit digest: ${riskyResult.auditDigest} + +`; +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg); + +console.log(`Ready decision: ${readyResult.decision}`); +console.log(`Risky decision: ${riskyResult.decision}`); +console.log(`Wrote reports to ${reportsDir}`); diff --git a/enterprise-export-maintenance-guard/make-demo-video.js b/enterprise-export-maintenance-guard/make-demo-video.js new file mode 100644 index 00000000..e50da56c --- /dev/null +++ b/enterprise-export-maintenance-guard/make-demo-video.js @@ -0,0 +1,50 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { riskyExportJob } from "./src/sampleJobs.js"; +import { evaluateExportMaintenanceJob } from "./src/guard.js"; + +const reportsDir = path.join(process.cwd(), "reports"); +const demoMp4 = path.join(reportsDir, "demo.mp4"); +const resultPacket = evaluateExportMaintenanceJob(riskyExportJob); + +function escapeDrawtext(text) { + return text.replaceAll("\\", "\\\\").replaceAll(":", "\\:").replaceAll("'", "\\'"); +} + +const font = "C\\:/Windows/Fonts/arial.ttf"; +const lines = [ + "Enterprise Export Maintenance Guard", + `Decision ${resultPacket.decision} | Findings ${resultPacket.findingCount}`, + "Blocks unsafe exports during destination maintenance, degraded health, and stale evidence.", + `Audit digest ${resultPacket.auditDigest}`, +]; +const drawText = lines + .map( + (line, index) => + `drawtext=fontfile='${font}':text='${escapeDrawtext(line)}':x=48:y=${64 + index * 72}:fontsize=${index === 0 ? 34 : 24}:fontcolor=${index === 1 ? "0xffdddd" : "white"}`, + ) + .join(","); + +const result = spawnSync( + "ffmpeg", + [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x0f172a:s=960x540:r=12", + "-t", + "4", + "-vf", + `${drawText},format=yuv420p`, + "-an", + demoMp4, + ], + { encoding: "utf8" }, +); + +if (result.status !== 0) { + throw new Error(result.stderr || "ffmpeg failed to render demo.mp4"); +} + +console.log(`Wrote ${path.relative(process.cwd(), demoMp4)}`); diff --git a/enterprise-export-maintenance-guard/package.json b/enterprise-export-maintenance-guard/package.json new file mode 100644 index 00000000..c9b5aa8d --- /dev/null +++ b/enterprise-export-maintenance-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "enterprise-export-maintenance-guard", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Dependency-free guard for enterprise export destination maintenance windows.", + "scripts": { + "check": "node --check src/guard.js && node --check src/sampleJobs.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node make-demo-video.js" + } +} diff --git a/enterprise-export-maintenance-guard/reports/demo.mp4 b/enterprise-export-maintenance-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..cd8c7ff0de303d957ed4abc77f745bcb51064142 GIT binary patch literal 23635 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 I3JVGU2U)N4)&Kwi literal 0 HcmV?d00001 diff --git a/enterprise-export-maintenance-guard/reports/ready-export.json b/enterprise-export-maintenance-guard/reports/ready-export.json new file mode 100644 index 00000000..748f86a4 --- /dev/null +++ b/enterprise-export-maintenance-guard/reports/ready-export.json @@ -0,0 +1,11 @@ +{ + "jobId": "zenodo-export-ready", + "exportTarget": "zenodo", + "decision": "SEND", + "findingCount": 0, + "highCount": 0, + "mediumCount": 0, + "retryAfterMinutes": 0, + "findings": [], + "auditDigest": "15f23d4df1aefe5e" +} diff --git a/enterprise-export-maintenance-guard/reports/risky-export.json b/enterprise-export-maintenance-guard/reports/risky-export.json new file mode 100644 index 00000000..71f7b7f5 --- /dev/null +++ b/enterprise-export-maintenance-guard/reports/risky-export.json @@ -0,0 +1,48 @@ +{ + "jobId": "funder-portal-held", + "exportTarget": "horizon-eu-portal", + "decision": "HOLD", + "findingCount": 6, + "highCount": 2, + "mediumCount": 4, + "retryAfterMinutes": 90, + "findings": [ + { + "severity": "high", + "code": "ACTIVE_DESTINATION_MAINTENANCE", + "message": "horizon-eu-portal is in maintenance: grant portal database failover.", + "remediation": "Defer export for at least 130 minutes or route to a safe queue." + }, + { + "severity": "medium", + "code": "INSTITUTIONAL_BLACKOUT_WINDOW", + "message": "Institutional blackout is active: institutional grant-office freeze.", + "remediation": "Wait for the institutional export freeze to end or obtain explicit admin override evidence." + }, + { + "severity": "medium", + "code": "STALE_DESTINATION_STATUS_EVIDENCE", + "message": "Status evidence is 30.3 hours old.", + "remediation": "Refresh destination health evidence before exporting to institutional systems." + }, + { + "severity": "high", + "code": "DEGRADED_DESTINATION_HEALTH", + "message": "horizon-eu-portal health is degraded.", + "remediation": "Hold embargo-sensitive exports or queue non-sensitive exports until destination health recovers." + }, + { + "severity": "medium", + "code": "MISSING_OWNER_ACKNOWLEDGEMENT", + "message": "Export owner has not acknowledged destination timing risk.", + "remediation": "Collect owner acknowledgement before pushing to the external destination." + }, + { + "severity": "medium", + "code": "RETRY_WINDOW_EXCEEDS_POLICY", + "message": "Retry-after 90m exceeds allowed delay 20m.", + "remediation": "Reschedule the job rather than relying on automatic retries beyond policy." + } + ], + "auditDigest": "3aac7e7788d1480d" +} diff --git a/enterprise-export-maintenance-guard/reports/risky-export.md b/enterprise-export-maintenance-guard/reports/risky-export.md new file mode 100644 index 00000000..bc6dcd93 --- /dev/null +++ b/enterprise-export-maintenance-guard/reports/risky-export.md @@ -0,0 +1,22 @@ +# Enterprise Export Maintenance Guard Report + +- Job: funder-portal-held +- Destination: horizon-eu-portal +- Destination type: funder +- Decision: HOLD +- Audit digest: 3aac7e7788d1480d + +## Findings + +- HIGH ACTIVE_DESTINATION_MAINTENANCE: horizon-eu-portal is in maintenance: grant portal database failover. + Remediation: Defer export for at least 130 minutes or route to a safe queue. +- MEDIUM INSTITUTIONAL_BLACKOUT_WINDOW: Institutional blackout is active: institutional grant-office freeze. + Remediation: Wait for the institutional export freeze to end or obtain explicit admin override evidence. +- MEDIUM STALE_DESTINATION_STATUS_EVIDENCE: Status evidence is 30.3 hours old. + Remediation: Refresh destination health evidence before exporting to institutional systems. +- HIGH DEGRADED_DESTINATION_HEALTH: horizon-eu-portal health is degraded. + Remediation: Hold embargo-sensitive exports or queue non-sensitive exports until destination health recovers. +- MEDIUM MISSING_OWNER_ACKNOWLEDGEMENT: Export owner has not acknowledged destination timing risk. + Remediation: Collect owner acknowledgement before pushing to the external destination. +- MEDIUM RETRY_WINDOW_EXCEEDS_POLICY: Retry-after 90m exceeds allowed delay 20m. + Remediation: Reschedule the job rather than relying on automatic retries beyond policy. diff --git a/enterprise-export-maintenance-guard/reports/summary.svg b/enterprise-export-maintenance-guard/reports/summary.svg new file mode 100644 index 00000000..c7ffbd7c --- /dev/null +++ b/enterprise-export-maintenance-guard/reports/summary.svg @@ -0,0 +1,8 @@ + + + Enterprise Export Maintenance Guard + Decision: HOLD + Findings: 6 | High: 2 | Medium: 4 + Checks maintenance windows, degraded health, stale evidence, and retry policy. + Audit digest: 3aac7e7788d1480d + diff --git a/enterprise-export-maintenance-guard/src/guard.js b/enterprise-export-maintenance-guard/src/guard.js new file mode 100644 index 00000000..74c88092 --- /dev/null +++ b/enterprise-export-maintenance-guard/src/guard.js @@ -0,0 +1,150 @@ +import crypto from "node:crypto"; + +const HOUR_MS = 60 * 60 * 1000; + +function addFinding(findings, severity, code, message, remediation) { + findings.push({ severity, code, message, remediation }); +} + +function parseTime(value) { + const time = Date.parse(value); + if (Number.isNaN(time)) { + throw new Error(`Invalid timestamp: ${value}`); + } + return time; +} + +function isInsideWindow(timestamp, window) { + return timestamp >= parseTime(window.startsAt) && timestamp <= parseTime(window.endsAt); +} + +function minutesUntilEnd(timestamp, window) { + return Math.ceil((parseTime(window.endsAt) - timestamp) / 60000); +} + +function evaluateWindows(job, findings) { + const requestedAt = parseTime(job.requestedAt); + const activeMaintenance = job.maintenanceWindows.find((window) => isInsideWindow(requestedAt, window)); + if (activeMaintenance) { + addFinding( + findings, + "high", + "ACTIVE_DESTINATION_MAINTENANCE", + `${job.exportTarget} is in maintenance: ${activeMaintenance.reason}.`, + `Defer export for at least ${minutesUntilEnd(requestedAt, activeMaintenance)} minutes or route to a safe queue.`, + ); + } + + const activeBlackout = job.blackoutCalendar.find((window) => isInsideWindow(requestedAt, window)); + if (activeBlackout) { + addFinding( + findings, + "medium", + "INSTITUTIONAL_BLACKOUT_WINDOW", + `Institutional blackout is active: ${activeBlackout.reason}.`, + "Wait for the institutional export freeze to end or obtain explicit admin override evidence.", + ); + } +} + +function evaluateStatusEvidence(job, findings) { + const requestedAt = parseTime(job.requestedAt); + const checkedAt = parseTime(job.statusEvidence.checkedAt); + const ageHours = (requestedAt - checkedAt) / HOUR_MS; + + if (ageHours > 12) { + addFinding( + findings, + "medium", + "STALE_DESTINATION_STATUS_EVIDENCE", + `Status evidence is ${ageHours.toFixed(1)} hours old.`, + "Refresh destination health evidence before exporting to institutional systems.", + ); + } + + if (job.statusEvidence.health !== "operational") { + addFinding( + findings, + "high", + "DEGRADED_DESTINATION_HEALTH", + `${job.exportTarget} health is ${job.statusEvidence.health}.`, + "Hold embargo-sensitive exports or queue non-sensitive exports until destination health recovers.", + ); + } +} + +function evaluateRetryAndOwnership(job, findings) { + if (!job.ownerAcknowledgedAt) { + addFinding( + findings, + "medium", + "MISSING_OWNER_ACKNOWLEDGEMENT", + "Export owner has not acknowledged destination timing risk.", + "Collect owner acknowledgement before pushing to the external destination.", + ); + } + + if (job.retryPolicy.retryAfterMinutes > job.retryPolicy.maxDelayMinutes) { + addFinding( + findings, + "medium", + "RETRY_WINDOW_EXCEEDS_POLICY", + `Retry-after ${job.retryPolicy.retryAfterMinutes}m exceeds allowed delay ${job.retryPolicy.maxDelayMinutes}m.`, + "Reschedule the job rather than relying on automatic retries beyond policy.", + ); + } +} + +export function evaluateExportMaintenanceJob(job) { + const findings = []; + evaluateWindows(job, findings); + evaluateStatusEvidence(job, findings); + evaluateRetryAndOwnership(job, findings); + + const highCount = findings.filter((finding) => finding.severity === "high").length; + const mediumCount = findings.filter((finding) => finding.severity === "medium").length; + const decision = highCount > 0 && job.embargoSensitive ? "HOLD" : highCount > 0 || mediumCount > 0 ? "DEFER" : "SEND"; + const auditDigest = crypto + .createHash("sha256") + .update(JSON.stringify({ id: job.id, decision, findings })) + .digest("hex") + .slice(0, 16); + + return { + jobId: job.id, + exportTarget: job.exportTarget, + decision, + findingCount: findings.length, + highCount, + mediumCount, + retryAfterMinutes: decision === "SEND" ? 0 : job.retryPolicy.retryAfterMinutes, + findings, + auditDigest, + }; +} + +export function renderMarkdownReport(job, result) { + const lines = [ + "# Enterprise Export Maintenance Guard Report", + "", + `- Job: ${job.id}`, + `- Destination: ${job.exportTarget}`, + `- Destination type: ${job.destinationType}`, + `- Decision: ${result.decision}`, + `- Audit digest: ${result.auditDigest}`, + "", + "## Findings", + "", + ]; + + if (result.findings.length === 0) { + lines.push("- No blockers found. Export can be sent now."); + } else { + for (const finding of result.findings) { + lines.push(`- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`); + lines.push(` Remediation: ${finding.remediation}`); + } + } + + return `${lines.join("\n")}\n`; +} diff --git a/enterprise-export-maintenance-guard/src/sampleJobs.js b/enterprise-export-maintenance-guard/src/sampleJobs.js new file mode 100644 index 00000000..195122ec --- /dev/null +++ b/enterprise-export-maintenance-guard/src/sampleJobs.js @@ -0,0 +1,39 @@ +export const readyExportJob = { + id: "zenodo-export-ready", + exportTarget: "zenodo", + destinationType: "repository", + requestedAt: "2026-06-26T14:00:00Z", + embargoSensitive: false, + ownerAcknowledgedAt: "2026-06-26T13:55:00Z", + statusEvidence: { + checkedAt: "2026-06-26T13:50:00Z", + health: "operational", + source: "synthetic-status-snapshot", + }, + maintenanceWindows: [ + { startsAt: "2026-06-27T03:00:00Z", endsAt: "2026-06-27T05:00:00Z", reason: "scheduled index maintenance" }, + ], + blackoutCalendar: [], + retryPolicy: { maxDelayMinutes: 45, retryAfterMinutes: 10 }, +}; + +export const riskyExportJob = { + id: "funder-portal-held", + exportTarget: "horizon-eu-portal", + destinationType: "funder", + requestedAt: "2026-06-26T14:20:00Z", + embargoSensitive: true, + ownerAcknowledgedAt: null, + statusEvidence: { + checkedAt: "2026-06-25T08:00:00Z", + health: "degraded", + source: "synthetic-status-snapshot", + }, + maintenanceWindows: [ + { startsAt: "2026-06-26T14:00:00Z", endsAt: "2026-06-26T16:30:00Z", reason: "grant portal database failover" }, + ], + blackoutCalendar: [ + { startsAt: "2026-06-26T12:00:00Z", endsAt: "2026-06-26T18:00:00Z", reason: "institutional grant-office freeze" }, + ], + retryPolicy: { maxDelayMinutes: 20, retryAfterMinutes: 90 }, +}; diff --git a/enterprise-export-maintenance-guard/test.js b/enterprise-export-maintenance-guard/test.js new file mode 100644 index 00000000..d9f27734 --- /dev/null +++ b/enterprise-export-maintenance-guard/test.js @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { readyExportJob, riskyExportJob } from "./src/sampleJobs.js"; +import { evaluateExportMaintenanceJob, renderMarkdownReport } from "./src/guard.js"; + +const readyResult = evaluateExportMaintenanceJob(readyExportJob); +assert.equal(readyResult.decision, "SEND"); +assert.equal(readyResult.findingCount, 0); +assert.equal(readyResult.retryAfterMinutes, 0); + +const riskyResult = evaluateExportMaintenanceJob(riskyExportJob); +assert.equal(riskyResult.decision, "HOLD"); +assert.ok(riskyResult.findings.some((finding) => finding.code === "ACTIVE_DESTINATION_MAINTENANCE")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "DEGRADED_DESTINATION_HEALTH")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "STALE_DESTINATION_STATUS_EVIDENCE")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "RETRY_WINDOW_EXCEEDS_POLICY")); + +const report = renderMarkdownReport(riskyExportJob, riskyResult); +assert.match(report, /Enterprise Export Maintenance Guard Report/); +assert.match(report, /HOLD/); +assert.match(report, /ACTIVE_DESTINATION_MAINTENANCE/); + +console.log("4 export maintenance guard tests passed");