From 7b3776605ef64d639ef7859d85a877e092fa1b14 Mon Sep 17 00:00:00 2001 From: Julian Ahlmark Date: Fri, 29 May 2026 14:58:46 +0300 Subject: [PATCH] Add institutional repository sync SLA guard --- .../demo.js | 69 +++++++++ .../index.js | 131 ++++++++++++++++++ .../readme.md | 27 ++++ .../render-video.js | 73 ++++++++++ .../reports/demo.mp4 | Bin 0 -> 7075 bytes .../reports/repository-sync-sla-report.json | 96 +++++++++++++ .../reports/repository-sync-sla-report.md | 58 ++++++++ .../reports/summary.svg | 13 ++ .../sample-data.js | 128 +++++++++++++++++ .../test.js | 39 ++++++ 10 files changed, 634 insertions(+) create mode 100644 institutional-repository-sync-sla-guard/demo.js create mode 100644 institutional-repository-sync-sla-guard/index.js create mode 100644 institutional-repository-sync-sla-guard/readme.md create mode 100644 institutional-repository-sync-sla-guard/render-video.js create mode 100644 institutional-repository-sync-sla-guard/reports/demo.mp4 create mode 100644 institutional-repository-sync-sla-guard/reports/repository-sync-sla-report.json create mode 100644 institutional-repository-sync-sla-guard/reports/repository-sync-sla-report.md create mode 100644 institutional-repository-sync-sla-guard/reports/summary.svg create mode 100644 institutional-repository-sync-sla-guard/sample-data.js create mode 100644 institutional-repository-sync-sla-guard/test.js diff --git a/institutional-repository-sync-sla-guard/demo.js b/institutional-repository-sync-sla-guard/demo.js new file mode 100644 index 00000000..7157512a --- /dev/null +++ b/institutional-repository-sync-sla-guard/demo.js @@ -0,0 +1,69 @@ +const fs = require("fs"); +const path = require("path"); + +const { evaluateSyncJobs } = require("./index"); +const { syncJobs } = require("./sample-data"); + +const reportDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportDir, { recursive: true }); + +const report = evaluateSyncJobs(syncJobs); +fs.writeFileSync(path.join(reportDir, "repository-sync-sla-report.json"), `${JSON.stringify(report, null, 2)}\n`); + +const lines = [ + "# Institutional Repository Sync SLA Guard", + "", + `Generated: ${report.generatedAt}`, + `Report digest: \`${report.reportDigest}\``, + "", + "## Summary", + "", + `- Total jobs: ${report.summary.total}`, + `- Ready: ${report.summary.ready}`, + `- Needs review: ${report.summary.needs_review}`, + `- Blocked: ${report.summary.blocked}`, + `- Findings: ${report.summary.findingCount}`, + "", + "## Decisions", + "", +]; + +for (const decision of report.decisions) { + lines.push(`### ${decision.id}`); + lines.push(""); + lines.push(`- Decision: **${decision.decision}**`); + lines.push(`- Project: ${decision.project}`); + lines.push(`- Target: ${decision.targetRepository} (${decision.targetType})`); + lines.push(`- Escalation: \`${decision.escalation}\``); + lines.push(`- Audit digest: \`${decision.auditDigest}\``); + lines.push("- Findings:"); + const findings = decision.findings.length + ? decision.findings + : [{ severity: "info", code: "none", message: "No blocking or review findings." }]; + for (const finding of findings) { + lines.push(` - ${finding.severity}: ${finding.code} - ${finding.message}`); + } + lines.push(""); +} +if (lines[lines.length - 1] === "") { + lines.pop(); +} +fs.writeFileSync(path.join(reportDir, "repository-sync-sla-report.md"), `${lines.join("\n")}\n`); + +const svg = ` + + + Institutional Repository Sync SLA Guard + Enterprise export-pipeline reviewer artifact + + + + ready ${report.summary.ready} + review ${report.summary.needs_review} + blocked ${report.summary.blocked} + Digest ${report.reportDigest.slice(0, 32)}... + +`; +fs.writeFileSync(path.join(reportDir, "summary.svg"), svg); + +console.log(JSON.stringify(report.summary, null, 2)); diff --git a/institutional-repository-sync-sla-guard/index.js b/institutional-repository-sync-sla-guard/index.js new file mode 100644 index 00000000..21b0df80 --- /dev/null +++ b/institutional-repository-sync-sla-guard/index.js @@ -0,0 +1,131 @@ +const crypto = require("crypto"); + +function digest(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function embargoDays(embargoUntil, now = new Date("2026-05-29T00:00:00Z")) { + if (!embargoUntil) return 0; + const embargoDate = new Date(`${embargoUntil}T00:00:00Z`); + if (Number.isNaN(embargoDate.getTime())) return 0; + return Math.ceil((embargoDate.getTime() - now.getTime()) / 86_400_000); +} + +function evaluateJob(job) { + const findings = []; + + if (job.mandate.dueInHours < 0) { + findings.push({ + severity: "blocker", + code: "mandate-deadline-missed", + message: `${job.mandate.funder} repository sync is ${Math.abs(job.mandate.dueInHours)} hour(s) overdue.`, + }); + } else if (job.mandate.dueInHours <= 48) { + findings.push({ + severity: "review", + code: "mandate-deadline-near", + message: `${job.mandate.funder} deadline is within ${job.mandate.dueInHours} hour(s).`, + }); + } + + if (!job.package.metadataComplete) { + findings.push({ + severity: "blocker", + code: "metadata-incomplete", + message: "Repository export metadata is incomplete.", + }); + } + + if (job.mandate.requiresDoi && !job.package.doiPresent) { + findings.push({ + severity: "blocker", + code: "missing-doi", + message: "Target repository or funder mandate requires DOI preservation.", + }); + } + + if (job.mandate.requiresOrcid && job.package.orcidCoveragePercent < 90) { + findings.push({ + severity: "review", + code: "low-orcid-coverage", + message: `ORCID coverage is ${job.package.orcidCoveragePercent}%, below the 90% admin threshold.`, + }); + } + + if (!job.package.artifactHashPinned) { + findings.push({ + severity: "blocker", + code: "unpinned-artifact-hash", + message: "Export package must pin artifact hashes before repository sync.", + }); + } + + const holdDays = embargoDays(job.package.embargoUntil); + if (holdDays > 0) { + findings.push({ + severity: "blocker", + code: "active-embargo", + message: `Repository sync is blocked by an active embargo for ${holdDays} day(s).`, + }); + } + + if (job.operations.queuedHours >= 24 || job.operations.retryCount >= 3) { + findings.push({ + severity: "review", + code: "sync-backlog-risk", + message: `Sync has queued for ${job.operations.queuedHours} hour(s) with ${job.operations.retryCount} retry attempt(s).`, + }); + } + + if (job.operations.lastWebhookStatus === "failed") { + findings.push({ + severity: "review", + code: "webhook-delivery-failed", + message: "Last institutional webhook delivery failed and requires admin visibility.", + }); + } + + const blockers = findings.filter((finding) => finding.severity === "blocker"); + const reviews = findings.filter((finding) => finding.severity === "review"); + const decision = blockers.length ? "blocked" : reviews.length ? "needs_review" : "ready"; + + return { + id: job.id, + project: job.project, + targetRepository: job.target.repository, + targetType: job.target.type, + adminOwner: job.operations.adminOwner, + decision, + findings, + escalation: decision === "ready" ? "release-to-sync-worker" : `route-to-${job.operations.adminOwner}`, + auditDigest: digest({ + id: job.id, + decision, + findings: findings.map((finding) => finding.code), + target: job.target, + mandate: job.mandate, + }), + }; +} + +function evaluateSyncJobs(jobs) { + const decisions = jobs.map(evaluateJob); + const summary = decisions.reduce( + (acc, decision) => { + acc.total += 1; + acc[decision.decision] += 1; + acc.findingCount += decision.findings.length; + return acc; + }, + { total: 0, ready: 0, needs_review: 0, blocked: 0, findingCount: 0 } + ); + return { + generatedAt: "2026-05-29T00:00:00.000Z", + guard: "institutional-repository-sync-sla-guard", + summary, + decisions, + reportDigest: digest({ summary, decisions }), + }; +} + +module.exports = { evaluateJob, evaluateSyncJobs, embargoDays }; diff --git a/institutional-repository-sync-sla-guard/readme.md b/institutional-repository-sync-sla-guard/readme.md new file mode 100644 index 00000000..99ecd64d --- /dev/null +++ b/institutional-repository-sync-sla-guard/readme.md @@ -0,0 +1,27 @@ +# Institutional Repository Sync SLA Guard + +This is a focused Enterprise Tooling slice for SCIBASE export pipelines. It evaluates institutional repository sync jobs before they are released to external systems such as Zenodo, PubMed Central, arXiv, Invenio, DSpace, funder portals, or institutional archives. + +The module uses synthetic reviewer scenarios only. It does not call external repositories, APIs, webhooks, identity providers, payment systems, live projects, private user data, credentials, or tokens. + +## What It Checks + +- Funder mandate deadlines and overdue sync jobs. +- Metadata completeness, DOI preservation, ORCID coverage, and artifact hash pins. +- Embargo holds before export. +- Backlog/retry risk and webhook delivery visibility for enterprise admins. +- Deterministic audit digests and escalation routes. + +## Run + +```bash +node institutional-repository-sync-sla-guard/test.js +node institutional-repository-sync-sla-guard/demo.js +node institutional-repository-sync-sla-guard/render-video.js +``` + +Generated reviewer artifacts are written to `institutional-repository-sync-sla-guard/reports/`. + +## Scope Boundary + +This slice is distinct from existing #19 work around enterprise dashboards, institutional admin auditing, generic API/webhook integrations, API rate limiting, webhook delivery, accessibility, repository/archive export, artifact hosting, and broad integration modules. It focuses specifically on SLA and compliance governance for institutional repository sync backlogs. diff --git a/institutional-repository-sync-sla-guard/render-video.js b/institutional-repository-sync-sla-guard/render-video.js new file mode 100644 index 00000000..89ce11e5 --- /dev/null +++ b/institutional-repository-sync-sla-guard/render-video.js @@ -0,0 +1,73 @@ +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const { evaluateSyncJobs } = require("./index"); +const { syncJobs } = require("./sample-data"); + +const reportDir = path.join(__dirname, "reports"); +const framePath = path.join(reportDir, "demo-frame.ppm"); +const mp4Path = path.join(reportDir, "demo.mp4"); +const report = evaluateSyncJobs(syncJobs); + +function rgb(hex) { + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + ]; +} + +function rect(buffer, width, x, y, w, h, color) { + const [r, g, b] = rgb(color); + for (let row = y; row < y + h; row += 1) { + for (let col = x; col < x + w; col += 1) { + const idx = (row * width + col) * 3; + buffer[idx] = r; + buffer[idx + 1] = g; + buffer[idx + 2] = b; + } + } +} + +const width = 960; +const height = 540; +const pixels = Buffer.alloc(width * height * 3); +for (let i = 0; i < pixels.length; i += 3) { + pixels[i] = 0xee; + pixels[i + 1] = 0xf3; + pixels[i + 2] = 0xf7; +} +rect(pixels, width, 54, 52, 852, 436, "ffffff"); +rect(pixels, width, 54, 52, 852, 4, "1f3349"); +rect(pixels, width, 54, 484, 852, 4, "1f3349"); +rect(pixels, width, 54, 52, 4, 436, "1f3349"); +rect(pixels, width, 902, 52, 4, 436, "1f3349"); +rect(pixels, width, 96, 180, Math.max(1, report.summary.ready) * 120, 58, "2f855a"); +rect(pixels, width, 96, 270, Math.max(1, report.summary.needs_review) * 120, 58, "b7791f"); +rect(pixels, width, 96, 360, Math.max(1, report.summary.blocked) * 120, 58, "b83232"); +rect(pixels, width, 96, 450, 720, 12, "42566f"); +fs.writeFileSync(framePath, Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), pixels])); + +execFileSync( + "ffmpeg", + [ + "-y", + "-loop", + "1", + "-framerate", + "24", + "-i", + framePath, + "-t", + "5", + "-vf", + "format=yuv420p", + "-movflags", + "+faststart", + mp4Path, + ], + { stdio: "inherit" } +); + +console.log(mp4Path); diff --git a/institutional-repository-sync-sla-guard/reports/demo.mp4 b/institutional-repository-sync-sla-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1f1f4499e9c6ae53038f8ebe703b20f23379d84c GIT binary patch literal 7075 zcmeHKeN+_J6@R#hptaEi3D&Pch>0J&JNp4alC2;bF(*kq_)!m-Zf9p^*^!x@VP=3` zP3j^TJ&Cr*lwyNNHf>BThKlwiTD4XZsS;CJ-^`-uviy;g z)Ao;jXJ+1g_uhAZ@4k26yKfLegj@AXoFWPc4MDhu*Gg5;7D2LG5K25Gh++joh!-k6 z4AlLK;R-^JtV3}KKljRg4PZae&b_BO5H$p$Vb@fdDh1&(wN%42^ivF51KbD812_ZW z8d|{1@mj|WmQfX;#2najVwT=wWy{Yy(wr z4BJQJdI_|}0?NzqEY=w|O5nV1Ad)Kt&1nB&XE3^KJR`GM2Nx{=R=La*I-PJrQC&RH zI~7&wTM(@CCSEkCap`YUV;EY%?E)b2i%Z;?=RiNpYO-dVETo0tIhW6xW!Ht;H#HQ3 zIt#S&;}|q4)Q-lMK;3jYw|n%D+7U8f6ayk3O10F*p#)Fo#g8Vp-0A7Tos(Uv9G=t( zmo;k$r7Eg6;kb#%=feR9B^ms{3xNyfW25#32aBS~hKK9EHg4iRnk}efa(zBx z^P|rL4v$!T{P*@pBA##9jM{!24||)&3@G8fjrTpK{q<#0DoYl-3ys8G_8_hRkMxhr}5%(Jix(k{|w^09p{4%?U$8p?A*C?Kp7x zbitmw8K;QT>0Q@x`Xh`4Ey^rm$;u>To7HM1T(-x>dqTk~X82v}OX+qpDJl*<4v!X~Gu*k*B;SQ7$oK6q834c+e3gl@q;6 z4nb2cigs8CNcs*N!LTk~q)Q!^95RO_C@;nP71lxeY&oRQVzChd>+rBXLMe9v^&}xF ze&`Bcr_4AkCK5WqhY&a)%Q&$`3-oZxl(&R+Sh5J(BZ~s%gsv7sm06zW6ri$w*$k}$ zL6->*5{5uAD?~5ruv%tVEQFg(J+`k21$5j4TR^WD9@PahQDVK$5>W!F z&Xk~QDeDK@4r?YECY^9ib6D+!LbG0$E>|6P5)V_R@SMu5;(_im?Y!$Qmdmt*mfeH^ zw;vX}1r3MQW+I6)?2LmnWdSK+!&*)G99c7gp{T6nuoIjFs{|1NiwL%;G6(>?oG1Vx z+rX}?K~IHxFTT^0eERXt$!#}X_S^rMUvWImR(3RWq4JX@_%0THhCXidzqQBs<73Zg zoQ#$sE0=a8td#v|$uIuhzB*~&)8|e#)lYq@s`lD}N!xF(XUA4veKTc-I_$IJHTetb zvp?8|`^_s@l>Pawj_#g?AMBa6Z&qjhG_G^Vd+9}N^V+4xB^%qacAU&PBm89Th!1Ox zWd$t=CAr;QD|=tOReq{u)&eU3yN$rJ4gWJTRF5Qv9xfywy?PdHDr}8^b-|Uot>(;q z$${_fI(lwic)^U2Pl3P1{?Hdgm9n(zn|^mbb55pM!=^ANf{Vuf00d z6nHK5YKKBLjj{P3*|zx7rYp_7T(ex9lly7c`Ncnt|HJ;nA7ALW(|qmc@j<>gcwU$~ za!X3m`ii9A#Mi7|SyU&z8#tb_=xUms{B2K>pbRxtRb`{1)8-!>M)7URh-3^nuE1lGM5{x=U=G$>Qd>M58+3` zfTo-9Hw7QE6Z7gb`22&tjdfbk8Bor%w~34+dzay+p%64u@)~CQ*1%82-9QDx^*)xw zHBjp8gZE3SVVqb(FOQH^fn-u%y=VTf^E_`nmslCPhZ;7%Fb`Bx^BP{6J9>sW3gp|7 zkk~LlZi|Er=#WhS$wz_A1r>liI(JM|$c{)zY#1Orqe6BM0;zi${C0H6pfM6s_cCaX z2D#Zj2&C?1uy7E_vPekX%b*e!(l-cX*vq<=K_GKM zB{eVjmw_PP*CGEE2^r8Ox1vH`7z9%HvSH#tkS&_w21hi=hFQ@d>oW#{%muR$v9kw) z)V=)L`!d=l348g)e>zEfb;O^+&1d=pe + + + Institutional Repository Sync SLA Guard + Enterprise export-pipeline reviewer artifact + + + + ready 1 + review 0 + blocked 3 + Digest b694760bd00672e8fd0c17f288c6007b... + diff --git a/institutional-repository-sync-sla-guard/sample-data.js b/institutional-repository-sync-sla-guard/sample-data.js new file mode 100644 index 00000000..fd6f9d33 --- /dev/null +++ b/institutional-repository-sync-sla-guard/sample-data.js @@ -0,0 +1,128 @@ +const syncJobs = [ + { + id: "zenodo-open-access-overdue", + project: "Single-cell reproducibility atlas", + target: { + repository: "Zenodo", + type: "indexed-repository", + region: "EU", + }, + mandate: { + funder: "Horizon EU", + dueInHours: -18, + requiresOpenAccess: true, + requiresDoi: true, + requiresOrcid: true, + }, + package: { + metadataComplete: true, + doiPresent: true, + orcidCoveragePercent: 94, + license: "CC-BY-4.0", + embargoUntil: null, + artifactHashPinned: true, + }, + operations: { + queuedHours: 42, + retryCount: 5, + lastWebhookStatus: "failed", + lastWebhookAgeHours: 13, + adminOwner: "research-office", + }, + }, + { + id: "pubmed-metadata-gap", + project: "Clinical review supplement", + target: { + repository: "PubMed Central", + type: "funder-portal", + region: "US", + }, + mandate: { + funder: "NIH", + dueInHours: 36, + requiresOpenAccess: true, + requiresDoi: true, + requiresOrcid: true, + }, + package: { + metadataComplete: false, + doiPresent: true, + orcidCoveragePercent: 67, + license: "CC-BY-4.0", + embargoUntil: null, + artifactHashPinned: true, + }, + operations: { + queuedHours: 8, + retryCount: 1, + lastWebhookStatus: "delivered", + lastWebhookAgeHours: 2, + adminOwner: "compliance-desk", + }, + }, + { + id: "arxiv-ready", + project: "Materials preprint export", + target: { + repository: "arXiv", + type: "preprint-repository", + region: "US", + }, + mandate: { + funder: "Internal", + dueInHours: 120, + requiresOpenAccess: false, + requiresDoi: false, + requiresOrcid: true, + }, + package: { + metadataComplete: true, + doiPresent: false, + orcidCoveragePercent: 100, + license: "MIT", + embargoUntil: null, + artifactHashPinned: true, + }, + operations: { + queuedHours: 3, + retryCount: 0, + lastWebhookStatus: "delivered", + lastWebhookAgeHours: 1, + adminOwner: "lab-admin", + }, + }, + { + id: "embargoed-dataset-hold", + project: "Corporate R&D catalyst screen", + target: { + repository: "Invenio", + type: "institutional-repository", + region: "US", + }, + mandate: { + funder: "Industry sponsor", + dueInHours: 72, + requiresOpenAccess: false, + requiresDoi: true, + requiresOrcid: false, + }, + package: { + metadataComplete: true, + doiPresent: true, + orcidCoveragePercent: 50, + license: "proprietary-review", + embargoUntil: "2026-06-15", + artifactHashPinned: true, + }, + operations: { + queuedHours: 15, + retryCount: 0, + lastWebhookStatus: "not-sent", + lastWebhookAgeHours: null, + adminOwner: "sponsor-ops", + }, + }, +]; + +module.exports = { syncJobs }; diff --git a/institutional-repository-sync-sla-guard/test.js b/institutional-repository-sync-sla-guard/test.js new file mode 100644 index 00000000..1e1ecf92 --- /dev/null +++ b/institutional-repository-sync-sla-guard/test.js @@ -0,0 +1,39 @@ +const assert = require("assert"); + +const { embargoDays, evaluateJob, evaluateSyncJobs } = require("./index"); +const { syncJobs } = require("./sample-data"); + +assert.strictEqual(embargoDays("2026-06-15"), 17); +assert.strictEqual(embargoDays(null), 0); + +const overdue = evaluateJob(syncJobs[0]); +assert.strictEqual(overdue.decision, "blocked"); +assert(overdue.findings.some((finding) => finding.code === "mandate-deadline-missed")); +assert(overdue.findings.some((finding) => finding.code === "sync-backlog-risk")); +assert(overdue.findings.some((finding) => finding.code === "webhook-delivery-failed")); + +const metadataGap = evaluateJob(syncJobs[1]); +assert.strictEqual(metadataGap.decision, "blocked"); +assert(metadataGap.findings.some((finding) => finding.code === "metadata-incomplete")); +assert(metadataGap.findings.some((finding) => finding.code === "low-orcid-coverage")); + +const ready = evaluateJob(syncJobs[2]); +assert.strictEqual(ready.decision, "ready"); +assert.strictEqual(ready.escalation, "release-to-sync-worker"); + +const embargoed = evaluateJob(syncJobs[3]); +assert.strictEqual(embargoed.decision, "blocked"); +assert(embargoed.findings.some((finding) => finding.code === "active-embargo")); + +const report = evaluateSyncJobs(syncJobs); +assert.deepStrictEqual(report.summary, { + total: 4, + ready: 1, + needs_review: 0, + blocked: 3, + findingCount: 7, +}); +assert.match(report.reportDigest, /^[0-9a-f]{64}$/); +assert(report.decisions.every((decision) => /^[0-9a-f]{64}$/.test(decision.auditDigest))); + +console.log("institutional-repository-sync-sla-guard tests passed");