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 00000000..cd8c7ff0 Binary files /dev/null and b/enterprise-export-maintenance-guard/reports/demo.mp4 differ 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");