diff --git a/subscription-cancellation-effective-date-guard/README.md b/subscription-cancellation-effective-date-guard/README.md
new file mode 100644
index 00000000..a2489329
--- /dev/null
+++ b/subscription-cancellation-effective-date-guard/README.md
@@ -0,0 +1,34 @@
+# Subscription Cancellation Effective-Date Guard
+
+This is a focused Revenue Infrastructure slice for issue #20. It evaluates
+subscription cancellation packets before entitlements, AI compute credits,
+analytics licenses, and renewal invoices are changed.
+
+The guard uses synthetic data only and has no external service dependencies.
+
+## Checks
+
+- Cancellation requests submitted after the contractual notice window.
+- Cancellation effective dates that do not match the contract end date.
+- Renewal invoices that are still scheduled after cancellation.
+- Refund or service-credit obligations that have no finance review.
+- AI compute credits that would remain usable after the wind-down date.
+- Analytics-license exports that continue past the cancellation cutoff.
+- Institutional purchase-order exceptions that require manual handling.
+
+## Local Verification
+
+```bash
+node subscription-cancellation-effective-date-guard/test.js
+node subscription-cancellation-effective-date-guard/demo.js
+```
+
+Demo artifacts are written to
+`subscription-cancellation-effective-date-guard/reports/`.
+
+## Issue #20 Mapping
+
+This targets the subscription billing, AI compute billing, and analytics
+licensing layers in issue #20. It is distinct from renewal notice, trial abuse,
+dunning, downgrade, payment authorization, account transfer, collections,
+receipt privacy, quote approval, sanctions, and analytics seat roster slices.
diff --git a/subscription-cancellation-effective-date-guard/cancellationGuard.js b/subscription-cancellation-effective-date-guard/cancellationGuard.js
new file mode 100644
index 00000000..4e2b32b9
--- /dev/null
+++ b/subscription-cancellation-effective-date-guard/cancellationGuard.js
@@ -0,0 +1,153 @@
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+function parseDate(value) {
+ const time = Date.parse(value);
+ if (Number.isNaN(time)) {
+ throw new Error(`Invalid date: ${value}`);
+ }
+ return new Date(time);
+}
+
+function daysBetween(start, end) {
+ return Math.ceil((parseDate(end) - parseDate(start)) / MS_PER_DAY);
+}
+
+function addFinding(findings, severity, id, message, action, evidence = {}) {
+ findings.push({ severity, id, message, action, evidence });
+}
+
+function evaluateCancellationPacket(packet) {
+ const findings = [];
+ const noticeDays = daysBetween(packet.requestedAt, packet.contractEndDate);
+
+ if (noticeDays < packet.requiredNoticeDays) {
+ addFinding(
+ findings,
+ "hold",
+ "late-cancellation-notice",
+ `Cancellation notice provides ${noticeDays} days, below the required ${packet.requiredNoticeDays} days.`,
+ "Route to finance/legal for late-notice approval before suppressing renewal revenue.",
+ { noticeDays, requiredNoticeDays: packet.requiredNoticeDays }
+ );
+ }
+
+ if (packet.effectiveDate !== packet.contractEndDate && !packet.earlyTerminationApprovalId) {
+ addFinding(
+ findings,
+ "block",
+ "unapproved-effective-date",
+ "Cancellation effective date differs from the contract end date without early-termination approval.",
+ "Attach early-termination approval or align the effective date with the contract end date.",
+ { effectiveDate: packet.effectiveDate, contractEndDate: packet.contractEndDate }
+ );
+ }
+
+ for (const invoice of packet.renewalInvoices || []) {
+ if (invoice.status === "scheduled" && Date.parse(invoice.issueDate) >= Date.parse(packet.effectiveDate)) {
+ addFinding(
+ findings,
+ "block",
+ `scheduled-renewal-invoice:${invoice.id}`,
+ `Renewal invoice ${invoice.id} is still scheduled after cancellation takes effect.`,
+ "Cancel or hold the renewal invoice before finalizing the cancellation.",
+ invoice
+ );
+ }
+ }
+
+ if (packet.refundDueCents > 0 && !packet.financeReviewId) {
+ addFinding(
+ findings,
+ "hold",
+ "refund-without-review",
+ "A refund or service credit is due but no finance review is attached.",
+ "Create a finance review before issuing credit notes or refund payments.",
+ { refundDueCents: packet.refundDueCents }
+ );
+ }
+
+ for (const creditLot of packet.computeCreditLots || []) {
+ if (creditLot.remainingUnits > 0 && Date.parse(creditLot.expiresAt) > Date.parse(packet.computeWindDownDate)) {
+ addFinding(
+ findings,
+ "hold",
+ `compute-credit-overhang:${creditLot.id}`,
+ `Compute credit lot ${creditLot.id} remains usable after the wind-down date.`,
+ "Expire, refund, or convert the remaining compute credits before cancellation close.",
+ creditLot
+ );
+ }
+ }
+
+ for (const exportGrant of packet.analyticsExportGrants || []) {
+ if (Date.parse(exportGrant.accessUntil) > Date.parse(packet.analyticsCutoffDate)) {
+ addFinding(
+ findings,
+ "block",
+ `analytics-export-overhang:${exportGrant.id}`,
+ `Analytics export grant ${exportGrant.id} outlives the cancellation cutoff.`,
+ "Revoke or shorten export access before confirming cancellation.",
+ exportGrant
+ );
+ }
+ }
+
+ if (packet.institutionalPoException && !packet.poCloseoutId) {
+ addFinding(
+ findings,
+ "hold",
+ "po-closeout-missing",
+ "Institutional purchase-order exception is present without a closeout record.",
+ "Attach PO closeout evidence before marking the account cancelled.",
+ { institutionalPoException: true }
+ );
+ }
+
+ const blockers = findings.filter((finding) => finding.severity === "block").length;
+ const holds = findings.filter((finding) => finding.severity === "hold").length;
+ const warnings = findings.filter((finding) => finding.severity === "warn").length;
+
+ return {
+ accountId: packet.accountId,
+ cancellationId: packet.cancellationId,
+ decision: blockers > 0 ? "block-cancellation-close" : holds > 0 ? "hold-for-finance-review" : "ready-to-close",
+ summary: { blockers, holds, warnings, findings: findings.length },
+ findings
+ };
+}
+
+function toMarkdown(result) {
+ const rows = result.findings.map((finding) => `| ${finding.severity} | ${finding.message} | ${finding.action} |`);
+ return [
+ "# Cancellation Effective-Date Guard Report",
+ "",
+ `Account: ${result.accountId}`,
+ `Cancellation: ${result.cancellationId}`,
+ `Decision: ${result.decision}`,
+ "",
+ "| Severity | Finding | Action |",
+ "| --- | --- | --- |",
+ ...(rows.length ? rows : ["| ok | No issues found. | Close cancellation packet. |"]),
+ ""
+ ].join("\n");
+}
+
+function toSvg(result) {
+ const color = result.decision === "ready-to-close" ? "#0f7b45" : result.decision === "hold-for-finance-review" ? "#9a6700" : "#b42318";
+ return [
+ ``
+ ].join("\n");
+}
+
+module.exports = {
+ evaluateCancellationPacket,
+ toMarkdown,
+ toSvg
+};
diff --git a/subscription-cancellation-effective-date-guard/demo.js b/subscription-cancellation-effective-date-guard/demo.js
new file mode 100644
index 00000000..7605df2a
--- /dev/null
+++ b/subscription-cancellation-effective-date-guard/demo.js
@@ -0,0 +1,16 @@
+const fs = require("fs");
+const path = require("path");
+const { evaluateCancellationPacket, toMarkdown, toSvg } = require("./cancellationGuard");
+const samplePacket = require("./samplePacket");
+
+const result = evaluateCancellationPacket(samplePacket);
+const reportDir = path.join(__dirname, "reports");
+
+fs.mkdirSync(reportDir, { recursive: true });
+fs.writeFileSync(path.join(reportDir, "cancellation-packet.json"), `${JSON.stringify(result, null, 2)}\n`);
+fs.writeFileSync(path.join(reportDir, "cancellation-report.md"), toMarkdown(result));
+fs.writeFileSync(path.join(reportDir, "summary.svg"), toSvg(result));
+
+console.log(`decision=${result.decision}`);
+console.log(`findings=${result.findings.length}`);
+console.log(`reports=${reportDir}`);
diff --git a/subscription-cancellation-effective-date-guard/reports/cancellation-packet.json b/subscription-cancellation-effective-date-guard/reports/cancellation-packet.json
new file mode 100644
index 00000000..3003e085
--- /dev/null
+++ b/subscription-cancellation-effective-date-guard/reports/cancellation-packet.json
@@ -0,0 +1,85 @@
+{
+ "accountId": "inst-lab-204",
+ "cancellationId": "can-2026-05-alpha",
+ "decision": "block-cancellation-close",
+ "summary": {
+ "blockers": 3,
+ "holds": 4,
+ "warnings": 0,
+ "findings": 7
+ },
+ "findings": [
+ {
+ "severity": "hold",
+ "id": "late-cancellation-notice",
+ "message": "Cancellation notice provides 17 days, below the required 30 days.",
+ "action": "Route to finance/legal for late-notice approval before suppressing renewal revenue.",
+ "evidence": {
+ "noticeDays": 17,
+ "requiredNoticeDays": 30
+ }
+ },
+ {
+ "severity": "block",
+ "id": "unapproved-effective-date",
+ "message": "Cancellation effective date differs from the contract end date without early-termination approval.",
+ "action": "Attach early-termination approval or align the effective date with the contract end date.",
+ "evidence": {
+ "effectiveDate": "2026-05-31",
+ "contractEndDate": "2026-06-01"
+ }
+ },
+ {
+ "severity": "block",
+ "id": "scheduled-renewal-invoice:inv-renew-991",
+ "message": "Renewal invoice inv-renew-991 is still scheduled after cancellation takes effect.",
+ "action": "Cancel or hold the renewal invoice before finalizing the cancellation.",
+ "evidence": {
+ "id": "inv-renew-991",
+ "issueDate": "2026-06-01",
+ "status": "scheduled",
+ "amountCents": 4800000
+ }
+ },
+ {
+ "severity": "hold",
+ "id": "refund-without-review",
+ "message": "A refund or service credit is due but no finance review is attached.",
+ "action": "Create a finance review before issuing credit notes or refund payments.",
+ "evidence": {
+ "refundDueCents": 125000
+ }
+ },
+ {
+ "severity": "hold",
+ "id": "compute-credit-overhang:cc-ai-771",
+ "message": "Compute credit lot cc-ai-771 remains usable after the wind-down date.",
+ "action": "Expire, refund, or convert the remaining compute credits before cancellation close.",
+ "evidence": {
+ "id": "cc-ai-771",
+ "remainingUnits": 430,
+ "expiresAt": "2026-07-31"
+ }
+ },
+ {
+ "severity": "block",
+ "id": "analytics-export-overhang:lic-export-44",
+ "message": "Analytics export grant lic-export-44 outlives the cancellation cutoff.",
+ "action": "Revoke or shorten export access before confirming cancellation.",
+ "evidence": {
+ "id": "lic-export-44",
+ "dataset": "citation-network",
+ "accessUntil": "2026-06-30"
+ }
+ },
+ {
+ "severity": "hold",
+ "id": "po-closeout-missing",
+ "message": "Institutional purchase-order exception is present without a closeout record.",
+ "action": "Attach PO closeout evidence before marking the account cancelled.",
+ "evidence": {
+ "institutionalPoException": true
+ }
+ }
+ ]
+}
diff --git a/subscription-cancellation-effective-date-guard/reports/cancellation-report.md b/subscription-cancellation-effective-date-guard/reports/cancellation-report.md
new file mode 100644
index 00000000..87cda0cb
--- /dev/null
+++ b/subscription-cancellation-effective-date-guard/reports/cancellation-report.md
@@ -0,0 +1,15 @@
+# Cancellation Effective-Date Guard Report
+
+Account: inst-lab-204
+Cancellation: can-2026-05-alpha
+Decision: block-cancellation-close
+
+| Severity | Finding | Action |
+| --- | --- | --- |
+| hold | Cancellation notice provides 17 days, below the required 30 days. | Route to finance/legal for late-notice approval before suppressing renewal revenue. |
+| block | Cancellation effective date differs from the contract end date without early-termination approval. | Attach early-termination approval or align the effective date with the contract end date. |
+| block | Renewal invoice inv-renew-991 is still scheduled after cancellation takes effect. | Cancel or hold the renewal invoice before finalizing the cancellation. |
+| hold | A refund or service credit is due but no finance review is attached. | Create a finance review before issuing credit notes or refund payments. |
+| hold | Compute credit lot cc-ai-771 remains usable after the wind-down date. | Expire, refund, or convert the remaining compute credits before cancellation close. |
+| block | Analytics export grant lic-export-44 outlives the cancellation cutoff. | Revoke or shorten export access before confirming cancellation. |
+| hold | Institutional purchase-order exception is present without a closeout record. | Attach PO closeout evidence before marking the account cancelled. |
diff --git a/subscription-cancellation-effective-date-guard/reports/demo.webm b/subscription-cancellation-effective-date-guard/reports/demo.webm
new file mode 100644
index 00000000..c65d778f
Binary files /dev/null and b/subscription-cancellation-effective-date-guard/reports/demo.webm differ
diff --git a/subscription-cancellation-effective-date-guard/reports/summary.svg b/subscription-cancellation-effective-date-guard/reports/summary.svg
new file mode 100644
index 00000000..b3702c0d
--- /dev/null
+++ b/subscription-cancellation-effective-date-guard/reports/summary.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/subscription-cancellation-effective-date-guard/samplePacket.js b/subscription-cancellation-effective-date-guard/samplePacket.js
new file mode 100644
index 00000000..f9e00163
--- /dev/null
+++ b/subscription-cancellation-effective-date-guard/samplePacket.js
@@ -0,0 +1,26 @@
+module.exports = {
+ accountId: "inst-lab-204",
+ cancellationId: "can-2026-05-alpha",
+ requestedAt: "2026-05-15",
+ contractEndDate: "2026-06-01",
+ effectiveDate: "2026-05-31",
+ requiredNoticeDays: 30,
+ earlyTerminationApprovalId: "",
+ refundDueCents: 125000,
+ financeReviewId: "",
+ computeWindDownDate: "2026-06-07",
+ analyticsCutoffDate: "2026-06-01",
+ institutionalPoException: true,
+ poCloseoutId: "",
+ renewalInvoices: [
+ { id: "inv-renew-991", issueDate: "2026-06-01", status: "scheduled", amountCents: 4800000 },
+ { id: "inv-final-990", issueDate: "2026-05-20", status: "issued", amountCents: 1200000 }
+ ],
+ computeCreditLots: [
+ { id: "cc-ai-771", remainingUnits: 430, expiresAt: "2026-07-31" },
+ { id: "cc-ai-772", remainingUnits: 0, expiresAt: "2026-06-01" }
+ ],
+ analyticsExportGrants: [
+ { id: "lic-export-44", dataset: "citation-network", accessUntil: "2026-06-30" }
+ ]
+};
diff --git a/subscription-cancellation-effective-date-guard/test.js b/subscription-cancellation-effective-date-guard/test.js
new file mode 100644
index 00000000..d59bee4a
--- /dev/null
+++ b/subscription-cancellation-effective-date-guard/test.js
@@ -0,0 +1,35 @@
+const assert = require("assert");
+const { evaluateCancellationPacket } = require("./cancellationGuard");
+const samplePacket = require("./samplePacket");
+
+const result = evaluateCancellationPacket(samplePacket);
+
+assert.equal(result.decision, "block-cancellation-close");
+assert.ok(result.findings.some((finding) => finding.id === "late-cancellation-notice"));
+assert.ok(result.findings.some((finding) => finding.id === "unapproved-effective-date"));
+assert.ok(result.findings.some((finding) => finding.id === "scheduled-renewal-invoice:inv-renew-991"));
+assert.ok(result.findings.some((finding) => finding.id === "refund-without-review"));
+assert.ok(result.findings.some((finding) => finding.id === "compute-credit-overhang:cc-ai-771"));
+assert.ok(result.findings.some((finding) => finding.id === "analytics-export-overhang:lic-export-44"));
+assert.ok(result.findings.some((finding) => finding.id === "po-closeout-missing"));
+
+const cleanResult = evaluateCancellationPacket({
+ accountId: "lab-clean",
+ cancellationId: "can-clean",
+ requestedAt: "2026-04-01",
+ contractEndDate: "2026-06-01",
+ effectiveDate: "2026-06-01",
+ requiredNoticeDays: 30,
+ refundDueCents: 0,
+ computeWindDownDate: "2026-06-07",
+ analyticsCutoffDate: "2026-06-01",
+ institutionalPoException: false,
+ renewalInvoices: [{ id: "inv-old", issueDate: "2026-05-01", status: "issued", amountCents: 50000 }],
+ computeCreditLots: [{ id: "cc-zero", remainingUnits: 0, expiresAt: "2026-07-01" }],
+ analyticsExportGrants: [{ id: "lic-ok", accessUntil: "2026-06-01" }]
+});
+
+assert.equal(cleanResult.decision, "ready-to-close");
+assert.equal(cleanResult.findings.length, 0);
+
+console.log("cancellation effective-date guard tests passed");