diff --git a/grant-compute-budget-guard/package.json b/grant-compute-budget-guard/package.json
new file mode 100644
index 00000000..f9bde8f4
--- /dev/null
+++ b/grant-compute-budget-guard/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "scibase-grant-compute-budget-guard",
+ "version": "1.0.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "test": "node --test test/*.test.js",
+ "demo": "node scripts/demo.js"
+ }
+}
diff --git a/grant-compute-budget-guard/readme.md b/grant-compute-budget-guard/readme.md
new file mode 100644
index 00000000..62020d1b
--- /dev/null
+++ b/grant-compute-budget-guard/readme.md
@@ -0,0 +1,22 @@
+# Grant Compute Budget Guard
+
+This module contributes to SCIBASE issue #20, Revenue Infrastructure.
+
+It evaluates AI compute usage before the platform converts usage into billable spend. The guard checks grant award validity, billing holds, usage budgets, institutional invoice requirements, and high-margin overage rules so research teams do not accidentally create unrecoverable compute charges.
+
+## Local Verification
+
+```bash
+npm test
+npm run demo
+```
+
+## Demo Evidence
+
+The demo transcript is captured in `reports/demo-transcript.md`. The demo generates these reviewer artifacts:
+
+- `reports/compute-budget-report.md`
+- `reports/compute-budget-packet.json`
+- `reports/summary.svg`
+
+The demo data is synthetic and does not call payment processors, cloud providers, or grant systems.
diff --git a/grant-compute-budget-guard/reports/compute-budget-packet.json b/grant-compute-budget-guard/reports/compute-budget-packet.json
new file mode 100644
index 00000000..1878c279
--- /dev/null
+++ b/grant-compute-budget-guard/reports/compute-budget-packet.json
@@ -0,0 +1,100 @@
+{
+ "title": "SCIBASE Grant Compute Budget Guard",
+ "issue": "SCIBASE.AI#20",
+ "claim": "/claim #20",
+ "evaluation": {
+ "status": "hold_billing",
+ "generatedAt": "2026-06-13T18:30:00.000Z",
+ "packetId": "scibase-grant-compute-budget-demo",
+ "digest": "a0876258828149a3de20dd81473f46891355c1b423bb7d3f71b81377dacf2d7a",
+ "counts": {
+ "accounts": 2,
+ "grants": 2,
+ "blockers": 6,
+ "warnings": 0,
+ "heldAccounts": 1
+ },
+ "blockers": [
+ {
+ "code": "missing_award_id",
+ "accountId": "acct-lab-hold",
+ "message": "Grant-backed account is missing an award id."
+ },
+ {
+ "code": "expired_grant",
+ "accountId": "acct-lab-hold",
+ "message": "Grant has expired before pending usage billing."
+ },
+ {
+ "code": "grant_billing_hold",
+ "accountId": "acct-lab-hold",
+ "message": "Grant is under a billing hold."
+ },
+ {
+ "code": "missing_purchase_order",
+ "accountId": "acct-lab-hold",
+ "message": "Institutional invoice account is missing a purchase order."
+ },
+ {
+ "code": "budget_overage",
+ "accountId": "acct-lab-hold",
+ "message": "Projected compute spend exceeds the configured budget limit.",
+ "projectedSpend": 5750,
+ "budgetLimit": 5000,
+ "overagePercent": 15
+ },
+ {
+ "code": "low_compute_margin",
+ "accountId": "acct-lab-hold",
+ "message": "Pending usage falls below the minimum gross margin threshold.",
+ "grossMarginPercent": 20,
+ "minGrossMarginPercent": 30
+ }
+ ],
+ "warnings": [],
+ "decisions": [
+ {
+ "accountId": "acct-lab-stable",
+ "institution": "North Campus Lab",
+ "decision": "bill",
+ "reasons": [],
+ "requiredActions": []
+ },
+ {
+ "accountId": "acct-lab-hold",
+ "institution": "Materials Institute",
+ "decision": "hold",
+ "reasons": [
+ "missing_award_id",
+ "expired_grant",
+ "grant_billing_hold",
+ "missing_purchase_order",
+ "budget_overage",
+ "low_compute_margin"
+ ],
+ "requiredActions": [
+ "Add the grant award id before recognizing sponsored compute revenue.",
+ "Move usage to a renewed grant or institutional invoice before billing.",
+ "Resolve the sponsor billing hold before creating an invoice.",
+ "Attach a purchase order for institutional invoicing.",
+ "Require budget-owner approval before converting usage to billable spend.",
+ "Reprice the compute job or route it to a cheaper execution tier."
+ ]
+ }
+ ],
+ "policy": {
+ "maxOveragePercent": 5,
+ "minGrossMarginPercent": 30,
+ "requirePurchaseOrderForInvoice": true,
+ "requireGrantAwardId": true,
+ "blockExpiredGrants": true
+ }
+ },
+ "reviewerChecklist": [
+ "Grant-backed usage has an award id and active grant window.",
+ "Institutional invoice accounts include purchase orders.",
+ "Pending AI compute charges stay within approved budget limits.",
+ "Billing holds stop invoices before revenue recognition.",
+ "Compute usage preserves the configured gross margin floor."
+ ]
+}
diff --git a/grant-compute-budget-guard/reports/compute-budget-report.md b/grant-compute-budget-guard/reports/compute-budget-report.md
new file mode 100644
index 00000000..4860ee3c
--- /dev/null
+++ b/grant-compute-budget-guard/reports/compute-budget-report.md
@@ -0,0 +1,25 @@
+# Grant Compute Budget Guard Report
+
+Issue: SCIBASE.AI#20
+Claim marker: `/claim #20`
+Status: `hold_billing`
+Digest: `a0876258828149a3de20dd81473f46891355c1b423bb7d3f71b81377dacf2d7a`
+
+## Reviewer Checklist
+- Grant-backed usage has an award id and active grant window.
+- Institutional invoice accounts include purchase orders.
+- Pending AI compute charges stay within approved budget limits.
+- Billing holds stop invoices before revenue recognition.
+- Compute usage preserves the configured gross margin floor.
+
+## Blockers
+- missing_award_id: Grant-backed account is missing an award id.
+- expired_grant: Grant has expired before pending usage billing.
+- grant_billing_hold: Grant is under a billing hold.
+- missing_purchase_order: Institutional invoice account is missing a purchase order.
+- budget_overage: Projected compute spend exceeds the configured budget limit.
+- low_compute_margin: Pending usage falls below the minimum gross margin threshold.
+
+## Account Decisions
+- acct-lab-stable: bill
+- acct-lab-hold: hold (missing_award_id, expired_grant, grant_billing_hold, missing_purchase_order, budget_overage, low_compute_margin)
diff --git a/grant-compute-budget-guard/reports/demo-transcript.md b/grant-compute-budget-guard/reports/demo-transcript.md
new file mode 100644
index 00000000..dcb78976
--- /dev/null
+++ b/grant-compute-budget-guard/reports/demo-transcript.md
@@ -0,0 +1,35 @@
+# Grant Compute Budget Guard Demo Transcript
+
+Date verified: 2026-06-15
+
+Commands run from `grant-compute-budget-guard`:
+
+```bash
+npm test
+npm run demo
+```
+
+Test result:
+
+```text
+5 tests passed
+0 tests failed
+```
+
+Demo output:
+
+```json
+{
+ "status": "hold_billing",
+ "digest": "a0876258828149a3de20dd81473f46891355c1b423bb7d3f71b81377dacf2d7a",
+ "blockers": 6,
+ "heldAccounts": 1,
+ "reportsDir": "grant-compute-budget-guard/reports"
+}
+```
+
+Generated reviewer artifacts:
+
+- `reports/compute-budget-report.md`
+- `reports/compute-budget-packet.json`
+- `reports/summary.svg`
diff --git a/grant-compute-budget-guard/reports/summary.svg b/grant-compute-budget-guard/reports/summary.svg
new file mode 100644
index 00000000..c9dc3175
--- /dev/null
+++ b/grant-compute-budget-guard/reports/summary.svg
@@ -0,0 +1 @@
+
diff --git a/grant-compute-budget-guard/scripts/demo.js b/grant-compute-budget-guard/scripts/demo.js
new file mode 100644
index 00000000..3773eea2
--- /dev/null
+++ b/grant-compute-budget-guard/scripts/demo.js
@@ -0,0 +1,29 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ buildReviewerPacket,
+ demoPacket,
+ renderMarkdownReport,
+ renderSvgSummary
+} from "../src/index.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const moduleRoot = path.resolve(__dirname, "..");
+const reportsDir = path.join(moduleRoot, "reports");
+const packet = demoPacket();
+const reviewerPacket = buildReviewerPacket(packet, { now: packet.generatedAt });
+
+fs.mkdirSync(reportsDir, { recursive: true });
+fs.writeFileSync(path.join(reportsDir, "compute-budget-packet.json"), `${JSON.stringify(reviewerPacket, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, "compute-budget-report.md"), renderMarkdownReport(packet, { now: packet.generatedAt }));
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(packet, { now: packet.generatedAt }));
+
+console.log(JSON.stringify({
+ status: reviewerPacket.evaluation.status,
+ digest: reviewerPacket.evaluation.digest,
+ blockers: reviewerPacket.evaluation.counts.blockers,
+ heldAccounts: reviewerPacket.evaluation.counts.heldAccounts,
+ reportsDir
+}, null, 2));
diff --git a/grant-compute-budget-guard/src/index.js b/grant-compute-budget-guard/src/index.js
new file mode 100644
index 00000000..f615f5b0
--- /dev/null
+++ b/grant-compute-budget-guard/src/index.js
@@ -0,0 +1,231 @@
+import crypto from "node:crypto";
+
+const DEFAULT_POLICY = {
+ maxOveragePercent: 5,
+ minGrossMarginPercent: 30,
+ requirePurchaseOrderForInvoice: true,
+ requireGrantAwardId: true,
+ blockExpiredGrants: true
+};
+
+export function evaluateComputeBudgets(packet, options = {}) {
+ const normalized = normalizePacket(packet);
+ const policy = { ...DEFAULT_POLICY, ...normalized.policy, ...(options.policy ?? {}) };
+ const now = new Date(options.now ?? normalized.generatedAt);
+ const grants = new Map(normalized.grants.map((grant) => [grant.id, grant]));
+ const blockers = [];
+ const warnings = [];
+ const decisions = [];
+
+ for (const account of normalized.accounts) {
+ const grant = grants.get(account.grantId);
+ const decision = {
+ accountId: account.id,
+ institution: account.institution,
+ decision: "bill",
+ reasons: [],
+ requiredActions: []
+ };
+ const projectedSpend = account.currentSpend + account.pendingUsageCost;
+ const overagePercent = account.budgetLimit ? ((projectedSpend - account.budgetLimit) / account.budgetLimit) * 100 : 0;
+ const grossMarginPercent = account.pendingUsageCost ? ((account.pendingUsageCost - account.cloudCost) / account.pendingUsageCost) * 100 : 100;
+
+ if (!grant) {
+ hold(decision, "missing_grant_record", "Attach a grant or institutional billing record before billing compute usage.");
+ blockers.push(finding("missing_grant_record", account.id, "Compute account references a missing grant record."));
+ } else {
+ if (policy.requireGrantAwardId && !grant.awardId) {
+ hold(decision, "missing_award_id", "Add the grant award id before recognizing sponsored compute revenue.");
+ blockers.push(finding("missing_award_id", account.id, "Grant-backed account is missing an award id."));
+ }
+
+ if (policy.blockExpiredGrants && new Date(grant.endsAt) < now) {
+ hold(decision, "expired_grant", "Move usage to a renewed grant or institutional invoice before billing.");
+ blockers.push(finding("expired_grant", account.id, "Grant has expired before pending usage billing."));
+ }
+
+ if (grant.billingHold) {
+ hold(decision, "grant_billing_hold", "Resolve the sponsor billing hold before creating an invoice.");
+ blockers.push(finding("grant_billing_hold", account.id, "Grant is under a billing hold."));
+ }
+ }
+
+ if (policy.requirePurchaseOrderForInvoice && account.billingMode === "invoice" && !account.purchaseOrder) {
+ hold(decision, "missing_purchase_order", "Attach a purchase order for institutional invoicing.");
+ blockers.push(finding("missing_purchase_order", account.id, "Institutional invoice account is missing a purchase order."));
+ }
+
+ if (projectedSpend > account.budgetLimit) {
+ if (overagePercent > policy.maxOveragePercent) {
+ hold(decision, "budget_overage", "Require budget-owner approval before converting usage to billable spend.");
+ blockers.push({
+ ...finding("budget_overage", account.id, "Projected compute spend exceeds the configured budget limit."),
+ projectedSpend,
+ budgetLimit: account.budgetLimit,
+ overagePercent: round(overagePercent)
+ });
+ } else {
+ warnings.push({
+ ...finding("minor_budget_overage", account.id, "Projected spend is slightly above the budget limit."),
+ projectedSpend,
+ budgetLimit: account.budgetLimit,
+ overagePercent: round(overagePercent)
+ });
+ }
+ }
+
+ if (grossMarginPercent < policy.minGrossMarginPercent) {
+ hold(decision, "low_compute_margin", "Reprice the compute job or route it to a cheaper execution tier.");
+ blockers.push({
+ ...finding("low_compute_margin", account.id, "Pending usage falls below the minimum gross margin threshold."),
+ grossMarginPercent: round(grossMarginPercent),
+ minGrossMarginPercent: policy.minGrossMarginPercent
+ });
+ }
+
+ decisions.push(decision);
+ }
+
+ const status = blockers.length ? "hold_billing" : warnings.length ? "needs_finance_review" : "ready_to_bill";
+ return {
+ status,
+ generatedAt: now.toISOString(),
+ packetId: normalized.packetId,
+ digest: digest({ packetId: normalized.packetId, blockers, warnings, decisions }),
+ counts: {
+ accounts: normalized.accounts.length,
+ grants: normalized.grants.length,
+ blockers: blockers.length,
+ warnings: warnings.length,
+ heldAccounts: decisions.filter((item) => item.decision === "hold").length
+ },
+ blockers,
+ warnings,
+ decisions,
+ policy
+ };
+}
+
+export function buildReviewerPacket(packet, options = {}) {
+ return {
+ title: "SCIBASE Grant Compute Budget Guard",
+ issue: "SCIBASE.AI#20",
+ claim: "/claim #20",
+ evaluation: evaluateComputeBudgets(packet, options),
+ reviewerChecklist: [
+ "Grant-backed usage has an award id and active grant window.",
+ "Institutional invoice accounts include purchase orders.",
+ "Pending AI compute charges stay within approved budget limits.",
+ "Billing holds stop invoices before revenue recognition.",
+ "Compute usage preserves the configured gross margin floor."
+ ]
+ };
+}
+
+export function renderMarkdownReport(packet, options = {}) {
+ const review = buildReviewerPacket(packet, options);
+ const { evaluation } = review;
+ return [
+ "# Grant Compute Budget Guard Report",
+ "",
+ `Issue: ${review.issue}`,
+ `Claim marker: \`${review.claim}\``,
+ `Status: \`${evaluation.status}\``,
+ `Digest: \`${evaluation.digest}\``,
+ "",
+ "## Reviewer Checklist",
+ ...review.reviewerChecklist.map((item) => `- ${item}`),
+ "",
+ "## Blockers",
+ ...(evaluation.blockers.length ? evaluation.blockers.map((item) => `- ${item.code}: ${item.message}`) : ["- None."]),
+ "",
+ "## Account Decisions",
+ ...evaluation.decisions.map((item) => `- ${item.accountId}: ${item.decision}${item.reasons.length ? ` (${item.reasons.join(", ")})` : ""}`)
+ ].join("\n") + "\n";
+}
+
+export function renderSvgSummary(packet, options = {}) {
+ const review = buildReviewerPacket(packet, options);
+ const { evaluation } = review;
+ return `\n`;
+}
+
+export function demoPacket() {
+ return {
+ packetId: "scibase-grant-compute-budget-demo",
+ generatedAt: "2026-06-13T18:30:00.000Z",
+ grants: [
+ { id: "grant-active-nsf", awardId: "NSF-2042-OPEN", sponsor: "NSF", endsAt: "2026-12-31T00:00:00.000Z", billingHold: false },
+ { id: "grant-expired-doe", awardId: "", sponsor: "DOE", endsAt: "2026-04-30T00:00:00.000Z", billingHold: true }
+ ],
+ accounts: [
+ { id: "acct-lab-stable", institution: "North Campus Lab", grantId: "grant-active-nsf", billingMode: "card", purchaseOrder: "", budgetLimit: 10000, currentSpend: 7200, pendingUsageCost: 1100, cloudCost: 520 },
+ { id: "acct-lab-hold", institution: "Materials Institute", grantId: "grant-expired-doe", billingMode: "invoice", purchaseOrder: "", budgetLimit: 5000, currentSpend: 4800, pendingUsageCost: 950, cloudCost: 760 }
+ ]
+ };
+}
+
+export function normalizePacket(packet) {
+ if (!packet || typeof packet !== "object") throw new TypeError("A compute budget packet object is required.");
+ return {
+ packetId: text(packet.packetId, "packetId"),
+ generatedAt: text(packet.generatedAt, "generatedAt"),
+ policy: packet.policy ?? {},
+ grants: asArray(packet.grants, "grants").map((grant) => ({
+ id: text(grant.id, "grant.id"),
+ awardId: String(grant.awardId ?? "").trim(),
+ sponsor: text(grant.sponsor, "grant.sponsor"),
+ endsAt: text(grant.endsAt, "grant.endsAt"),
+ billingHold: Boolean(grant.billingHold)
+ })),
+ accounts: asArray(packet.accounts, "accounts").map((account) => ({
+ id: text(account.id, "account.id"),
+ institution: text(account.institution, "account.institution"),
+ grantId: text(account.grantId, "account.grantId"),
+ billingMode: text(account.billingMode, "account.billingMode"),
+ purchaseOrder: String(account.purchaseOrder ?? "").trim(),
+ budgetLimit: number(account.budgetLimit, "account.budgetLimit"),
+ currentSpend: number(account.currentSpend, "account.currentSpend"),
+ pendingUsageCost: number(account.pendingUsageCost, "account.pendingUsageCost"),
+ cloudCost: number(account.cloudCost, "account.cloudCost")
+ }))
+ };
+}
+
+function hold(decision, reason, requiredAction) {
+ decision.decision = "hold";
+ decision.reasons.push(reason);
+ decision.requiredActions.push(requiredAction);
+}
+
+function finding(code, accountId, message) {
+ return { code, accountId, message };
+}
+
+function text(value, name) {
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string.`);
+ return value.trim();
+}
+
+function number(value, name) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) throw new TypeError(`${name} must be finite.`);
+ return parsed;
+}
+
+function asArray(value, name) {
+ if (!Array.isArray(value)) throw new TypeError(`${name} must be an array.`);
+ return value;
+}
+
+function digest(value) {
+ return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
+}
+
+function round(value) {
+ return Math.round(value * 100) / 100;
+}
+
+function escapeXml(value) {
+ return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
+}
diff --git a/grant-compute-budget-guard/test/index.test.js b/grant-compute-budget-guard/test/index.test.js
new file mode 100644
index 00000000..894a3e7a
--- /dev/null
+++ b/grant-compute-budget-guard/test/index.test.js
@@ -0,0 +1,69 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ buildReviewerPacket,
+ demoPacket,
+ evaluateComputeBudgets,
+ normalizePacket,
+ renderMarkdownReport,
+ renderSvgSummary
+} from "../src/index.js";
+
+test("holds expired grant, billing hold, and missing purchase order", () => {
+ const result = evaluateComputeBudgets(demoPacket(), { now: "2026-06-13T18:30:00.000Z" });
+ assert.equal(result.status, "hold_billing");
+ assert.ok(result.blockers.some((blocker) => blocker.code === "missing_award_id"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "expired_grant"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "grant_billing_hold"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "missing_purchase_order"));
+});
+
+test("holds large overages and low margin usage", () => {
+ const result = evaluateComputeBudgets(demoPacket(), { now: "2026-06-13T18:30:00.000Z" });
+ assert.ok(result.blockers.some((blocker) => blocker.code === "budget_overage"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "low_compute_margin"));
+});
+
+test("releases clean compute billing packets", () => {
+ const packet = demoPacket();
+ packet.accounts = [packet.accounts[0]];
+ const result = evaluateComputeBudgets(packet, { now: "2026-06-13T18:30:00.000Z" });
+ assert.equal(result.status, "ready_to_bill");
+ assert.equal(result.counts.blockers, 0);
+ assert.equal(result.counts.heldAccounts, 0);
+});
+
+test("warns on small budget overages", () => {
+ const packet = demoPacket();
+ packet.accounts = [{
+ id: "acct-small-overage",
+ institution: "Small Lab",
+ grantId: "grant-active-nsf",
+ billingMode: "card",
+ purchaseOrder: "",
+ budgetLimit: 1000,
+ currentSpend: 980,
+ pendingUsageCost: 40,
+ cloudCost: 10
+ }];
+ const result = evaluateComputeBudgets(packet, { now: "2026-06-13T18:30:00.000Z" });
+ assert.equal(result.status, "needs_finance_review");
+ assert.ok(result.warnings.some((warning) => warning.code === "minor_budget_overage"));
+});
+
+test("rejects malformed packets and renders claim artifacts", () => {
+ assert.equal(normalizePacket(demoPacket()).accounts.length, 2);
+ const broken = demoPacket();
+ broken.accounts[0].budgetLimit = "not-a-number";
+ assert.throws(() => normalizePacket(broken), /budgetLimit must be finite/);
+
+ const packet = demoPacket();
+ const review = buildReviewerPacket(packet, { now: packet.generatedAt });
+ const markdown = renderMarkdownReport(packet, { now: packet.generatedAt });
+ const svg = renderSvgSummary(packet, { now: packet.generatedAt });
+ assert.equal(review.claim, "/claim #20");
+ assert.match(markdown, /Grant Compute Budget Guard Report/);
+ assert.match(markdown, /`\/claim #20`/);
+ assert.match(svg, /