diff --git a/revenue-dunning-entitlement-hold-guard/demo.js b/revenue-dunning-entitlement-hold-guard/demo.js
new file mode 100644
index 00000000..24e7a56d
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/demo.js
@@ -0,0 +1,55 @@
+const fs = require("fs");
+const path = require("path");
+const { billingBatch } = require("./sample-data");
+const { evaluateBatch } = require("./index");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const report = evaluateBatch(billingBatch);
+fs.writeFileSync(path.join(reportsDir, "dunning-entitlement-report.json"), `${JSON.stringify(report, null, 2)}\n`);
+
+const markdown = [
+ "# Revenue Dunning and Entitlement Hold Report",
+ "",
+ `Batch: ${report.batchId}`,
+ `Generated: ${report.generatedAt}`,
+ "",
+ "## Summary",
+ "",
+ `- Accounts evaluated: ${report.summary.total}`,
+ `- Kept active: ${report.summary["keep-active"] || 0}`,
+ `- Finance exceptions: ${report.summary["finance-exception"] || 0}`,
+ `- Entitlement holds: ${report.summary["hold-entitlements"] || 0}`,
+ `- Findings: ${report.summary.findingCount}`,
+ "",
+ "## Decisions",
+ "",
+ ...report.accounts.flatMap((account) => [
+ `### ${account.id}`,
+ "",
+ `- Decision: ${account.decision}`,
+ `- Findings: ${account.findings.length ? account.findings.join("; ") : "none"}`,
+ `- Actions: ${account.actions.join("; ")}`,
+ ""
+ ])
+].join("\n").trim();
+
+fs.writeFileSync(path.join(reportsDir, "dunning-entitlement-report.md"), `${markdown}\n`);
+
+const svg = `
+`;
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg);
+console.log(JSON.stringify(report.summary, null, 2));
diff --git a/revenue-dunning-entitlement-hold-guard/index.js b/revenue-dunning-entitlement-hold-guard/index.js
new file mode 100644
index 00000000..0a27802f
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/index.js
@@ -0,0 +1,47 @@
+function evaluateAccount(account) {
+ const findings = [];
+
+ if (account.invoiceStatus === "paid") {
+ return {
+ id: account.id,
+ decision: "keep-active",
+ findings,
+ actions: ["keep entitlements active", "allow queued exports"]
+ };
+ }
+
+ if (account.failedAttempts >= 3) findings.push("retry limit exceeded");
+ if (account.daysPastDue >= 7 && !account.dunningNotices.includes("day-7")) findings.push("missing day-7 dunning notice");
+ if (account.daysPastDue >= 14 && !account.dunningNotices.includes("day-14")) findings.push("missing day-14 dunning notice");
+ if (account.entitlement === "active" && account.daysPastDue >= 14) findings.push("active entitlement beyond grace window");
+ if (account.computeCreditsRemaining > 0 && account.failedAttempts >= 3) findings.push("compute credits should be held after repeated payment failure");
+ if (account.analyticsExportQueued && account.invoiceStatus === "failed") findings.push("analytics license export queued for unpaid account");
+ if (account.institutionalInvoice && account.failedAttempts < 3) findings.push("institutional invoice should route to finance exception workflow");
+
+ const decision = account.institutionalInvoice && account.failedAttempts < 3
+ ? "finance-exception"
+ : findings.length > 0
+ ? "hold-entitlements"
+ : "continue-grace";
+
+ const actions = {
+ "finance-exception": ["route to institutional billing desk", "pause analytics export until invoice evidence is attached"],
+ "hold-entitlements": ["hold risky entitlements", "pause compute credits or exports", "send remediation notice"],
+ "continue-grace": ["continue grace period", "schedule next dunning notice"]
+ }[decision];
+
+ return { id: account.id, decision, findings, actions };
+}
+
+function evaluateBatch(batch) {
+ const accounts = batch.accounts.map(evaluateAccount);
+ const summary = accounts.reduce((acc, account) => {
+ acc.total += 1;
+ acc[account.decision] = (acc[account.decision] || 0) + 1;
+ acc.findingCount += account.findings.length;
+ return acc;
+ }, { total: 0, findingCount: 0 });
+ return { batchId: batch.id, generatedAt: batch.generatedAt, summary, accounts };
+}
+
+module.exports = { evaluateAccount, evaluateBatch };
diff --git a/revenue-dunning-entitlement-hold-guard/readme.md b/revenue-dunning-entitlement-hold-guard/readme.md
new file mode 100644
index 00000000..8473a80c
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/readme.md
@@ -0,0 +1,33 @@
+# Revenue Dunning Entitlement Hold Guard
+
+This module adds a focused failed-payment control for SCIBASE Revenue Infrastructure. It decides when renewal invoices and AI-compute top-ups should continue grace, route to finance exception handling, or hold risky entitlements after payment failures.
+
+It is intentionally separate from broad billing ledgers, usage metering, receipt privacy, grant cost-share controls, sanctions/export checks, quote approval, seat rosters, payment authorization, customer consolidation, and revenue recognition.
+
+## What It Checks
+
+- Failed payment retry windows
+- Dunning notice cadence
+- Entitlement grace periods
+- Institutional invoice exceptions
+- AI compute-credit hold safety
+- Analytics-license export holds
+
+## Demo
+
+Run:
+
+```bash
+node revenue-dunning-entitlement-hold-guard/test.js
+node revenue-dunning-entitlement-hold-guard/demo.js
+node revenue-dunning-entitlement-hold-guard/render-video.js
+```
+
+Generated artifacts:
+
+- `reports/dunning-entitlement-report.json`
+- `reports/dunning-entitlement-report.md`
+- `reports/summary.svg`
+- `reports/demo.mp4`
+
+All data is synthetic. The module does not call payment providers, banks, wallets, billing systems, ERP systems, private accounts, credentials, or SCIBASE production services.
diff --git a/revenue-dunning-entitlement-hold-guard/render-video.js b/revenue-dunning-entitlement-hold-guard/render-video.js
new file mode 100644
index 00000000..59fe2648
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/render-video.js
@@ -0,0 +1,38 @@
+const fs = require("fs");
+const path = require("path");
+const { spawnSync } = require("child_process");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+const width = 960;
+const height = 540;
+const ppm = path.join(reportsDir, "demo-frame.ppm");
+const mp4 = path.join(reportsDir, "demo.mp4");
+
+function pixel(x, y) {
+ if (y < 112) return [23, 32, 51];
+ if (x > 46 && x < 914 && y > 126 && y < 188) return [255, 255, 255];
+ if (x > 46 && x < 914 && y > 210 && y < 272) return [255, 255, 255];
+ if (x > 46 && x < 914 && y > 294 && y < 356) return [255, 255, 255];
+ if (x > 46 && x < 914 && y > 378 && y < 440) return [255, 255, 255];
+ if (x > 64 && x < 238 && y > 140 && y < 174) return [4, 120, 87];
+ if (x > 64 && x < 238 && y > 224 && y < 258) return [37, 99, 235];
+ if (x > 64 && x < 238 && y > 308 && y < 342) return [180, 83, 9];
+ if (x > 64 && x < 238 && y > 392 && y < 426) return [180, 83, 9];
+ return [248, 250, 252];
+}
+const header = `P6\n${width} ${height}\n255\n`;
+const body = Buffer.alloc(width * height * 3);
+for (let y = 0; y < height; y += 1) {
+ for (let x = 0; x < width; x += 1) {
+ const offset = (y * width + x) * 3;
+ const [r, g, b] = pixel(x, y);
+ body[offset] = r;
+ body[offset + 1] = g;
+ body[offset + 2] = b;
+ }
+}
+fs.writeFileSync(ppm, Buffer.concat([Buffer.from(header), body]));
+const result = spawnSync("ffmpeg", ["-y", "-loop", "1", "-framerate", "24", "-i", ppm, "-t", "5", "-vf", "format=yuv420p", "-movflags", "+faststart", mp4], { stdio: "inherit" });
+if (result.status !== 0) throw new Error("ffmpeg failed to render demo video");
+console.log(mp4);
diff --git a/revenue-dunning-entitlement-hold-guard/reports/demo.mp4 b/revenue-dunning-entitlement-hold-guard/reports/demo.mp4
new file mode 100644
index 00000000..638c1cc1
Binary files /dev/null and b/revenue-dunning-entitlement-hold-guard/reports/demo.mp4 differ
diff --git a/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.json b/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.json
new file mode 100644
index 00000000..604391ae
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.json
@@ -0,0 +1,61 @@
+{
+ "batchId": "revenue-dunning-2026-05-29",
+ "generatedAt": "2026-05-29T15:04:00.000Z",
+ "summary": {
+ "total": 4,
+ "findingCount": 7,
+ "keep-active": 1,
+ "finance-exception": 1,
+ "hold-entitlements": 2
+ },
+ "accounts": [
+ {
+ "id": "acct-lab-1",
+ "decision": "keep-active",
+ "findings": [],
+ "actions": [
+ "keep entitlements active",
+ "allow queued exports"
+ ]
+ },
+ {
+ "id": "acct-inst-2",
+ "decision": "finance-exception",
+ "findings": [
+ "analytics license export queued for unpaid account",
+ "institutional invoice should route to finance exception workflow"
+ ],
+ "actions": [
+ "route to institutional billing desk",
+ "pause analytics export until invoice evidence is attached"
+ ]
+ },
+ {
+ "id": "acct-ai-3",
+ "decision": "hold-entitlements",
+ "findings": [
+ "retry limit exceeded",
+ "active entitlement beyond grace window",
+ "compute credits should be held after repeated payment failure"
+ ],
+ "actions": [
+ "hold risky entitlements",
+ "pause compute credits or exports",
+ "send remediation notice"
+ ]
+ },
+ {
+ "id": "acct-api-4",
+ "decision": "hold-entitlements",
+ "findings": [
+ "missing day-7 dunning notice",
+ "analytics license export queued for unpaid account"
+ ],
+ "actions": [
+ "hold risky entitlements",
+ "pause compute credits or exports",
+ "send remediation notice"
+ ]
+ }
+ ]
+}
diff --git a/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.md b/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.md
new file mode 100644
index 00000000..e11081e2
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/reports/dunning-entitlement-report.md
@@ -0,0 +1,38 @@
+# Revenue Dunning and Entitlement Hold Report
+
+Batch: revenue-dunning-2026-05-29
+Generated: 2026-05-29T15:04:00.000Z
+
+## Summary
+
+- Accounts evaluated: 4
+- Kept active: 1
+- Finance exceptions: 1
+- Entitlement holds: 2
+- Findings: 7
+
+## Decisions
+
+### acct-lab-1
+
+- Decision: keep-active
+- Findings: none
+- Actions: keep entitlements active; allow queued exports
+
+### acct-inst-2
+
+- Decision: finance-exception
+- Findings: analytics license export queued for unpaid account; institutional invoice should route to finance exception workflow
+- Actions: route to institutional billing desk; pause analytics export until invoice evidence is attached
+
+### acct-ai-3
+
+- Decision: hold-entitlements
+- Findings: retry limit exceeded; active entitlement beyond grace window; compute credits should be held after repeated payment failure
+- Actions: hold risky entitlements; pause compute credits or exports; send remediation notice
+
+### acct-api-4
+
+- Decision: hold-entitlements
+- Findings: missing day-7 dunning notice; analytics license export queued for unpaid account
+- Actions: hold risky entitlements; pause compute credits or exports; send remediation notice
diff --git a/revenue-dunning-entitlement-hold-guard/reports/summary.svg b/revenue-dunning-entitlement-hold-guard/reports/summary.svg
new file mode 100644
index 00000000..400694c6
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/reports/summary.svg
@@ -0,0 +1,21 @@
+
diff --git a/revenue-dunning-entitlement-hold-guard/sample-data.js b/revenue-dunning-entitlement-hold-guard/sample-data.js
new file mode 100644
index 00000000..1160e9a1
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/sample-data.js
@@ -0,0 +1,56 @@
+const billingBatch = {
+ id: "revenue-dunning-2026-05-29",
+ generatedAt: "2026-05-29T15:04:00.000Z",
+ accounts: [
+ {
+ id: "acct-lab-1",
+ plan: "lab-pro",
+ invoiceStatus: "paid",
+ failedAttempts: 0,
+ daysPastDue: 0,
+ dunningNotices: [],
+ entitlement: "active",
+ computeCreditsRemaining: 120,
+ institutionalInvoice: false,
+ analyticsExportQueued: false
+ },
+ {
+ id: "acct-inst-2",
+ plan: "institution",
+ invoiceStatus: "failed",
+ failedAttempts: 1,
+ daysPastDue: 5,
+ dunningNotices: ["day-1"],
+ entitlement: "active",
+ computeCreditsRemaining: 700,
+ institutionalInvoice: true,
+ analyticsExportQueued: true
+ },
+ {
+ id: "acct-ai-3",
+ plan: "compute-pack",
+ invoiceStatus: "failed",
+ failedAttempts: 4,
+ daysPastDue: 18,
+ dunningNotices: ["day-1", "day-7", "day-14"],
+ entitlement: "active",
+ computeCreditsRemaining: 30,
+ institutionalInvoice: false,
+ analyticsExportQueued: false
+ },
+ {
+ id: "acct-api-4",
+ plan: "analytics-api",
+ invoiceStatus: "failed",
+ failedAttempts: 2,
+ daysPastDue: 9,
+ dunningNotices: ["day-1"],
+ entitlement: "grace",
+ computeCreditsRemaining: 0,
+ institutionalInvoice: false,
+ analyticsExportQueued: true
+ }
+ ]
+};
+
+module.exports = { billingBatch };
diff --git a/revenue-dunning-entitlement-hold-guard/test.js b/revenue-dunning-entitlement-hold-guard/test.js
new file mode 100644
index 00000000..eea73510
--- /dev/null
+++ b/revenue-dunning-entitlement-hold-guard/test.js
@@ -0,0 +1,24 @@
+const assert = require("assert");
+const { billingBatch } = require("./sample-data");
+const { evaluateBatch } = require("./index");
+
+const report = evaluateBatch(billingBatch);
+
+assert.strictEqual(report.summary.total, 4);
+assert.strictEqual(report.summary["keep-active"], 1);
+assert.strictEqual(report.summary["finance-exception"], 1);
+assert.strictEqual(report.summary["hold-entitlements"], 2);
+
+const institutional = report.accounts.find((account) => account.id === "acct-inst-2");
+assert.strictEqual(institutional.decision, "finance-exception");
+assert(institutional.actions.some((action) => action.includes("institutional billing")));
+
+const compute = report.accounts.find((account) => account.id === "acct-ai-3");
+assert(compute.findings.some((finding) => finding.includes("retry limit")));
+assert(compute.findings.some((finding) => finding.includes("compute credits")));
+
+const analytics = report.accounts.find((account) => account.id === "acct-api-4");
+assert(analytics.findings.some((finding) => finding.includes("missing day-7")));
+assert(analytics.findings.some((finding) => finding.includes("analytics license export")));
+
+console.log("revenue-dunning-entitlement-hold-guard tests passed");