From c25709966a89b37ac4a0df1ca2f90e3223845147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szab=C3=B3=20D=C3=A1vid?= Date: Sat, 27 Jun 2026 10:18:24 +0200 Subject: [PATCH] Add embargoed data license guard --- embargoed-data-license-guard/README.md | 49 ++++++ .../artifacts/demo-output.json | 68 ++++++++ .../artifacts/demo-report.md | 25 +++ .../artifacts/release-summary.svg | 6 + embargoed-data-license-guard/package.json | 11 ++ embargoed-data-license-guard/src/cli.js | 17 ++ .../src/licenseGuard.js | 164 ++++++++++++++++++ embargoed-data-license-guard/src/report.js | 66 +++++++ .../src/samplePackets.js | 80 +++++++++ .../test/license-guard.test.js | 50 ++++++ 10 files changed, 536 insertions(+) create mode 100644 embargoed-data-license-guard/README.md create mode 100644 embargoed-data-license-guard/artifacts/demo-output.json create mode 100644 embargoed-data-license-guard/artifacts/demo-report.md create mode 100644 embargoed-data-license-guard/artifacts/release-summary.svg create mode 100644 embargoed-data-license-guard/package.json create mode 100644 embargoed-data-license-guard/src/cli.js create mode 100644 embargoed-data-license-guard/src/licenseGuard.js create mode 100644 embargoed-data-license-guard/src/report.js create mode 100644 embargoed-data-license-guard/src/samplePackets.js create mode 100644 embargoed-data-license-guard/test/license-guard.test.js diff --git a/embargoed-data-license-guard/README.md b/embargoed-data-license-guard/README.md new file mode 100644 index 00000000..4f36fbbe --- /dev/null +++ b/embargoed-data-license-guard/README.md @@ -0,0 +1,49 @@ +# Embargoed Data License Guard + +This module is a focused Revenue Infrastructure slice for issue #20. It checks +whether an anonymized research metadata or data-licensing export can be released +to an institutional, government, or market-intelligence customer. + +It is intentionally self-contained: + +- no payment processor calls +- no external APIs +- no credentials +- no private research content +- synthetic packets only + +## What It Checks + +- Embargo windows have ended or the license has a valid early-access grant. +- Exclusive licenses are not released to the wrong customer. +- Cohort size is large enough for anonymized export. +- Consent coverage meets the license policy. +- License tier permits the requested export type. +- Required attribution, redaction, and data-processing terms are present. +- Deliverables are not stale relative to the license snapshot. + +## Run + +```bash +npm test +npm run demo +``` + +The demo writes reviewer artifacts under `artifacts/`: + +- `demo-output.json` +- `demo-report.md` +- `release-summary.svg` + +## Requirement Mapping + +Issue #20 calls out monetizing structured, anonymized usage and research +metadata through licensing APIs and analytics. This guard focuses on the release +control before such a licensing export is delivered or invoiced. + +| Issue requirement | Covered by | +| --- | --- | +| Data licensing models for institutional and government customers | customer/license packet fields | +| Anonymized metadata, never private content | cohort and redaction checks | +| API/dashboard licensing options | `exportType` tier entitlement checks | +| Predictable revenue and compliance-safe delivery | deterministic release decisions | diff --git a/embargoed-data-license-guard/artifacts/demo-output.json b/embargoed-data-license-guard/artifacts/demo-output.json new file mode 100644 index 00000000..579d3df7 --- /dev/null +++ b/embargoed-data-license-guard/artifacts/demo-output.json @@ -0,0 +1,68 @@ +[ + { + "licenseId": "lic-gov-neuro-2026", + "customer": "NIH Policy Lab", + "exportType": "api", + "decision": "RELEASE", + "findings": [], + "financeAction": "release_export_and_invoice" + }, + { + "licenseId": "lic-market-rare-disease-2026", + "customer": "Market Intel A", + "exportType": "periodic_snapshot", + "decision": "REVIEW", + "findings": [ + { + "code": "EARLY_ACCESS_REVIEW", + "severity": "REVIEW", + "message": "Export is still under embargo but has an early-access approval." + }, + { + "code": "CONSENT_GAP", + "severity": "REVIEW", + "message": "Consent coverage 92% is below required 95%." + } + ], + "financeAction": "route_to_licensing_operations" + }, + { + "licenseId": "lic-exclusive-funder-2026", + "customer": "Regional Consortium", + "exportType": "white_label", + "decision": "HOLD", + "findings": [ + { + "code": "EXCLUSIVITY_CONFLICT", + "severity": "HOLD", + "message": "License conflicts with an active exclusive customer grant." + }, + { + "code": "COHORT_TOO_SMALL", + "severity": "HOLD", + "message": "Cohort size 42 is below minimum 100." + }, + { + "code": "CONSENT_GAP", + "severity": "REVIEW", + "message": "Consent coverage 88% is below required 95%." + }, + { + "code": "TIER_NOT_ENTITLED", + "severity": "HOLD", + "message": "Dashboard license does not permit white_label exports." + }, + { + "code": "MISSING_RELEASE_TERMS", + "severity": "REVIEW", + "message": "Missing data processing agreement, redaction profile, attribution text." + }, + { + "code": "STALE_SNAPSHOT", + "severity": "REVIEW", + "message": "Snapshot is 57 days old; maximum is 30." + } + ], + "financeAction": "hold_export_and_block_invoice" + } +] diff --git a/embargoed-data-license-guard/artifacts/demo-report.md b/embargoed-data-license-guard/artifacts/demo-report.md new file mode 100644 index 00000000..e59e4a4a --- /dev/null +++ b/embargoed-data-license-guard/artifacts/demo-report.md @@ -0,0 +1,25 @@ +# Embargoed Data License Guard Demo + +| License | Customer | Export | Decision | Finance action | +| --- | --- | --- | --- | --- | +| lic-gov-neuro-2026 | NIH Policy Lab | api | RELEASE | release_export_and_invoice | +| lic-market-rare-disease-2026 | Market Intel A | periodic_snapshot | REVIEW | route_to_licensing_operations | +| lic-exclusive-funder-2026 | Regional Consortium | white_label | HOLD | hold_export_and_block_invoice | + +### lic-gov-neuro-2026 + + - RELEASE: no blocking findings + +### lic-market-rare-disease-2026 + + - REVIEW: EARLY_ACCESS_REVIEW - Export is still under embargo but has an early-access approval. + - REVIEW: CONSENT_GAP - Consent coverage 92% is below required 95%. + +### lic-exclusive-funder-2026 + + - HOLD: EXCLUSIVITY_CONFLICT - License conflicts with an active exclusive customer grant. + - HOLD: COHORT_TOO_SMALL - Cohort size 42 is below minimum 100. + - REVIEW: CONSENT_GAP - Consent coverage 88% is below required 95%. + - HOLD: TIER_NOT_ENTITLED - Dashboard license does not permit white_label exports. + - REVIEW: MISSING_RELEASE_TERMS - Missing data processing agreement, redaction profile, attribution text. + - REVIEW: STALE_SNAPSHOT - Snapshot is 57 days old; maximum is 30. diff --git a/embargoed-data-license-guard/artifacts/release-summary.svg b/embargoed-data-license-guard/artifacts/release-summary.svg new file mode 100644 index 00000000..c7ff21c5 --- /dev/null +++ b/embargoed-data-license-guard/artifacts/release-summary.svg @@ -0,0 +1,6 @@ + + + Embargoed data license decisions + Release1Review1Hold1 + Synthetic packets only; no live customer or research data. + diff --git a/embargoed-data-license-guard/package.json b/embargoed-data-license-guard/package.json new file mode 100644 index 00000000..66ad2c03 --- /dev/null +++ b/embargoed-data-license-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "embargoed-data-license-guard", + "version": "1.0.0", + "private": true, + "description": "Dependency-free release guard for embargoed research data licensing exports.", + "type": "module", + "scripts": { + "demo": "node src/cli.js", + "test": "node test/license-guard.test.js" + } +} diff --git a/embargoed-data-license-guard/src/cli.js b/embargoed-data-license-guard/src/cli.js new file mode 100644 index 00000000..5d872595 --- /dev/null +++ b/embargoed-data-license-guard/src/cli.js @@ -0,0 +1,17 @@ +import { evaluateLicenseRelease } from "./licenseGuard.js"; +import { writeArtifacts } from "./report.js"; +import { samplePackets } from "./samplePackets.js"; + +const results = samplePackets.map((packet) => evaluateLicenseRelease(packet)); + +for (const result of results) { + console.log( + `${result.licenseId}: ${result.decision} (${result.financeAction})`, + ); + for (const finding of result.findings) { + console.log(` - ${finding.severity} ${finding.code}: ${finding.message}`); + } +} + +writeArtifacts(results); +console.log("Artifacts written to artifacts/"); diff --git a/embargoed-data-license-guard/src/licenseGuard.js b/embargoed-data-license-guard/src/licenseGuard.js new file mode 100644 index 00000000..e3c94db5 --- /dev/null +++ b/embargoed-data-license-guard/src/licenseGuard.js @@ -0,0 +1,164 @@ +const EXPORT_TIER_ORDER = { + dashboard: 1, + periodic_snapshot: 2, + api: 3, + white_label: 4, +}; + +const DECISION_PRIORITY = { + RELEASE: 0, + REVIEW: 1, + HOLD: 2, +}; + +export function evaluateLicenseRelease(packet, today = "2026-06-27") { + const findings = [ + checkEmbargo(packet, today), + checkExclusivity(packet), + checkCohortSize(packet), + checkConsentCoverage(packet), + checkTier(packet), + checkTerms(packet), + checkSnapshotFreshness(packet, today), + ].filter(Boolean); + + const decision = findings.reduce( + (current, finding) => + DECISION_PRIORITY[finding.severity] > DECISION_PRIORITY[current] + ? finding.severity + : current, + "RELEASE", + ); + + return { + licenseId: packet.licenseId, + customer: packet.customer.name, + exportType: packet.export.type, + decision, + findings, + financeAction: financeActionFor(decision), + }; +} + +export function summarizeResults(results) { + return { + total: results.length, + release: results.filter((result) => result.decision === "RELEASE").length, + review: results.filter((result) => result.decision === "REVIEW").length, + hold: results.filter((result) => result.decision === "HOLD").length, + }; +} + +function checkEmbargo(packet, today) { + if (packet.export.embargoEndsAt <= today) { + return null; + } + if (packet.license.allowEarlyAccess && packet.license.earlyAccessApprovalId) { + return { + code: "EARLY_ACCESS_REVIEW", + severity: "REVIEW", + message: "Export is still under embargo but has an early-access approval.", + }; + } + return { + code: "EMBARGO_ACTIVE", + severity: "HOLD", + message: `Export is embargoed until ${packet.export.embargoEndsAt}.`, + }; +} + +function checkExclusivity(packet) { + const exclusive = packet.license.exclusiveCustomerId; + if (!exclusive || exclusive === packet.customer.id) { + return null; + } + return { + code: "EXCLUSIVITY_CONFLICT", + severity: "HOLD", + message: "License conflicts with an active exclusive customer grant.", + }; +} + +function checkCohortSize(packet) { + if (packet.export.cohortSize >= packet.policy.minimumCohortSize) { + return null; + } + return { + code: "COHORT_TOO_SMALL", + severity: "HOLD", + message: `Cohort size ${packet.export.cohortSize} is below minimum ${packet.policy.minimumCohortSize}.`, + }; +} + +function checkConsentCoverage(packet) { + if (packet.export.consentCoverage >= packet.policy.minimumConsentCoverage) { + return null; + } + return { + code: "CONSENT_GAP", + severity: "REVIEW", + message: `Consent coverage ${packet.export.consentCoverage}% is below required ${packet.policy.minimumConsentCoverage}%.`, + }; +} + +function checkTier(packet) { + const requested = EXPORT_TIER_ORDER[packet.export.type] ?? Number.POSITIVE_INFINITY; + const allowed = EXPORT_TIER_ORDER[packet.license.maxExportType] ?? 0; + if (requested <= allowed) { + return null; + } + return { + code: "TIER_NOT_ENTITLED", + severity: "HOLD", + message: `${packet.license.tier} license does not permit ${packet.export.type} exports.`, + }; +} + +function checkTerms(packet) { + const missing = []; + if (!packet.license.hasDataProcessingAgreement) { + missing.push("data processing agreement"); + } + if (!packet.export.redactionProfileId) { + missing.push("redaction profile"); + } + if (!packet.export.attributionText) { + missing.push("attribution text"); + } + if (missing.length === 0) { + return null; + } + return { + code: "MISSING_RELEASE_TERMS", + severity: "REVIEW", + message: `Missing ${missing.join(", ")}.`, + }; +} + +function checkSnapshotFreshness(packet, today) { + const snapshotAgeDays = daysBetween(packet.export.snapshotAt, today); + if (snapshotAgeDays <= packet.policy.maximumSnapshotAgeDays) { + return null; + } + return { + code: "STALE_SNAPSHOT", + severity: "REVIEW", + message: `Snapshot is ${snapshotAgeDays} days old; maximum is ${packet.policy.maximumSnapshotAgeDays}.`, + }; +} + +function daysBetween(from, to) { + const start = Date.parse(`${from}T00:00:00Z`); + const end = Date.parse(`${to}T00:00:00Z`); + return Math.floor((end - start) / 86_400_000); +} + +function financeActionFor(decision) { + if (decision === "RELEASE") { + return "release_export_and_invoice"; + } + if (decision === "REVIEW") { + return "route_to_licensing_operations"; + } + return "hold_export_and_block_invoice"; +} diff --git a/embargoed-data-license-guard/src/report.js b/embargoed-data-license-guard/src/report.js new file mode 100644 index 00000000..bf4cd6b8 --- /dev/null +++ b/embargoed-data-license-guard/src/report.js @@ -0,0 +1,66 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { summarizeResults } from "./licenseGuard.js"; + +export function writeArtifacts(results, outputDir = "artifacts") { + mkdirSync(outputDir, { recursive: true }); + writeFileSync( + join(outputDir, "demo-output.json"), + `${JSON.stringify(results, null, 2)}\n`, + ); + writeFileSync(join(outputDir, "demo-report.md"), markdownReport(results)); + writeFileSync(join(outputDir, "release-summary.svg"), svgSummary(results)); +} + +export function markdownReport(results) { + const rows = results + .map( + (result) => + `| ${result.licenseId} | ${result.customer} | ${result.exportType} | ${result.decision} | ${result.financeAction} |`, + ) + .join("\n"); + const details = results + .map((result) => { + const findings = result.findings.length + ? result.findings + .map((finding) => ` - ${finding.severity}: ${finding.code} - ${finding.message}`) + .join("\n") + : " - RELEASE: no blocking findings"; + return `### ${result.licenseId}\n\n${findings}`; + }) + .join("\n\n"); + + return `# Embargoed Data License Guard Demo + +| License | Customer | Export | Decision | Finance action | +| --- | --- | --- | --- | --- | +${rows} + +${details} +`; +} + +export function svgSummary(results) { + const summary = summarizeResults(results); + const bars = [ + { label: "Release", value: summary.release, color: "#15803d" }, + { label: "Review", value: summary.review, color: "#ca8a04" }, + { label: "Hold", value: summary.hold, color: "#b91c1c" }, + ]; + const max = Math.max(...bars.map((bar) => bar.value), 1); + const barMarkup = bars + .map((bar, index) => { + const y = 56 + index * 48; + const width = 240 * (bar.value / max); + return `${bar.label}${bar.value}`; + }) + .join(""); + return ` + + Embargoed data license decisions + ${barMarkup} + Synthetic packets only; no live customer or research data. + +`; +} diff --git a/embargoed-data-license-guard/src/samplePackets.js b/embargoed-data-license-guard/src/samplePackets.js new file mode 100644 index 00000000..f9a82acc --- /dev/null +++ b/embargoed-data-license-guard/src/samplePackets.js @@ -0,0 +1,80 @@ +export const samplePackets = [ + { + licenseId: "lic-gov-neuro-2026", + customer: { id: "nih-policy-lab", name: "NIH Policy Lab" }, + license: { + tier: "Government API", + maxExportType: "api", + allowEarlyAccess: false, + earlyAccessApprovalId: null, + exclusiveCustomerId: null, + hasDataProcessingAgreement: true, + }, + export: { + type: "api", + embargoEndsAt: "2026-05-15", + snapshotAt: "2026-06-20", + cohortSize: 1840, + consentCoverage: 97, + redactionProfileId: "redact-standard-v3", + attributionText: "Derived from anonymized SCIBASE research metadata.", + }, + policy: { + minimumCohortSize: 100, + minimumConsentCoverage: 95, + maximumSnapshotAgeDays: 30, + }, + }, + { + licenseId: "lic-market-rare-disease-2026", + customer: { id: "market-intel-a", name: "Market Intel A" }, + license: { + tier: "Periodic Snapshot", + maxExportType: "periodic_snapshot", + allowEarlyAccess: true, + earlyAccessApprovalId: "ea-rare-disease-17", + exclusiveCustomerId: null, + hasDataProcessingAgreement: true, + }, + export: { + type: "periodic_snapshot", + embargoEndsAt: "2026-07-15", + snapshotAt: "2026-06-12", + cohortSize: 84, + consentCoverage: 92, + redactionProfileId: "redact-rare-disease-v2", + attributionText: "Synthetic rare-disease trend extract for review.", + }, + policy: { + minimumCohortSize: 75, + minimumConsentCoverage: 95, + maximumSnapshotAgeDays: 30, + }, + }, + { + licenseId: "lic-exclusive-funder-2026", + customer: { id: "regional-consortium", name: "Regional Consortium" }, + license: { + tier: "Dashboard", + maxExportType: "dashboard", + allowEarlyAccess: false, + earlyAccessApprovalId: null, + exclusiveCustomerId: "national-funder", + hasDataProcessingAgreement: false, + }, + export: { + type: "white_label", + embargoEndsAt: "2026-04-01", + snapshotAt: "2026-05-01", + cohortSize: 42, + consentCoverage: 88, + redactionProfileId: "", + attributionText: "", + }, + policy: { + minimumCohortSize: 100, + minimumConsentCoverage: 95, + maximumSnapshotAgeDays: 30, + }, + }, +]; diff --git a/embargoed-data-license-guard/test/license-guard.test.js b/embargoed-data-license-guard/test/license-guard.test.js new file mode 100644 index 00000000..9c9816df --- /dev/null +++ b/embargoed-data-license-guard/test/license-guard.test.js @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFileSync, rmSync } from "node:fs"; + +import { + evaluateLicenseRelease, + summarizeResults, +} from "../src/licenseGuard.js"; +import { markdownReport, svgSummary, writeArtifacts } from "../src/report.js"; +import { samplePackets } from "../src/samplePackets.js"; + +const [releasePacket, reviewPacket, holdPacket] = samplePackets; + +const releaseResult = evaluateLicenseRelease(releasePacket); +assert.equal(releaseResult.decision, "RELEASE"); + +const review = evaluateLicenseRelease(reviewPacket); +assert.equal(review.decision, "REVIEW"); +assert.deepEqual( + review.findings.map((finding) => finding.code), + ["EARLY_ACCESS_REVIEW", "CONSENT_GAP"], +); + +const hold = evaluateLicenseRelease(holdPacket); +assert.equal(hold.decision, "HOLD"); +assert.ok(hold.findings.some((finding) => finding.code === "EXCLUSIVITY_CONFLICT")); +assert.ok(hold.findings.some((finding) => finding.code === "TIER_NOT_ENTITLED")); + +assert.deepEqual( + summarizeResults(samplePackets.map((packet) => evaluateLicenseRelease(packet))), + { total: 3, release: 1, review: 1, hold: 1 }, +); + +const report = markdownReport([releaseResult, review, hold]); +assert.match(report, /Embargoed Data License Guard Demo/); +assert.match(report, /lic-market-rare-disease-2026/); + +const svg = svgSummary([releaseResult, review, hold]); +assert.match(svg, /