Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions enterprise-export-maintenance-guard/README.md
Original file line number Diff line number Diff line change
@@ -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/`.
29 changes: 29 additions & 0 deletions enterprise-export-maintenance-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#0f172a"/>
<text x="48" y="78" fill="#ffffff" font-family="Arial, sans-serif" font-size="34" font-weight="700">Enterprise Export Maintenance Guard</text>
<text x="48" y="136" fill="#fca5a5" font-family="Arial, sans-serif" font-size="28">Decision: ${riskyResult.decision}</text>
<text x="48" y="190" fill="#d1d5db" font-family="Arial, sans-serif" font-size="23">Findings: ${riskyResult.findingCount} | High: ${riskyResult.highCount} | Medium: ${riskyResult.mediumCount}</text>
<text x="48" y="246" fill="#d1d5db" font-family="Arial, sans-serif" font-size="22">Checks maintenance windows, degraded health, stale evidence, and retry policy.</text>
<text x="48" y="306" fill="#93c5fd" font-family="Arial, sans-serif" font-size="22">Audit digest: ${riskyResult.auditDigest}</text>
</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}`);
50 changes: 50 additions & 0 deletions enterprise-export-maintenance-guard/make-demo-video.js
Original file line number Diff line number Diff line change
@@ -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)}`);
13 changes: 13 additions & 0 deletions enterprise-export-maintenance-guard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Binary file not shown.
11 changes: 11 additions & 0 deletions enterprise-export-maintenance-guard/reports/ready-export.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"jobId": "zenodo-export-ready",
"exportTarget": "zenodo",
"decision": "SEND",
"findingCount": 0,
"highCount": 0,
"mediumCount": 0,
"retryAfterMinutes": 0,
"findings": [],
"auditDigest": "15f23d4df1aefe5e"
}
48 changes: 48 additions & 0 deletions enterprise-export-maintenance-guard/reports/risky-export.json
Original file line number Diff line number Diff line change
@@ -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"
}
22 changes: 22 additions & 0 deletions enterprise-export-maintenance-guard/reports/risky-export.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions enterprise-export-maintenance-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
150 changes: 150 additions & 0 deletions enterprise-export-maintenance-guard/src/guard.js
Original file line number Diff line number Diff line change
@@ -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`;
}
Loading