From 373e0678b638809ee650c35d2625ef33a266df5c Mon Sep 17 00:00:00 2001
From: taherd <183945978+taherdhanera@users.noreply.github.com>
Date: Fri, 22 May 2026 17:41:55 +0530
Subject: [PATCH] Add analytics license seat roster guard
---
analytics-license-seat-roster-guard/README.md | 28 ++
.../demo-video.js | 173 +++++++++++
analytics-license-seat-roster-guard/demo.js | 18 ++
analytics-license-seat-roster-guard/index.js | 294 ++++++++++++++++++
.../package.json | 14 +
.../reports/demo.webm | Bin 0 -> 11202 bytes
.../reports/reviewer-packet.md | 42 +++
.../reports/summary.json | 153 +++++++++
.../reports/summary.svg | 16 +
.../requirements-map.md | 18 ++
.../sample-data.js | 120 +++++++
analytics-license-seat-roster-guard/test.js | 55 ++++
12 files changed, 931 insertions(+)
create mode 100644 analytics-license-seat-roster-guard/README.md
create mode 100644 analytics-license-seat-roster-guard/demo-video.js
create mode 100644 analytics-license-seat-roster-guard/demo.js
create mode 100644 analytics-license-seat-roster-guard/index.js
create mode 100644 analytics-license-seat-roster-guard/package.json
create mode 100644 analytics-license-seat-roster-guard/reports/demo.webm
create mode 100644 analytics-license-seat-roster-guard/reports/reviewer-packet.md
create mode 100644 analytics-license-seat-roster-guard/reports/summary.json
create mode 100644 analytics-license-seat-roster-guard/reports/summary.svg
create mode 100644 analytics-license-seat-roster-guard/requirements-map.md
create mode 100644 analytics-license-seat-roster-guard/sample-data.js
create mode 100644 analytics-license-seat-roster-guard/test.js
diff --git a/analytics-license-seat-roster-guard/README.md b/analytics-license-seat-roster-guard/README.md
new file mode 100644
index 00000000..a0fd9907
--- /dev/null
+++ b/analytics-license-seat-roster-guard/README.md
@@ -0,0 +1,28 @@
+# Analytics License Seat Roster Guard
+
+Self-contained Revenue Infrastructure slice for `SCIBASE-AI/SCIBASE.AI#20`.
+
+The guard reconciles named analytics dashboard and API seats before renewal or
+true-up billing. It checks contracted seat classes, allowed domains, temporary
+access windows, inactive paid seats, API usage by non-API seats, duplicate
+identities, and finance approvals so revenue leakage can be fixed before
+renewal invoices are sent.
+
+## Run
+
+```bash
+npm run check
+npm test
+npm run demo
+npm run demo:video
+```
+
+## Outputs
+
+- `reports/summary.json`
+- `reports/reviewer-packet.md`
+- `reports/summary.svg`
+- `reports/demo.webm`
+
+All data is synthetic. The module does not call payment processors, SSO, SCIM,
+ERP, analytics APIs, billing systems, or external services.
diff --git a/analytics-license-seat-roster-guard/demo-video.js b/analytics-license-seat-roster-guard/demo-video.js
new file mode 100644
index 00000000..613a111d
--- /dev/null
+++ b/analytics-license-seat-roster-guard/demo-video.js
@@ -0,0 +1,173 @@
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const { execFileSync } = require("child_process");
+
+const reportDir = path.join(__dirname, "reports");
+const outputPath = path.join(reportDir, "demo.webm");
+
+const chromeCandidates = [
+ process.env.CHROME_PATH,
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
+].filter(Boolean);
+
+function findBrowser() {
+ const found = chromeCandidates.find((candidate) => fs.existsSync(candidate));
+ if (!found) {
+ throw new Error("Chrome or Edge was not found. Set CHROME_PATH to generate reports/demo.webm.");
+ }
+ return found;
+}
+
+function fileUrl(filePath) {
+ return `file:///${filePath.replace(/\\/g, "/")}`;
+}
+
+const html = String.raw`
+
+
+
+ Analytics license seat roster guard demo
+
+
+
+
+ recording
+
+
+`;
+
+fs.mkdirSync(reportDir, { recursive: true });
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "analytics-license-demo-"));
+const htmlPath = path.join(tempDir, "demo.html");
+const profileDir = path.join(tempDir, "profile");
+fs.writeFileSync(htmlPath, html, "utf8");
+
+const stdout = execFileSync(
+ findBrowser(),
+ [
+ "--headless=new",
+ "--disable-gpu",
+ "--disable-dev-shm-usage",
+ "--autoplay-policy=no-user-gesture-required",
+ "--run-all-compositor-stages-before-draw",
+ "--virtual-time-budget=7000",
+ `--user-data-dir=${profileDir}`,
+ "--dump-dom",
+ fileUrl(htmlPath)
+ ],
+ { encoding: "utf8", maxBuffer: 30 * 1024 * 1024 }
+);
+
+const match = stdout.match(/data:video\/webm;base64,([A-Za-z0-9+/=]+)/);
+if (!match) {
+ throw new Error(`Demo video generation failed. Browser output ended with: ${stdout.slice(-600)}`);
+}
+
+fs.writeFileSync(outputPath, Buffer.from(match[1], "base64"));
+console.log(`Generated ${path.relative(process.cwd(), outputPath)}`);
diff --git a/analytics-license-seat-roster-guard/demo.js b/analytics-license-seat-roster-guard/demo.js
new file mode 100644
index 00000000..8c919701
--- /dev/null
+++ b/analytics-license-seat-roster-guard/demo.js
@@ -0,0 +1,18 @@
+const fs = require("fs");
+const path = require("path");
+const { project } = require("./sample-data");
+const { buildReviewPacket, renderMarkdownReport, renderSvgSummary } = require("./index");
+
+const reportDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportDir, { recursive: true });
+
+const packet = buildReviewPacket(project);
+
+fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(packet, null, 2)}\n`, "utf8");
+fs.writeFileSync(path.join(reportDir, "reviewer-packet.md"), renderMarkdownReport(packet), "utf8");
+fs.writeFileSync(path.join(reportDir, "summary.svg"), renderSvgSummary(packet), "utf8");
+
+console.log(`Generated reports for ${packet.guard}`);
+console.log(`Decision: ${packet.decision}`);
+console.log(`Estimated exposure: $${packet.estimatedRevenueExposure}`);
+console.log(`Findings: ${packet.findings.length}`);
diff --git a/analytics-license-seat-roster-guard/index.js b/analytics-license-seat-roster-guard/index.js
new file mode 100644
index 00000000..a5938704
--- /dev/null
+++ b/analytics-license-seat-roster-guard/index.js
@@ -0,0 +1,294 @@
+const SEVERITY_WEIGHTS = {
+ critical: 40,
+ high: 24,
+ medium: 12,
+ low: 5
+};
+
+function daysBetween(a, b) {
+ const left = new Date(a).getTime();
+ const right = new Date(b).getTime();
+ return Math.floor((right - left) / (24 * 60 * 60 * 1000));
+}
+
+function normalizeEmail(email) {
+ return String(email || "").trim().toLowerCase();
+}
+
+function domainFromEmail(email) {
+ const value = normalizeEmail(email);
+ return value.includes("@") ? value.split("@").pop() : "";
+}
+
+function addFinding(findings, severity, rule, message, action, exposure = 0, userIds = []) {
+ findings.push({
+ severity,
+ rule,
+ message,
+ action,
+ estimatedExposure: Math.round(exposure * 100) / 100,
+ userIds
+ });
+}
+
+function isDomainApproved(domain, approvals) {
+ return approvals.domainApprovals.some((approval) => approval.domain === domain && approval.status === "approved");
+}
+
+function hasOverageApproval(seatClass, approvals) {
+ return approvals.overageApprovals.some(
+ (approval) => approval.seatClass === seatClass && approval.status === "approved"
+ );
+}
+
+function isTemporaryAccessApproved(user, approvals, asOfDate) {
+ const approval = approvals.temporaryAccessApprovals.find((item) => item.userId === user.id);
+ if (!approval || approval.status !== "approved") {
+ return false;
+ }
+ return daysBetween(asOfDate, approval.expiresAt) >= 0;
+}
+
+function groupActiveSeats(users) {
+ return users
+ .filter((user) => user.status === "active")
+ .reduce((totals, user) => {
+ totals[user.seatClass] = (totals[user.seatClass] || 0) + 1;
+ return totals;
+ }, {});
+}
+
+function findDuplicateIdentities(users) {
+ const byEmail = new Map();
+ for (const user of users.filter((item) => item.status === "active")) {
+ const key = normalizeEmail(user.email);
+ if (!byEmail.has(key)) {
+ byEmail.set(key, []);
+ }
+ byEmail.get(key).push(user);
+ }
+ return [...byEmail.values()].filter((items) => items.length > 1);
+}
+
+function evaluateRoster(project) {
+ const findings = [];
+ const { contract, roster, usage, approvals } = project;
+ const activeBySeatClass = groupActiveSeats(roster.users);
+ const allowedDomains = new Set(contract.allowedDomains);
+
+ for (const [seatClass, entitlement] of Object.entries(contract.seatEntitlements)) {
+ const activeCount = activeBySeatClass[seatClass] || 0;
+ const overage = activeCount - entitlement;
+ if (overage > 0 && !hasOverageApproval(seatClass, approvals)) {
+ addFinding(
+ findings,
+ "high",
+ "unapproved-seat-overage",
+ `${activeCount} active ${seatClass} seats exceed the contracted ${entitlement} seat entitlement by ${overage}.`,
+ "Hold renewal true-up until finance approves overage billing or seats are reclaimed.",
+ overage * (contract.seatRates[seatClass] || 0),
+ roster.users.filter((user) => user.status === "active" && user.seatClass === seatClass).map((user) => user.id)
+ );
+ }
+ }
+
+ for (const user of roster.users) {
+ if (user.status !== "active") {
+ continue;
+ }
+
+ const domain = domainFromEmail(user.email);
+ if (!allowedDomains.has(domain) && !isDomainApproved(domain, approvals)) {
+ addFinding(
+ findings,
+ "critical",
+ "unapproved-seat-domain",
+ `${user.email} uses domain ${domain}, which is outside the signed analytics license domains.`,
+ "Remove the seat or attach a signed domain addendum before renewal billing.",
+ contract.seatRates[user.seatClass] || 0,
+ [user.id]
+ );
+ }
+
+ if (user.temporaryUntil && daysBetween(project.asOfDate, user.temporaryUntil) < 0 && !isTemporaryAccessApproved(user, approvals, project.asOfDate)) {
+ addFinding(
+ findings,
+ "medium",
+ "expired-temporary-access",
+ `${user.email} still has active access after temporary access expired on ${user.temporaryUntil}.`,
+ "Disable the temporary seat or convert it into a paid named seat before renewal.",
+ contract.seatRates[user.seatClass] || 0,
+ [user.id]
+ );
+ }
+
+ if (daysBetween(user.lastSeenAt, project.asOfDate) > contract.inactivityReclaimDays) {
+ addFinding(
+ findings,
+ "low",
+ "inactive-paid-seat",
+ `${user.email} has not used analytics access since ${user.lastSeenAt}.`,
+ "Queue the seat for renewal roster confirmation or reclaim before the true-up invoice.",
+ contract.seatRates[user.seatClass] || 0,
+ [user.id]
+ );
+ }
+
+ const usageRecord = usage.byUser[user.id] || { apiQueries: 0, dashboardSessions: 0 };
+ if (usageRecord.apiQueries > 0 && user.seatClass !== "api") {
+ addFinding(
+ findings,
+ "high",
+ "api-usage-without-api-seat",
+ `${user.email} generated ${usageRecord.apiQueries} analytics API queries while assigned to a ${user.seatClass} seat.`,
+ "Reclassify the user to an API seat or remove API keys before billing the renewal period.",
+ contract.seatRates.api - (contract.seatRates[user.seatClass] || 0),
+ [user.id]
+ );
+ }
+ }
+
+ for (const duplicateGroup of findDuplicateIdentities(roster.users)) {
+ addFinding(
+ findings,
+ "medium",
+ "duplicate-named-seat",
+ `${duplicateGroup[0].email} appears as ${duplicateGroup.length} active named seats.`,
+ "Collapse duplicate identity records before seat counts are sent to finance.",
+ (duplicateGroup.length - 1) * (contract.seatRates[duplicateGroup[0].seatClass] || 0),
+ duplicateGroup.map((user) => user.id)
+ );
+ }
+
+ const totalExposure = findings.reduce((sum, finding) => sum + finding.estimatedExposure, 0);
+ const severitySummary = findings.reduce(
+ (summary, finding) => {
+ summary[finding.severity] += 1;
+ return summary;
+ },
+ { critical: 0, high: 0, medium: 0, low: 0 }
+ );
+
+ return {
+ activeBySeatClass,
+ contractedSeatEntitlements: contract.seatEntitlements,
+ findings,
+ severitySummary,
+ estimatedRevenueExposure: Math.round(totalExposure * 100) / 100,
+ score: Math.max(0, 100 - findings.reduce((sum, finding) => sum + SEVERITY_WEIGHTS[finding.severity], 0))
+ };
+}
+
+function decisionFromScore(score, severitySummary) {
+ if (severitySummary.critical > 0) {
+ return "block-renewal-until-seat-evidence-is-clean";
+ }
+ if (score < 70) {
+ return "hold-renewal-true-up-for-finance-review";
+ }
+ if (score < 88) {
+ return "review-seat-exceptions-before-invoice";
+ }
+ return "renewal-roster-ready";
+}
+
+function buildFinanceActions(findings) {
+ return findings.map((finding) => ({
+ priority: finding.severity === "critical" || finding.severity === "high" ? "blocking" : "review",
+ rule: finding.rule,
+ action: finding.action,
+ estimatedExposure: finding.estimatedExposure,
+ userIds: finding.userIds
+ }));
+}
+
+function buildReviewPacket(project) {
+ const evaluation = evaluateRoster(project);
+ return {
+ guard: "analytics-license-seat-roster-guard",
+ issue: "SCIBASE-AI/SCIBASE.AI#20",
+ customer: project.contract.customer,
+ asOfDate: project.asOfDate,
+ renewalDate: project.contract.renewalDate,
+ decision: decisionFromScore(evaluation.score, evaluation.severitySummary),
+ score: evaluation.score,
+ activeBySeatClass: evaluation.activeBySeatClass,
+ contractedSeatEntitlements: evaluation.contractedSeatEntitlements,
+ estimatedRevenueExposure: evaluation.estimatedRevenueExposure,
+ findings: evaluation.findings,
+ financeActions: buildFinanceActions(evaluation.findings),
+ safety: [
+ "Synthetic roster and usage data only",
+ "No Stripe, PayPal, bank, ACH, ERP, SSO, SCIM, or analytics provider calls",
+ "No private customer data, payment credentials, tax IDs, or live invoice mutations"
+ ]
+ };
+}
+
+function renderMarkdownReport(packet) {
+ const lines = [
+ `# Analytics License Seat Roster Guard`,
+ ``,
+ `Customer: ${packet.customer}`,
+ `Issue: ${packet.issue}`,
+ `Decision: ${packet.decision}`,
+ `Score: ${packet.score}`,
+ `Estimated revenue exposure: $${packet.estimatedRevenueExposure}`,
+ ``,
+ `## Seat Counts`,
+ ``,
+ `| Seat class | Active | Contracted |`,
+ `| --- | ---: | ---: |`
+ ];
+
+ for (const seatClass of Object.keys(packet.contractedSeatEntitlements)) {
+ lines.push(
+ `| ${seatClass} | ${packet.activeBySeatClass[seatClass] || 0} | ${packet.contractedSeatEntitlements[seatClass]} |`
+ );
+ }
+
+ lines.push(``, `## Findings`, ``);
+ for (const finding of packet.findings) {
+ lines.push(`- **${finding.severity} / ${finding.rule}**: ${finding.message}`);
+ lines.push(` - Action: ${finding.action}`);
+ lines.push(` - Exposure: $${finding.estimatedExposure}`);
+ }
+
+ lines.push(``, `## Safety`, ``);
+ for (const item of packet.safety) {
+ lines.push(`- ${item}`);
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvgSummary(packet) {
+ const exposureWidth = Math.min(760, Math.max(40, packet.estimatedRevenueExposure / 12));
+ const criticalCount = packet.findings.filter((finding) => finding.severity === "critical").length;
+ const highCount = packet.findings.filter((finding) => finding.severity === "high").length;
+ return `
+`;
+}
+
+module.exports = {
+ buildReviewPacket,
+ decisionFromScore,
+ evaluateRoster,
+ renderMarkdownReport,
+ renderSvgSummary
+};
diff --git a/analytics-license-seat-roster-guard/package.json b/analytics-license-seat-roster-guard/package.json
new file mode 100644
index 00000000..6e019300
--- /dev/null
+++ b/analytics-license-seat-roster-guard/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "analytics-license-seat-roster-guard",
+ "version": "1.0.0",
+ "description": "Deterministic revenue guard for analytics license seat roster true-up and leakage review.",
+ "main": "index.js",
+ "private": true,
+ "type": "commonjs",
+ "scripts": {
+ "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check demo-video.js && node --check test.js",
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "node demo-video.js"
+ }
+}
diff --git a/analytics-license-seat-roster-guard/reports/demo.webm b/analytics-license-seat-roster-guard/reports/demo.webm
new file mode 100644
index 0000000000000000000000000000000000000000..8402e55d2dc1dbc85f0ac2c337c7409388e422a6
GIT binary patch
literal 11202
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
literal 0
HcmV?d00001
diff --git a/analytics-license-seat-roster-guard/reports/reviewer-packet.md b/analytics-license-seat-roster-guard/reports/reviewer-packet.md
new file mode 100644
index 00000000..70ffdd12
--- /dev/null
+++ b/analytics-license-seat-roster-guard/reports/reviewer-packet.md
@@ -0,0 +1,42 @@
+# Analytics License Seat Roster Guard
+
+Customer: Northbridge Research Consortium
+Issue: SCIBASE-AI/SCIBASE.AI#20
+Decision: block-renewal-until-seat-evidence-is-clean
+Score: 0
+Estimated revenue exposure: $8750
+
+## Seat Counts
+
+| Seat class | Active | Contracted |
+| --- | ---: | ---: |
+| dashboard | 5 | 4 |
+| api | 2 | 2 |
+| viewer | 3 | 5 |
+
+## Findings
+
+- **high / unapproved-seat-overage**: 5 active dashboard seats exceed the contracted 4 seat entitlement by 1.
+ - Action: Hold renewal true-up until finance approves overage billing or seats are reclaimed.
+ - Exposure: $1200
+- **low / inactive-paid-seat**: liam.gray@northbridge.edu has not used analytics access since 2025-12-18.
+ - Action: Queue the seat for renewal roster confirmation or reclaim before the true-up invoice.
+ - Exposure: $1200
+- **critical / unapproved-seat-domain**: visiting.pi@partner-lab.com uses domain partner-lab.com, which is outside the signed analytics license domains.
+ - Action: Remove the seat or attach a signed domain addendum before renewal billing.
+ - Exposure: $1200
+- **medium / expired-temporary-access**: visiting.pi@partner-lab.com still has active access after temporary access expired on 2026-04-30.
+ - Action: Disable the temporary seat or convert it into a paid named seat before renewal.
+ - Exposure: $1200
+- **high / api-usage-without-api-seat**: policy-api@northbridge.edu generated 1300 analytics API queries while assigned to a viewer seat.
+ - Action: Reclassify the user to an API seat or remove API keys before billing the renewal period.
+ - Exposure: $2750
+- **medium / duplicate-named-seat**: maya.chen@northbridge.edu appears as 2 active named seats.
+ - Action: Collapse duplicate identity records before seat counts are sent to finance.
+ - Exposure: $1200
+
+## Safety
+
+- Synthetic roster and usage data only
+- No Stripe, PayPal, bank, ACH, ERP, SSO, SCIM, or analytics provider calls
+- No private customer data, payment credentials, tax IDs, or live invoice mutations
diff --git a/analytics-license-seat-roster-guard/reports/summary.json b/analytics-license-seat-roster-guard/reports/summary.json
new file mode 100644
index 00000000..cf727d33
--- /dev/null
+++ b/analytics-license-seat-roster-guard/reports/summary.json
@@ -0,0 +1,153 @@
+{
+ "guard": "analytics-license-seat-roster-guard",
+ "issue": "SCIBASE-AI/SCIBASE.AI#20",
+ "customer": "Northbridge Research Consortium",
+ "asOfDate": "2026-05-22",
+ "renewalDate": "2026-06-01",
+ "decision": "block-renewal-until-seat-evidence-is-clean",
+ "score": 0,
+ "activeBySeatClass": {
+ "dashboard": 5,
+ "api": 2,
+ "viewer": 3
+ },
+ "contractedSeatEntitlements": {
+ "dashboard": 4,
+ "api": 2,
+ "viewer": 5
+ },
+ "estimatedRevenueExposure": 8750,
+ "findings": [
+ {
+ "severity": "high",
+ "rule": "unapproved-seat-overage",
+ "message": "5 active dashboard seats exceed the contracted 4 seat entitlement by 1.",
+ "action": "Hold renewal true-up until finance approves overage billing or seats are reclaimed.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-001",
+ "seat-002",
+ "seat-003",
+ "seat-004",
+ "seat-005"
+ ]
+ },
+ {
+ "severity": "low",
+ "rule": "inactive-paid-seat",
+ "message": "liam.gray@northbridge.edu has not used analytics access since 2025-12-18.",
+ "action": "Queue the seat for renewal roster confirmation or reclaim before the true-up invoice.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-003"
+ ]
+ },
+ {
+ "severity": "critical",
+ "rule": "unapproved-seat-domain",
+ "message": "visiting.pi@partner-lab.com uses domain partner-lab.com, which is outside the signed analytics license domains.",
+ "action": "Remove the seat or attach a signed domain addendum before renewal billing.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-005"
+ ]
+ },
+ {
+ "severity": "medium",
+ "rule": "expired-temporary-access",
+ "message": "visiting.pi@partner-lab.com still has active access after temporary access expired on 2026-04-30.",
+ "action": "Disable the temporary seat or convert it into a paid named seat before renewal.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-005"
+ ]
+ },
+ {
+ "severity": "high",
+ "rule": "api-usage-without-api-seat",
+ "message": "policy-api@northbridge.edu generated 1300 analytics API queries while assigned to a viewer seat.",
+ "action": "Reclassify the user to an API seat or remove API keys before billing the renewal period.",
+ "estimatedExposure": 2750,
+ "userIds": [
+ "seat-007"
+ ]
+ },
+ {
+ "severity": "medium",
+ "rule": "duplicate-named-seat",
+ "message": "maya.chen@northbridge.edu appears as 2 active named seats.",
+ "action": "Collapse duplicate identity records before seat counts are sent to finance.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-001",
+ "seat-010"
+ ]
+ }
+ ],
+ "financeActions": [
+ {
+ "priority": "blocking",
+ "rule": "unapproved-seat-overage",
+ "action": "Hold renewal true-up until finance approves overage billing or seats are reclaimed.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-001",
+ "seat-002",
+ "seat-003",
+ "seat-004",
+ "seat-005"
+ ]
+ },
+ {
+ "priority": "review",
+ "rule": "inactive-paid-seat",
+ "action": "Queue the seat for renewal roster confirmation or reclaim before the true-up invoice.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-003"
+ ]
+ },
+ {
+ "priority": "blocking",
+ "rule": "unapproved-seat-domain",
+ "action": "Remove the seat or attach a signed domain addendum before renewal billing.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-005"
+ ]
+ },
+ {
+ "priority": "review",
+ "rule": "expired-temporary-access",
+ "action": "Disable the temporary seat or convert it into a paid named seat before renewal.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-005"
+ ]
+ },
+ {
+ "priority": "blocking",
+ "rule": "api-usage-without-api-seat",
+ "action": "Reclassify the user to an API seat or remove API keys before billing the renewal period.",
+ "estimatedExposure": 2750,
+ "userIds": [
+ "seat-007"
+ ]
+ },
+ {
+ "priority": "review",
+ "rule": "duplicate-named-seat",
+ "action": "Collapse duplicate identity records before seat counts are sent to finance.",
+ "estimatedExposure": 1200,
+ "userIds": [
+ "seat-001",
+ "seat-010"
+ ]
+ }
+ ],
+ "safety": [
+ "Synthetic roster and usage data only",
+ "No Stripe, PayPal, bank, ACH, ERP, SSO, SCIM, or analytics provider calls",
+ "No private customer data, payment credentials, tax IDs, or live invoice mutations"
+ ]
+}
diff --git a/analytics-license-seat-roster-guard/reports/summary.svg b/analytics-license-seat-roster-guard/reports/summary.svg
new file mode 100644
index 00000000..e2d6688b
--- /dev/null
+++ b/analytics-license-seat-roster-guard/reports/summary.svg
@@ -0,0 +1,16 @@
+
diff --git a/analytics-license-seat-roster-guard/requirements-map.md b/analytics-license-seat-roster-guard/requirements-map.md
new file mode 100644
index 00000000..dbb02eea
--- /dev/null
+++ b/analytics-license-seat-roster-guard/requirements-map.md
@@ -0,0 +1,18 @@
+# Requirements Map
+
+Issue: `SCIBASE-AI/SCIBASE.AI#20`
+
+| Issue requirement | Implementation |
+| --- | --- |
+| Licensing APIs and analytics revenue | Validates named analytics dashboard/API seats against contract terms before renewal billing. |
+| Institutional customers | Sample data models a consortium contract with allowed domains, seat classes, temporary access, and finance approvals. |
+| Periodic analytics dashboard access | Checks active dashboard/viewer seats, inactive paid seats, duplicate identities, and temporary access expiry. |
+| API access to graph metadata | Detects analytics API usage by users without API seat rights, while avoiding live query serving or API authorization. |
+| Revenue infrastructure | Produces finance actions, estimated revenue exposure, and renewal true-up decisions. |
+| Safe local validation | Includes dependency-free tests, demo report generation, SVG summary, and browser-generated demo video. |
+
+## Non-goals
+
+- No Stripe, PayPal, ACH, bank, tax, ERP, or live invoice actions.
+- No SSO, SCIM, HRIS, analytics API, or dashboard provider calls.
+- No private customer data, credentials, or real payment artifacts.
diff --git a/analytics-license-seat-roster-guard/sample-data.js b/analytics-license-seat-roster-guard/sample-data.js
new file mode 100644
index 00000000..bf661827
--- /dev/null
+++ b/analytics-license-seat-roster-guard/sample-data.js
@@ -0,0 +1,120 @@
+const project = {
+ asOfDate: "2026-05-22",
+ contract: {
+ customer: "Northbridge Research Consortium",
+ renewalDate: "2026-06-01",
+ allowedDomains: ["northbridge.edu", "nrc-labs.org"],
+ inactivityReclaimDays: 90,
+ seatEntitlements: {
+ dashboard: 4,
+ api: 2,
+ viewer: 5
+ },
+ seatRates: {
+ dashboard: 1200,
+ api: 3200,
+ viewer: 450
+ }
+ },
+ approvals: {
+ domainApprovals: [
+ { domain: "nrc-labs.org", status: "approved", evidenceId: "addendum-2026-02" }
+ ],
+ overageApprovals: [],
+ temporaryAccessApprovals: [
+ { userId: "seat-009", status: "approved", expiresAt: "2026-06-15" }
+ ]
+ },
+ roster: {
+ users: [
+ {
+ id: "seat-001",
+ email: "maya.chen@northbridge.edu",
+ seatClass: "dashboard",
+ status: "active",
+ lastSeenAt: "2026-05-21"
+ },
+ {
+ id: "seat-002",
+ email: "omar.patel@northbridge.edu",
+ seatClass: "dashboard",
+ status: "active",
+ lastSeenAt: "2026-05-17"
+ },
+ {
+ id: "seat-003",
+ email: "liam.gray@northbridge.edu",
+ seatClass: "dashboard",
+ status: "active",
+ lastSeenAt: "2025-12-18"
+ },
+ {
+ id: "seat-004",
+ email: "grant.ops@northbridge.edu",
+ seatClass: "dashboard",
+ status: "active",
+ lastSeenAt: "2026-05-12"
+ },
+ {
+ id: "seat-005",
+ email: "visiting.pi@partner-lab.com",
+ seatClass: "dashboard",
+ status: "active",
+ temporaryUntil: "2026-04-30",
+ lastSeenAt: "2026-05-20"
+ },
+ {
+ id: "seat-006",
+ email: "data-api@nrc-labs.org",
+ seatClass: "api",
+ status: "active",
+ lastSeenAt: "2026-05-22"
+ },
+ {
+ id: "seat-007",
+ email: "policy-api@northbridge.edu",
+ seatClass: "viewer",
+ status: "active",
+ lastSeenAt: "2026-05-19"
+ },
+ {
+ id: "seat-008",
+ email: "archive-api@nrc-labs.org",
+ seatClass: "api",
+ status: "active",
+ lastSeenAt: "2026-05-18"
+ },
+ {
+ id: "seat-009",
+ email: "reviewer.temp@northbridge.edu",
+ seatClass: "viewer",
+ status: "active",
+ temporaryUntil: "2026-06-10",
+ lastSeenAt: "2026-05-16"
+ },
+ {
+ id: "seat-010",
+ email: "maya.chen@northbridge.edu",
+ seatClass: "viewer",
+ status: "active",
+ lastSeenAt: "2026-05-20"
+ }
+ ]
+ },
+ usage: {
+ byUser: {
+ "seat-001": { dashboardSessions: 14, apiQueries: 0 },
+ "seat-002": { dashboardSessions: 8, apiQueries: 0 },
+ "seat-003": { dashboardSessions: 0, apiQueries: 0 },
+ "seat-004": { dashboardSessions: 6, apiQueries: 0 },
+ "seat-005": { dashboardSessions: 2, apiQueries: 0 },
+ "seat-006": { dashboardSessions: 1, apiQueries: 4200 },
+ "seat-007": { dashboardSessions: 3, apiQueries: 1300 },
+ "seat-008": { dashboardSessions: 1, apiQueries: 900 },
+ "seat-009": { dashboardSessions: 2, apiQueries: 0 },
+ "seat-010": { dashboardSessions: 1, apiQueries: 0 }
+ }
+ }
+};
+
+module.exports = { project };
diff --git a/analytics-license-seat-roster-guard/test.js b/analytics-license-seat-roster-guard/test.js
new file mode 100644
index 00000000..7cfed875
--- /dev/null
+++ b/analytics-license-seat-roster-guard/test.js
@@ -0,0 +1,55 @@
+const assert = require("assert");
+const { project } = require("./sample-data");
+const { buildReviewPacket, evaluateRoster, renderMarkdownReport, renderSvgSummary } = require("./index");
+
+const evaluation = evaluateRoster(project);
+const packet = buildReviewPacket(project);
+
+assert.strictEqual(packet.guard, "analytics-license-seat-roster-guard");
+assert.strictEqual(packet.issue, "SCIBASE-AI/SCIBASE.AI#20");
+assert.strictEqual(packet.decision, "block-renewal-until-seat-evidence-is-clean");
+assert.ok(packet.estimatedRevenueExposure >= 6000, "expected material revenue exposure");
+
+assert.ok(
+ evaluation.findings.some((finding) => finding.rule === "unapproved-seat-overage"),
+ "expected dashboard seat overage finding"
+);
+assert.ok(
+ evaluation.findings.some((finding) => finding.rule === "unapproved-seat-domain"),
+ "expected unapproved external domain finding"
+);
+assert.ok(
+ evaluation.findings.some((finding) => finding.rule === "api-usage-without-api-seat"),
+ "expected API usage without API seat finding"
+);
+assert.ok(
+ evaluation.findings.some((finding) => finding.rule === "duplicate-named-seat"),
+ "expected duplicate named seat finding"
+);
+assert.ok(
+ evaluation.findings.some((finding) => finding.rule === "inactive-paid-seat"),
+ "expected inactive paid seat finding"
+);
+
+const cleanProject = JSON.parse(JSON.stringify(project));
+cleanProject.contract.seatEntitlements.dashboard = 5;
+cleanProject.contract.seatEntitlements.viewer = 6;
+cleanProject.contract.allowedDomains.push("partner-lab.com");
+cleanProject.roster.users = cleanProject.roster.users.filter((user) => user.id !== "seat-010");
+cleanProject.roster.users.find((user) => user.id === "seat-003").lastSeenAt = "2026-05-01";
+cleanProject.roster.users.find((user) => user.id === "seat-005").temporaryUntil = "2026-06-30";
+cleanProject.roster.users.find((user) => user.id === "seat-007").seatClass = "api";
+cleanProject.contract.seatEntitlements.api = 3;
+const cleanPacket = buildReviewPacket(cleanProject);
+assert.strictEqual(cleanPacket.decision, "renewal-roster-ready");
+assert.strictEqual(cleanPacket.findings.length, 0);
+
+const markdown = renderMarkdownReport(packet);
+assert.ok(markdown.includes("## Seat Counts"));
+assert.ok(markdown.includes("unapproved-seat-domain"));
+
+const svg = renderSvgSummary(packet);
+assert.ok(svg.includes("