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 = `
+`;
+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 @@
+
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");