diff --git a/README.md b/README.md
index d338cf6..7c0d4fa 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,6 @@
# deepevents.ai
deepevents.ai main codebase
+
+## SCIBASE contribution modules
+
+- [Enterprise API Change Governance](enterprise-api-change-governance/README.md): contract-change review for institutional REST APIs and webhook integrations.
diff --git a/enterprise-api-change-governance/README.md b/enterprise-api-change-governance/README.md
new file mode 100644
index 0000000..2f69376
--- /dev/null
+++ b/enterprise-api-change-governance/README.md
@@ -0,0 +1,38 @@
+# Enterprise API Change Governance
+
+This module adds a focused Enterprise Tooling slice for institutional REST API and webhook contract governance. It helps university, institute, and corporate R&D admins decide whether an integration-facing change is ready to ship, needs review, should be held, or must be blocked before it breaks downstream systems.
+
+## What It Covers
+
+- Versioned REST route and webhook event change review.
+- Breaking-change detection for removed fields, type changes, new required fields, and short deprecation windows.
+- Critical consumer readiness checks for notice acknowledgement, sandbox evidence, migration tickets, and version negotiation.
+- Restricted research data gates that require DPA evidence before funder or repository integrations receive sensitive payloads.
+- Admin dashboard metrics, prioritized remediation actions, migration plans, export evidence manifests, and signed webhook review events.
+
+## Why This Is Distinct
+
+Existing Enterprise Tooling submissions cover broad dashboards, export packages, compliance evidence packets, audit routing, webhook replay, identity drift, retention/legal hold, data residency, SLA, secret rotation, lab inventory, and compute/storage quotas. This slice focuses specifically on contract-change governance before APIs and webhook schemas are released to enterprise consumers.
+
+## References Reviewed
+
+- OpenAPI-style machine-readable contract diffs for REST API route review.
+- CloudEvents-style event envelopes for routing, replay, and event metadata consistency.
+- Backward-compatible API evolution practices such as versioned releases, deprecation windows, and consumer migration evidence.
+
+## Local Usage
+
+```bash
+npm run check
+npm test
+npm run demo
+```
+
+The demo writes:
+
+- `docs/demo.svg`
+- `docs/governance-report.json`
+
+This PR also includes a short synthetic walkthrough video at `docs/demo.mp4`.
+
+The committed sample data is synthetic and does not contain credentials, private research data, bank details, or personally identifying documents.
diff --git a/enterprise-api-change-governance/data/sample-change-plan.json b/enterprise-api-change-governance/data/sample-change-plan.json
new file mode 100644
index 0000000..6c2ac9e
--- /dev/null
+++ b/enterprise-api-change-governance/data/sample-change-plan.json
@@ -0,0 +1,165 @@
+{
+ "portfolioId": "enterprise-api-governance-demo",
+ "asOf": "2026-05-17T02:30:00.000Z",
+ "policy": {
+ "minimumBreakingDeprecationDays": 90,
+ "criticalConsumerNoticeDays": 60,
+ "minimumRollbackDays": 14,
+ "requireOpenApiDiff": true,
+ "requireWebhookSchemaVersion": true,
+ "requireSandboxEvidence": true,
+ "requireDpaForRestrictedExports": true
+ },
+ "integrations": [
+ {
+ "integrationId": "dspace-archive-prod",
+ "institution": "Northbridge University Library",
+ "systemType": "repository",
+ "criticality": "critical",
+ "contactGroup": "library-platforms",
+ "pinnedApiVersions": ["v1"],
+ "subscribedWebhookTypes": ["project.published.v1", "export.completed.v1"],
+ "lastSuccessfulSandboxRun": "2026-05-05",
+ "supportsVersionNegotiation": true,
+ "notificationStatus": "acknowledged",
+ "migrationTicket": "NBUL-4421",
+ "hasDataProcessingAgreement": true
+ },
+ {
+ "integrationId": "canvas-outcomes-sync",
+ "institution": "Westlake Medical School",
+ "systemType": "lms",
+ "criticality": "standard",
+ "contactGroup": "learning-systems",
+ "pinnedApiVersions": ["v2"],
+ "subscribedWebhookTypes": ["review.completed.v1"],
+ "lastSuccessfulSandboxRun": "2026-05-12",
+ "supportsVersionNegotiation": false,
+ "notificationStatus": "acknowledged",
+ "migrationTicket": "WMS-1180",
+ "hasDataProcessingAgreement": true
+ },
+ {
+ "integrationId": "funder-reporter-nightly",
+ "institution": "Horizon Bioinformatics Institute",
+ "systemType": "funder_portal",
+ "criticality": "critical",
+ "contactGroup": "grants-ops",
+ "pinnedApiVersions": ["v1"],
+ "subscribedWebhookTypes": ["compliance.flagged.v1", "export.completed.v1"],
+ "lastSuccessfulSandboxRun": null,
+ "supportsVersionNegotiation": false,
+ "notificationStatus": "missing",
+ "migrationTicket": null,
+ "hasDataProcessingAgreement": false
+ }
+ ],
+ "changes": [
+ {
+ "changeId": "api-project-export-v3",
+ "title": "Introduce v3 project export manifest with versioned metadata blocks",
+ "ownerTeam": "enterprise-integrations",
+ "surface": "rest_api",
+ "changeKind": "additive_version",
+ "currentVersion": "v2",
+ "proposedVersion": "v3",
+ "effectiveDate": "2026-09-01",
+ "affectedRoutes": ["GET /api/projects/{projectId}/exports", "POST /api/projects/{projectId}/exports"],
+ "affectedWebhookTypes": [],
+ "breaking": false,
+ "restrictedResearchData": false,
+ "openApiDiffAttached": true,
+ "rollbackPlanDays": 30,
+ "parallelRunDays": 120,
+ "schemaDiff": {
+ "removedFields": [],
+ "renamedFields": [],
+ "typeChanges": [],
+ "newRequiredFields": [],
+ "newOptionalFields": ["metadata.versionHistory", "metadata.repositoryTargets"]
+ },
+ "webhookEnvelope": {
+ "usesCloudEvents": true,
+ "schemaVersionField": "data.schemaVersion",
+ "idempotencyKeyField": "data.deliveryId",
+ "signatureVersion": "v2"
+ },
+ "sandboxEvidence": {
+ "fixturePack": "exports-v3-2026-05",
+ "passingIntegrations": ["dspace-archive-prod", "canvas-outcomes-sync"],
+ "failingIntegrations": []
+ }
+ },
+ {
+ "changeId": "api-review-score-removal",
+ "title": "Remove legacy peerReview.score field from review completed payloads",
+ "ownerTeam": "peer-review-platform",
+ "surface": "webhook",
+ "changeKind": "breaking_removal",
+ "currentVersion": "review.completed.v1",
+ "proposedVersion": "review.completed.v2",
+ "effectiveDate": "2026-06-15",
+ "affectedRoutes": [],
+ "affectedWebhookTypes": ["review.completed.v1"],
+ "breaking": true,
+ "restrictedResearchData": false,
+ "openApiDiffAttached": false,
+ "rollbackPlanDays": 3,
+ "parallelRunDays": 29,
+ "schemaDiff": {
+ "removedFields": ["peerReview.score"],
+ "renamedFields": ["peerReview.rubric -> peerReview.rubricBreakdown"],
+ "typeChanges": ["peerReview.reviewerCount:number -> string"],
+ "newRequiredFields": ["peerReview.decisionCode"],
+ "newOptionalFields": []
+ },
+ "webhookEnvelope": {
+ "usesCloudEvents": false,
+ "schemaVersionField": null,
+ "idempotencyKeyField": null,
+ "signatureVersion": null
+ },
+ "sandboxEvidence": {
+ "fixturePack": null,
+ "passingIntegrations": [],
+ "failingIntegrations": ["canvas-outcomes-sync"]
+ }
+ },
+ {
+ "changeId": "api-compliance-flag-pii",
+ "title": "Add restricted compliance flag webhook for funder reporting",
+ "ownerTeam": "compliance-ops",
+ "surface": "webhook",
+ "changeKind": "new_event",
+ "currentVersion": null,
+ "proposedVersion": "compliance.flagged.v2",
+ "effectiveDate": "2026-07-20",
+ "affectedRoutes": ["GET /api/compliance/flags"],
+ "affectedWebhookTypes": ["compliance.flagged.v2"],
+ "breaking": false,
+ "restrictedResearchData": true,
+ "openApiDiffAttached": true,
+ "rollbackPlanDays": 21,
+ "parallelRunDays": 90,
+ "schemaDiff": {
+ "removedFields": [],
+ "renamedFields": [],
+ "typeChanges": [],
+ "newRequiredFields": ["flag.category", "flag.evidenceDigest"],
+ "newOptionalFields": ["flag.funderMandateId"]
+ },
+ "webhookEnvelope": {
+ "usesCloudEvents": true,
+ "schemaVersionField": "data.schemaVersion",
+ "idempotencyKeyField": "data.deliveryId",
+ "signatureVersion": "v2"
+ },
+ "sandboxEvidence": {
+ "fixturePack": "compliance-v2-2026-05",
+ "passingIntegrations": ["dspace-archive-prod"],
+ "failingIntegrations": ["funder-reporter-nightly"]
+ }
+ }
+ ],
+ "signingKeyId": "synthetic-key-2026-05"
+}
diff --git a/enterprise-api-change-governance/docs/acceptance-notes.md b/enterprise-api-change-governance/docs/acceptance-notes.md
new file mode 100644
index 0000000..f932ad3
--- /dev/null
+++ b/enterprise-api-change-governance/docs/acceptance-notes.md
@@ -0,0 +1,41 @@
+# Acceptance Notes
+
+This contribution targets issue `#19` by adding a distinct Enterprise Tooling module for API and webhook contract-change governance.
+
+## Review Scope
+
+- Self-contained dependency-free Node module.
+- Synthetic data only.
+- No external credentials, accounts, payment details, bank information, private research data, or KYC material.
+- No changes to existing application code paths.
+
+## Distinctness
+
+This does not duplicate existing SCIBASE Enterprise Tooling submissions for:
+
+- admin dashboard foundations
+- export packages
+- compliance evidence packets
+- audit signal routing
+- webhook replay ledgers
+- identity provisioning drift
+- retention/legal hold
+- grant portfolio compliance
+- data residency
+- SLA/uptime monitoring
+- secret rotation
+- lab inventory readiness
+- compute/storage quota governance
+
+It covers a narrower pre-release governance layer: whether enterprise-facing API routes and webhook contracts can safely change without breaking institutional consumers.
+
+## Validation Commands
+
+```bash
+cd enterprise-api-change-governance
+npm run check
+npm test
+npm run demo
+```
+
+Demo video: `enterprise-api-change-governance/docs/demo.mp4`.
diff --git a/enterprise-api-change-governance/docs/demo.mp4 b/enterprise-api-change-governance/docs/demo.mp4
new file mode 100644
index 0000000..4bfa356
Binary files /dev/null and b/enterprise-api-change-governance/docs/demo.mp4 differ
diff --git a/enterprise-api-change-governance/docs/demo.svg b/enterprise-api-change-governance/docs/demo.svg
new file mode 100644
index 0000000..33a20b6
--- /dev/null
+++ b/enterprise-api-change-governance/docs/demo.svg
@@ -0,0 +1,65 @@
+
+
diff --git a/enterprise-api-change-governance/docs/governance-report.json b/enterprise-api-change-governance/docs/governance-report.json
new file mode 100644
index 0000000..4c0cb6d
--- /dev/null
+++ b/enterprise-api-change-governance/docs/governance-report.json
@@ -0,0 +1,608 @@
+{
+ "generatedAt": "2026-05-17T02:30:00.000Z",
+ "portfolioId": "enterprise-api-governance-demo",
+ "dashboard": {
+ "totalChanges": 3,
+ "byState": {
+ "ready": 1,
+ "watch": 0,
+ "hold": 1,
+ "blocked": 1
+ },
+ "blockedChanges": 1,
+ "holdOrBlockedChanges": 2,
+ "criticalIntegrationsImpacted": 1,
+ "consumerBlocks": 2,
+ "uniqueInstitutionsImpacted": 2,
+ "highestRiskScore": 100
+ },
+ "reviews": [
+ {
+ "changeId": "api-project-export-v3",
+ "title": "Introduce v3 project export manifest with versioned metadata blocks",
+ "ownerTeam": "enterprise-integrations",
+ "surface": "rest_api",
+ "state": "ready",
+ "riskScore": 0,
+ "effectiveDate": "2026-09-01",
+ "breaking": false,
+ "compatibilityWindowDays": 120,
+ "affectedRoutes": [
+ "GET /api/projects/{projectId}/exports",
+ "POST /api/projects/{projectId}/exports"
+ ],
+ "affectedWebhookTypes": [],
+ "affectedConsumers": [
+ {
+ "integrationId": "canvas-outcomes-sync",
+ "institution": "Westlake Medical School",
+ "systemType": "lms",
+ "criticality": "standard",
+ "contactGroup": "learning-systems",
+ "readiness": "ready",
+ "blockers": [],
+ "warnings": [],
+ "migrationTicket": "WMS-1180",
+ "lastSuccessfulSandboxRun": "2026-05-12"
+ }
+ ],
+ "findings": [],
+ "adminActions": [
+ "Approve api-project-export-v3 for enterprise rollout with monitored release notes."
+ ],
+ "migrationPlan": {
+ "changeId": "api-project-export-v3",
+ "noticeAudience": [
+ "learning-systems"
+ ],
+ "sandboxFixturePack": "exports-v3-2026-05",
+ "rollbackPlanDays": 30,
+ "parallelRunDays": 120,
+ "requiredBeforeRelease": [
+ "publish release note",
+ "monitor webhook deliveries"
+ ],
+ "suggestedOrder": [
+ "publish contract diff and migration notes",
+ "run sandbox fixtures against affected integrations",
+ "collect critical-consumer acknowledgement",
+ "ship parallel version",
+ "monitor signed webhook evidence"
+ ]
+ },
+ "evidenceDigest": "a35bc73066e732b78bb0f760fe27a9869df9d57b002b82f71a0e8b989440aa31"
+ },
+ {
+ "changeId": "api-review-score-removal",
+ "title": "Remove legacy peerReview.score field from review completed payloads",
+ "ownerTeam": "peer-review-platform",
+ "surface": "webhook",
+ "state": "blocked",
+ "riskScore": 100,
+ "effectiveDate": "2026-06-15",
+ "breaking": true,
+ "compatibilityWindowDays": 29,
+ "affectedRoutes": [],
+ "affectedWebhookTypes": [
+ "review.completed.v1"
+ ],
+ "affectedConsumers": [
+ {
+ "integrationId": "canvas-outcomes-sync",
+ "institution": "Westlake Medical School",
+ "systemType": "lms",
+ "criticality": "standard",
+ "contactGroup": "learning-systems",
+ "readiness": "blocked",
+ "blockers": [
+ "breaking change without version negotiation"
+ ],
+ "warnings": [],
+ "migrationTicket": "WMS-1180",
+ "lastSuccessfulSandboxRun": "2026-05-12"
+ }
+ ],
+ "findings": [
+ {
+ "severity": "blocker",
+ "code": "breaking-diff",
+ "message": "Breaking changes must include a machine-readable diff for admins and integrators."
+ },
+ {
+ "severity": "blocker",
+ "code": "removed-fields",
+ "message": "Removed fields detected: peerReview.score."
+ },
+ {
+ "severity": "warning",
+ "code": "type-changes",
+ "message": "Type changes require fixture-backed migration notes: peerReview.reviewerCount:number -> string."
+ },
+ {
+ "severity": "warning",
+ "code": "new-required-fields",
+ "message": "New required fields should ship behind a new version or default compatibility mode."
+ },
+ {
+ "severity": "blocker",
+ "code": "short-deprecation-window",
+ "message": "Breaking change has 29 days of notice; policy requires 90."
+ },
+ {
+ "severity": "blocker",
+ "code": "short-parallel-run",
+ "message": "Breaking changes need a parallel run window long enough for pinned institutional clients."
+ },
+ {
+ "severity": "warning",
+ "code": "rollback-window",
+ "message": "Rollback plan covers 3 days; policy requires at least 14."
+ },
+ {
+ "severity": "warning",
+ "code": "event-envelope",
+ "message": "Webhook changes should keep a standard event envelope for routing and replay."
+ },
+ {
+ "severity": "blocker",
+ "code": "schema-version",
+ "message": "Webhook payloads need an explicit schema version field."
+ },
+ {
+ "severity": "blocker",
+ "code": "idempotency-key",
+ "message": "Webhook payloads need a stable idempotency key for enterprise replay safety."
+ },
+ {
+ "severity": "warning",
+ "code": "signature-version",
+ "message": "Webhook signing version should be explicit so consumers can rotate verifiers safely."
+ },
+ {
+ "severity": "blocker",
+ "code": "consumer-blockers",
+ "message": "1 affected integrations are blocked."
+ }
+ ],
+ "adminActions": [
+ "Block api-review-score-removal: Breaking changes must include a machine-readable diff for admins and integrators.",
+ "Block api-review-score-removal: Removed fields detected: peerReview.score.",
+ "Review api-review-score-removal: Type changes require fixture-backed migration notes: peerReview.reviewerCount:number -> string.",
+ "Review api-review-score-removal: New required fields should ship behind a new version or default compatibility mode.",
+ "Block api-review-score-removal: Breaking change has 29 days of notice; policy requires 90.",
+ "Block api-review-score-removal: Breaking changes need a parallel run window long enough for pinned institutional clients.",
+ "Review api-review-score-removal: Rollback plan covers 3 days; policy requires at least 14.",
+ "Review api-review-score-removal: Webhook changes should keep a standard event envelope for routing and replay.",
+ "Block api-review-score-removal: Webhook payloads need an explicit schema version field.",
+ "Block api-review-score-removal: Webhook payloads need a stable idempotency key for enterprise replay safety.",
+ "Review api-review-score-removal: Webhook signing version should be explicit so consumers can rotate verifiers safely.",
+ "Block api-review-score-removal: 1 affected integrations are blocked.",
+ "Open migration review for Westlake Medical School (canvas-outcomes-sync)."
+ ],
+ "migrationPlan": {
+ "changeId": "api-review-score-removal",
+ "noticeAudience": [
+ "learning-systems"
+ ],
+ "sandboxFixturePack": null,
+ "rollbackPlanDays": 3,
+ "parallelRunDays": 29,
+ "requiredBeforeRelease": [
+ "breaking-diff",
+ "removed-fields",
+ "short-deprecation-window",
+ "short-parallel-run",
+ "schema-version",
+ "idempotency-key",
+ "consumer-blockers"
+ ],
+ "suggestedOrder": [
+ "publish contract diff and migration notes",
+ "run sandbox fixtures against affected integrations",
+ "collect critical-consumer acknowledgement",
+ "ship parallel version",
+ "monitor signed webhook evidence"
+ ]
+ },
+ "evidenceDigest": "52a3c00315c9a5db1fd4cd30a7c3e3273c90b275aa410900dc0e9912e57d0134"
+ },
+ {
+ "changeId": "api-compliance-flag-pii",
+ "title": "Add restricted compliance flag webhook for funder reporting",
+ "ownerTeam": "compliance-ops",
+ "surface": "webhook",
+ "state": "hold",
+ "riskScore": 86,
+ "effectiveDate": "2026-07-20",
+ "breaking": false,
+ "compatibilityWindowDays": 90,
+ "affectedRoutes": [
+ "GET /api/compliance/flags"
+ ],
+ "affectedWebhookTypes": [
+ "compliance.flagged.v2"
+ ],
+ "affectedConsumers": [
+ {
+ "integrationId": "funder-reporter-nightly",
+ "institution": "Horizon Bioinformatics Institute",
+ "systemType": "funder_portal",
+ "criticality": "critical",
+ "contactGroup": "grants-ops",
+ "readiness": "blocked",
+ "blockers": [
+ "restricted-data integration missing DPA"
+ ],
+ "warnings": [
+ "sandbox evidence is stale or absent",
+ "notification not acknowledged",
+ "no migration ticket linked"
+ ],
+ "migrationTicket": null,
+ "lastSuccessfulSandboxRun": null
+ }
+ ],
+ "findings": [
+ {
+ "severity": "warning",
+ "code": "new-required-fields",
+ "message": "New required fields should ship behind a new version or default compatibility mode."
+ },
+ {
+ "severity": "blocker",
+ "code": "consumer-blockers",
+ "message": "1 affected integrations are blocked."
+ },
+ {
+ "severity": "warning",
+ "code": "sandbox-evidence",
+ "message": "1 affected integrations need fresh sandbox evidence."
+ },
+ {
+ "severity": "warning",
+ "code": "notice-ack",
+ "message": "1 affected integrations have not acknowledged migration notice."
+ },
+ {
+ "severity": "blocker",
+ "code": "restricted-data-dpa",
+ "message": "Restricted research data cannot flow to integrations without DPA evidence."
+ }
+ ],
+ "adminActions": [
+ "Review api-compliance-flag-pii: New required fields should ship behind a new version or default compatibility mode.",
+ "Block api-compliance-flag-pii: 1 affected integrations are blocked.",
+ "Review api-compliance-flag-pii: 1 affected integrations need fresh sandbox evidence.",
+ "Review api-compliance-flag-pii: 1 affected integrations have not acknowledged migration notice.",
+ "Block api-compliance-flag-pii: Restricted research data cannot flow to integrations without DPA evidence.",
+ "Open migration review for Horizon Bioinformatics Institute (funder-reporter-nightly)."
+ ],
+ "migrationPlan": {
+ "changeId": "api-compliance-flag-pii",
+ "noticeAudience": [
+ "grants-ops"
+ ],
+ "sandboxFixturePack": "compliance-v2-2026-05",
+ "rollbackPlanDays": 21,
+ "parallelRunDays": 90,
+ "requiredBeforeRelease": [
+ "consumer-blockers",
+ "restricted-data-dpa"
+ ],
+ "suggestedOrder": [
+ "publish contract diff and migration notes",
+ "run sandbox fixtures against affected integrations",
+ "collect critical-consumer acknowledgement",
+ "ship parallel version",
+ "monitor signed webhook evidence"
+ ]
+ },
+ "evidenceDigest": "b6d529654bab1df513308e97fd17297f4476afb80c08e768d54e28a72f94f908"
+ }
+ ],
+ "exportManifest": {
+ "manifestId": "api-change-governance:enterprise-api-governance-demo",
+ "generatedFor": [
+ "institutional-admin-dashboard",
+ "api-consumer-notice-pack",
+ "webhook-delivery-review",
+ "compliance-export-evidence"
+ ],
+ "readyChangeIds": [
+ "api-project-export-v3"
+ ],
+ "reviewChangeIds": [
+ "api-review-score-removal",
+ "api-compliance-flag-pii"
+ ],
+ "evidenceDigests": {
+ "api-project-export-v3": "a35bc73066e732b78bb0f760fe27a9869df9d57b002b82f71a0e8b989440aa31",
+ "api-review-score-removal": "52a3c00315c9a5db1fd4cd30a7c3e3273c90b275aa410900dc0e9912e57d0134",
+ "api-compliance-flag-pii": "b6d529654bab1df513308e97fd17297f4476afb80c08e768d54e28a72f94f908"
+ }
+ },
+ "webhookEvents": [
+ {
+ "specversion": "1.0",
+ "type": "scibase.enterprise.api_change.ready",
+ "source": "/enterprise/api-change-governance",
+ "id": "58c5aa3ea0976ce9f7870fa7bea7d71f",
+ "subject": "api-project-export-v3",
+ "time": "2026-09-01",
+ "datacontenttype": "application/json",
+ "data": {
+ "changeId": "api-project-export-v3",
+ "state": "ready",
+ "riskScore": 0,
+ "affectedConsumers": 1,
+ "blockers": [],
+ "evidenceDigest": "a35bc73066e732b78bb0f760fe27a9869df9d57b002b82f71a0e8b989440aa31"
+ },
+ "signatureKeyId": "synthetic-key-2026-05",
+ "signature": "9aa63e7edcd6aa9a141d40ad03748137f2f072e3734c427c0683468c8dd9b198"
+ },
+ {
+ "specversion": "1.0",
+ "type": "scibase.enterprise.api_change.blocked",
+ "source": "/enterprise/api-change-governance",
+ "id": "3b5a0bc61fd4bc580f4eabd57824f9c2",
+ "subject": "api-review-score-removal",
+ "time": "2026-06-15",
+ "datacontenttype": "application/json",
+ "data": {
+ "changeId": "api-review-score-removal",
+ "state": "blocked",
+ "riskScore": 100,
+ "affectedConsumers": 1,
+ "blockers": [
+ "breaking-diff",
+ "removed-fields",
+ "short-deprecation-window",
+ "short-parallel-run",
+ "schema-version",
+ "idempotency-key",
+ "consumer-blockers"
+ ],
+ "evidenceDigest": "52a3c00315c9a5db1fd4cd30a7c3e3273c90b275aa410900dc0e9912e57d0134"
+ },
+ "signatureKeyId": "synthetic-key-2026-05",
+ "signature": "30f52b1549c748a69d37241d8e2ddc95a40932f0f2d1d48fb6e9c1f0afbf4268"
+ },
+ {
+ "specversion": "1.0",
+ "type": "scibase.enterprise.api_change.hold",
+ "source": "/enterprise/api-change-governance",
+ "id": "48fe3ef563dd16d186da31cbd2cc1855",
+ "subject": "api-compliance-flag-pii",
+ "time": "2026-07-20",
+ "datacontenttype": "application/json",
+ "data": {
+ "changeId": "api-compliance-flag-pii",
+ "state": "hold",
+ "riskScore": 86,
+ "affectedConsumers": 1,
+ "blockers": [
+ "consumer-blockers",
+ "restricted-data-dpa"
+ ],
+ "evidenceDigest": "b6d529654bab1df513308e97fd17297f4476afb80c08e768d54e28a72f94f908"
+ },
+ "signatureKeyId": "synthetic-key-2026-05",
+ "signature": "b1fbb381c8766fc7e3ff91a0caf40e7c59ca6a0f8b4f900a0d5e2e90af96c334"
+ }
+ ],
+ "sanitizedInputEcho": {
+ "portfolioId": "enterprise-api-governance-demo",
+ "asOf": "2026-05-17T02:30:00.000Z",
+ "policy": {
+ "minimumBreakingDeprecationDays": 90,
+ "criticalConsumerNoticeDays": 60,
+ "minimumRollbackDays": 14,
+ "requireOpenApiDiff": true,
+ "requireWebhookSchemaVersion": true,
+ "requireSandboxEvidence": true,
+ "requireDpaForRestrictedExports": true
+ },
+ "integrations": [
+ {
+ "integrationId": "dspace-archive-prod",
+ "institution": "Northbridge University Library",
+ "systemType": "repository",
+ "criticality": "critical",
+ "contactGroup": "library-platforms",
+ "pinnedApiVersions": [
+ "v1"
+ ],
+ "subscribedWebhookTypes": [
+ "project.published.v1",
+ "export.completed.v1"
+ ],
+ "lastSuccessfulSandboxRun": "2026-05-05",
+ "supportsVersionNegotiation": true,
+ "notificationStatus": "acknowledged",
+ "migrationTicket": "NBUL-4421",
+ "hasDataProcessingAgreement": true
+ },
+ {
+ "integrationId": "canvas-outcomes-sync",
+ "institution": "Westlake Medical School",
+ "systemType": "lms",
+ "criticality": "standard",
+ "contactGroup": "learning-systems",
+ "pinnedApiVersions": [
+ "v2"
+ ],
+ "subscribedWebhookTypes": [
+ "review.completed.v1"
+ ],
+ "lastSuccessfulSandboxRun": "2026-05-12",
+ "supportsVersionNegotiation": false,
+ "notificationStatus": "acknowledged",
+ "migrationTicket": "WMS-1180",
+ "hasDataProcessingAgreement": true
+ },
+ {
+ "integrationId": "funder-reporter-nightly",
+ "institution": "Horizon Bioinformatics Institute",
+ "systemType": "funder_portal",
+ "criticality": "critical",
+ "contactGroup": "grants-ops",
+ "pinnedApiVersions": [
+ "v1"
+ ],
+ "subscribedWebhookTypes": [
+ "compliance.flagged.v1",
+ "export.completed.v1"
+ ],
+ "lastSuccessfulSandboxRun": null,
+ "supportsVersionNegotiation": false,
+ "notificationStatus": "missing",
+ "migrationTicket": null,
+ "hasDataProcessingAgreement": false
+ }
+ ],
+ "changes": [
+ {
+ "changeId": "api-project-export-v3",
+ "title": "Introduce v3 project export manifest with versioned metadata blocks",
+ "ownerTeam": "enterprise-integrations",
+ "surface": "rest_api",
+ "changeKind": "additive_version",
+ "currentVersion": "v2",
+ "proposedVersion": "v3",
+ "effectiveDate": "2026-09-01",
+ "affectedRoutes": [
+ "GET /api/projects/{projectId}/exports",
+ "POST /api/projects/{projectId}/exports"
+ ],
+ "affectedWebhookTypes": [],
+ "breaking": false,
+ "restrictedResearchData": false,
+ "openApiDiffAttached": true,
+ "rollbackPlanDays": 30,
+ "parallelRunDays": 120,
+ "schemaDiff": {
+ "removedFields": [],
+ "renamedFields": [],
+ "typeChanges": [],
+ "newRequiredFields": [],
+ "newOptionalFields": [
+ "metadata.versionHistory",
+ "metadata.repositoryTargets"
+ ]
+ },
+ "webhookEnvelope": {
+ "usesCloudEvents": true,
+ "schemaVersionField": "data.schemaVersion",
+ "idempotencyKeyField": "data.deliveryId",
+ "signatureVersion": "v2"
+ },
+ "sandboxEvidence": {
+ "fixturePack": "exports-v3-2026-05",
+ "passingIntegrations": [
+ "dspace-archive-prod",
+ "canvas-outcomes-sync"
+ ],
+ "failingIntegrations": []
+ }
+ },
+ {
+ "changeId": "api-review-score-removal",
+ "title": "Remove legacy peerReview.score field from review completed payloads",
+ "ownerTeam": "peer-review-platform",
+ "surface": "webhook",
+ "changeKind": "breaking_removal",
+ "currentVersion": "review.completed.v1",
+ "proposedVersion": "review.completed.v2",
+ "effectiveDate": "2026-06-15",
+ "affectedRoutes": [],
+ "affectedWebhookTypes": [
+ "review.completed.v1"
+ ],
+ "breaking": true,
+ "restrictedResearchData": false,
+ "openApiDiffAttached": false,
+ "rollbackPlanDays": 3,
+ "parallelRunDays": 29,
+ "schemaDiff": {
+ "removedFields": [
+ "peerReview.score"
+ ],
+ "renamedFields": [
+ "peerReview.rubric -> peerReview.rubricBreakdown"
+ ],
+ "typeChanges": [
+ "peerReview.reviewerCount:number -> string"
+ ],
+ "newRequiredFields": [
+ "peerReview.decisionCode"
+ ],
+ "newOptionalFields": []
+ },
+ "webhookEnvelope": {
+ "usesCloudEvents": false,
+ "schemaVersionField": null,
+ "idempotencyKeyField": null,
+ "signatureVersion": null
+ },
+ "sandboxEvidence": {
+ "fixturePack": null,
+ "passingIntegrations": [],
+ "failingIntegrations": [
+ "canvas-outcomes-sync"
+ ]
+ }
+ },
+ {
+ "changeId": "api-compliance-flag-pii",
+ "title": "Add restricted compliance flag webhook for funder reporting",
+ "ownerTeam": "compliance-ops",
+ "surface": "webhook",
+ "changeKind": "new_event",
+ "currentVersion": null,
+ "proposedVersion": "compliance.flagged.v2",
+ "effectiveDate": "2026-07-20",
+ "affectedRoutes": [
+ "GET /api/compliance/flags"
+ ],
+ "affectedWebhookTypes": [
+ "compliance.flagged.v2"
+ ],
+ "breaking": false,
+ "restrictedResearchData": true,
+ "openApiDiffAttached": true,
+ "rollbackPlanDays": 21,
+ "parallelRunDays": 90,
+ "schemaDiff": {
+ "removedFields": [],
+ "renamedFields": [],
+ "typeChanges": [],
+ "newRequiredFields": [
+ "flag.category",
+ "flag.evidenceDigest"
+ ],
+ "newOptionalFields": [
+ "flag.funderMandateId"
+ ]
+ },
+ "webhookEnvelope": {
+ "usesCloudEvents": true,
+ "schemaVersionField": "data.schemaVersion",
+ "idempotencyKeyField": "data.deliveryId",
+ "signatureVersion": "v2"
+ },
+ "sandboxEvidence": {
+ "fixturePack": "compliance-v2-2026-05",
+ "passingIntegrations": [
+ "dspace-archive-prod"
+ ],
+ "failingIntegrations": [
+ "funder-reporter-nightly"
+ ]
+ }
+ }
+ ],
+ "signingKeyId": "synthetic-key-2026-05"
+ },
+ "auditDigest": "793bd6d911b7dfb2499178e3d595bf52b72796c83c3544a6a889df7b6848aeed"
+}
diff --git a/enterprise-api-change-governance/docs/requirement-map.md b/enterprise-api-change-governance/docs/requirement-map.md
new file mode 100644
index 0000000..b66c188
--- /dev/null
+++ b/enterprise-api-change-governance/docs/requirement-map.md
@@ -0,0 +1,29 @@
+# Requirement Map
+
+## Admin Dashboards
+
+- `buildApiChangeGovernance()` emits `dashboard.totalChanges`, `dashboard.byState`, blocked-change counts, critical integration impacts, consumer blocks, and highest risk score.
+- Each reviewed change includes a state, risk score, findings, and admin actions suitable for institutional admin queues.
+
+## API & Webhooks
+
+- REST API changes are gated by route lists, current/proposed versions, OpenAPI-style diff evidence, deprecation windows, rollback plans, and parallel run windows.
+- Webhook changes are evaluated for event envelope consistency, schema version fields, idempotency keys, and signature-version evidence.
+- `webhookEvents` emits signed CloudEvents-style governance events for downstream enterprise systems.
+
+## Export Pipelines
+
+- `exportManifest` groups ready vs. review-required changes and includes evidence digests for compliance export packets.
+- Restricted research data changes are blocked when affected integrations do not have DPA evidence.
+
+## Enterprise Governance
+
+- Critical consumers require acknowledged notices before release.
+- Sandbox fixture evidence is tracked for each affected institutional integration.
+- Migration plans include notice audience, fixture packs, rollback windows, parallel run windows, and release sequencing.
+
+## Demo And Tests
+
+- `data/sample-change-plan.json` includes additive, breaking, and restricted-data rollout examples.
+- `test/api-change-governance.test.js` covers ready, blocked, restricted-data hold, deterministic digest, signed events, and redaction behavior.
+- `scripts/demo.js` prints a terminal summary and writes demo artifacts.
diff --git a/enterprise-api-change-governance/package.json b/enterprise-api-change-governance/package.json
new file mode 100644
index 0000000..0ab6c73
--- /dev/null
+++ b/enterprise-api-change-governance/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "enterprise-api-change-governance",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Enterprise API and webhook contract change governance for institutional SCIBASE integrations.",
+ "scripts": {
+ "check": "node --check src/api-change-governance.js && node --check scripts/demo.js && node --check test/api-change-governance.test.js",
+ "test": "node --test",
+ "demo": "node scripts/demo.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "license": "MIT"
+}
diff --git a/enterprise-api-change-governance/scripts/demo.js b/enterprise-api-change-governance/scripts/demo.js
new file mode 100644
index 0000000..73b6a67
--- /dev/null
+++ b/enterprise-api-change-governance/scripts/demo.js
@@ -0,0 +1,109 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { buildApiChangeGovernance } from "../src/api-change-governance.js";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const rootDir = join(__dirname, "..");
+const sample = JSON.parse(await readFile(join(rootDir, "data", "sample-change-plan.json"), "utf8"));
+const report = buildApiChangeGovernance(sample, { generatedAt: sample.asOf });
+
+await mkdir(join(rootDir, "docs"), { recursive: true });
+await writeFile(join(rootDir, "docs", "demo.svg"), renderSvg(report));
+await writeFile(join(rootDir, "docs", "governance-report.json"), `${JSON.stringify(report, null, 2)}\n`);
+
+console.log("Enterprise API Change Governance");
+console.log(`Portfolio: ${report.portfolioId}`);
+console.log(`Changes: ${report.dashboard.totalChanges}`);
+console.log(`State distribution: ${JSON.stringify(report.dashboard.byState)}`);
+console.log(`Critical integrations impacted: ${report.dashboard.criticalIntegrationsImpacted}`);
+console.log(`Highest risk score: ${report.dashboard.highestRiskScore}`);
+console.log(`Audit digest: ${report.auditDigest}`);
+
+for (const review of report.reviews) {
+ console.log("");
+ console.log(`${review.state.toUpperCase()} ${review.riskScore} - ${review.title}`);
+ console.log(`Affected consumers: ${review.affectedConsumers.length}`);
+ console.log(`Evidence digest: ${review.evidenceDigest}`);
+ for (const action of review.adminActions.slice(0, 3)) {
+ console.log(`- ${action}`);
+ }
+}
+
+function renderSvg(report) {
+ const rows = report.reviews.map((review, index) => {
+ const y = 232 + index * 112;
+ const color = stateColor(review.state);
+ const findings = review.findings
+ .slice(0, 2)
+ .map((finding) => finding.code)
+ .join(", ") || "no blockers";
+ return `
+
+
+
+ ${escapeXml(review.title)}
+ ${review.changeId} - ${review.affectedConsumers.length} affected integrations - ${escapeXml(findings)}
+ ${review.state.toUpperCase()}
+ risk ${review.riskScore}
+ `;
+ }).join("");
+
+ return `
+
+`;
+}
+
+function stateColor(state) {
+ if (state === "ready") return "#0e9f6e";
+ if (state === "watch") return "#d97706";
+ if (state === "hold") return "#c2410c";
+ return "#c81e1e";
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
diff --git a/enterprise-api-change-governance/src/api-change-governance.js b/enterprise-api-change-governance/src/api-change-governance.js
new file mode 100644
index 0000000..85ef534
--- /dev/null
+++ b/enterprise-api-change-governance/src/api-change-governance.js
@@ -0,0 +1,443 @@
+import { createHash, createHmac } from "node:crypto";
+
+const DEFAULT_POLICY = Object.freeze({
+ minimumBreakingDeprecationDays: 90,
+ criticalConsumerNoticeDays: 60,
+ minimumRollbackDays: 14,
+ requireOpenApiDiff: true,
+ requireWebhookSchemaVersion: true,
+ requireSandboxEvidence: true,
+ requireDpaForRestrictedExports: true
+});
+
+const STATE_ORDER = ["ready", "watch", "hold", "blocked"];
+
+const SECRET_KEYS = new Set([
+ "apiKey",
+ "clientSecret",
+ "privateToken",
+ "signingKey",
+ "taxId"
+]);
+
+export function buildApiChangeGovernance(input, options = {}) {
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
+ const policy = { ...DEFAULT_POLICY, ...(input?.policy ?? {}) };
+ const asOf = new Date(input?.asOf ?? generatedAt);
+ const integrations = Array.isArray(input?.integrations) ? input.integrations : [];
+ const changes = Array.isArray(input?.changes) ? input.changes : [];
+
+ const reviews = changes.map((change) => evaluateChange(change, integrations, policy, asOf));
+ const dashboard = buildDashboard(reviews);
+ const exportManifest = buildExportManifest(input?.portfolioId, reviews);
+ const webhookEvents = reviews.map((review) => buildWebhookEvent(review, input?.signingKeyId));
+ const sanitizedInputEcho = redactSensitiveFields(input);
+ const digestPayload = {
+ portfolioId: input?.portfolioId ?? null,
+ asOf: input?.asOf ?? null,
+ dashboard,
+ exportManifest,
+ reviews: reviews.map(toDigestableReview),
+ webhookEvents: webhookEvents.map(({ signature, ...event }) => event),
+ sanitizedInputEcho
+ };
+
+ return {
+ generatedAt,
+ portfolioId: input?.portfolioId ?? "unknown-portfolio",
+ dashboard,
+ reviews,
+ exportManifest,
+ webhookEvents,
+ sanitizedInputEcho,
+ auditDigest: sha256(stableStringify(digestPayload))
+ };
+}
+
+export function evaluateChange(change, integrations, policy = DEFAULT_POLICY, asOf = new Date()) {
+ const affectedConsumers = buildAffectedConsumers(change, integrations, policy, asOf);
+ const findings = [
+ ...evaluateContractEvidence(change, policy),
+ ...evaluateDeprecation(change, policy, asOf),
+ ...evaluateWebhookEnvelope(change, policy),
+ ...evaluateConsumerReadiness(change, affectedConsumers, policy),
+ ...evaluateRestrictedData(change, affectedConsumers, policy)
+ ];
+ const state = stateFromFindings(findings);
+ const adminActions = buildAdminActions(change, findings, affectedConsumers);
+
+ return {
+ changeId: change.changeId,
+ title: change.title,
+ ownerTeam: change.ownerTeam ?? "unassigned",
+ surface: change.surface ?? "unknown",
+ state,
+ riskScore: riskScore(findings, affectedConsumers),
+ effectiveDate: change.effectiveDate ?? null,
+ breaking: Boolean(change.breaking),
+ compatibilityWindowDays: numberOrZero(change.parallelRunDays),
+ affectedRoutes: Array.isArray(change.affectedRoutes) ? change.affectedRoutes : [],
+ affectedWebhookTypes: Array.isArray(change.affectedWebhookTypes) ? change.affectedWebhookTypes : [],
+ affectedConsumers,
+ findings,
+ adminActions,
+ migrationPlan: buildMigrationPlan(change, affectedConsumers, findings),
+ evidenceDigest: sha256(stableStringify({
+ changeId: change.changeId,
+ state,
+ findings,
+ affectedConsumers: affectedConsumers.map(toDigestableConsumer)
+ }))
+ };
+}
+
+export function redactSensitiveFields(value) {
+ if (Array.isArray(value)) {
+ return value.map(redactSensitiveFields);
+ }
+
+ if (!value || typeof value !== "object") {
+ return value;
+ }
+
+ return Object.fromEntries(
+ Object.entries(value).map(([key, entry]) => [
+ key,
+ SECRET_KEYS.has(key) ? "[redacted]" : redactSensitiveFields(entry)
+ ])
+ );
+}
+
+export function stableStringify(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(",")}]`;
+ }
+
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
+ .join(",")}}`;
+ }
+
+ return JSON.stringify(value);
+}
+
+function buildAffectedConsumers(change, integrations, policy, asOf) {
+ return integrations
+ .filter((integration) => isIntegrationAffected(change, integration))
+ .map((integration) => {
+ const sandboxAgeDays = ageInDays(integration.lastSuccessfulSandboxRun, asOf);
+ const lacksFreshSandbox = sandboxAgeDays == null || sandboxAgeDays > 30;
+ const needsCriticalNotice = integration.criticality === "critical"
+ && daysUntil(change.effectiveDate, asOf) < policy.criticalConsumerNoticeDays;
+ const notificationMissing = integration.notificationStatus !== "acknowledged";
+ const hasVersionRisk = Boolean(change.breaking)
+ && !integration.supportsVersionNegotiation
+ && !integration.pinnedApiVersions?.includes(change.proposedVersion);
+ const hasDpaGap = Boolean(change.restrictedResearchData) && !integration.hasDataProcessingAgreement;
+ const blockers = [
+ hasVersionRisk ? "breaking change without version negotiation" : null,
+ hasDpaGap ? "restricted-data integration missing DPA" : null,
+ needsCriticalNotice && notificationMissing ? "critical consumer lacks acknowledged notice" : null
+ ].filter(Boolean);
+ const warnings = [
+ lacksFreshSandbox ? "sandbox evidence is stale or absent" : null,
+ notificationMissing ? "notification not acknowledged" : null,
+ !integration.migrationTicket ? "no migration ticket linked" : null
+ ].filter(Boolean);
+
+ return {
+ integrationId: integration.integrationId,
+ institution: integration.institution,
+ systemType: integration.systemType,
+ criticality: integration.criticality ?? "standard",
+ contactGroup: integration.contactGroup ?? "unknown",
+ readiness: blockers.length > 0 ? "blocked" : warnings.length > 0 ? "needs-review" : "ready",
+ blockers,
+ warnings,
+ migrationTicket: integration.migrationTicket ?? null,
+ lastSuccessfulSandboxRun: integration.lastSuccessfulSandboxRun ?? null
+ };
+ });
+}
+
+function isIntegrationAffected(change, integration) {
+ const versions = integration.pinnedApiVersions ?? [];
+ const webhooks = integration.subscribedWebhookTypes ?? [];
+ const routeAffected = change.currentVersion && versions.includes(change.currentVersion);
+ const proposedVersionAffected = change.proposedVersion && versions.includes(change.proposedVersion);
+ const webhookAffected = (change.affectedWebhookTypes ?? []).some((type) => webhooks.includes(type));
+ const newWebhookForSameFamily = (change.affectedWebhookTypes ?? []).some((type) => {
+ const family = type.split(".").slice(0, -1).join(".");
+ return webhooks.some((subscribed) => subscribed.startsWith(`${family}.`));
+ });
+
+ return Boolean(routeAffected || proposedVersionAffected || webhookAffected || newWebhookForSameFamily);
+}
+
+function evaluateContractEvidence(change, policy) {
+ const findings = [];
+ if (policy.requireOpenApiDiff && change.surface !== "webhook" && !change.openApiDiffAttached) {
+ findings.push(finding("blocker", "contract-diff", "OpenAPI or route contract diff is required before enterprise review."));
+ }
+
+ if (change.breaking && !change.openApiDiffAttached) {
+ findings.push(finding("blocker", "breaking-diff", "Breaking changes must include a machine-readable diff for admins and integrators."));
+ }
+
+ const diff = change.schemaDiff ?? {};
+ if ((diff.removedFields ?? []).length > 0) {
+ findings.push(finding("blocker", "removed-fields", `Removed fields detected: ${diff.removedFields.join(", ")}.`));
+ }
+ if ((diff.typeChanges ?? []).length > 0) {
+ findings.push(finding("warning", "type-changes", `Type changes require fixture-backed migration notes: ${diff.typeChanges.join(", ")}.`));
+ }
+ if ((diff.newRequiredFields ?? []).length > 0 && change.changeKind !== "additive_version") {
+ findings.push(finding("warning", "new-required-fields", "New required fields should ship behind a new version or default compatibility mode."));
+ }
+
+ return findings;
+}
+
+function evaluateDeprecation(change, policy, asOf) {
+ const findings = [];
+ const deprecationDays = daysUntil(change.effectiveDate, asOf);
+ const parallelRunDays = numberOrZero(change.parallelRunDays);
+ const rollbackPlanDays = numberOrZero(change.rollbackPlanDays);
+
+ if (change.breaking && deprecationDays < policy.minimumBreakingDeprecationDays) {
+ findings.push(finding("blocker", "short-deprecation-window", `Breaking change has ${deprecationDays} days of notice; policy requires ${policy.minimumBreakingDeprecationDays}.`));
+ }
+ if (change.breaking && parallelRunDays < policy.minimumBreakingDeprecationDays) {
+ findings.push(finding("blocker", "short-parallel-run", "Breaking changes need a parallel run window long enough for pinned institutional clients."));
+ }
+ if (rollbackPlanDays < policy.minimumRollbackDays) {
+ findings.push(finding("warning", "rollback-window", `Rollback plan covers ${rollbackPlanDays} days; policy requires at least ${policy.minimumRollbackDays}.`));
+ }
+
+ return findings;
+}
+
+function evaluateWebhookEnvelope(change, policy) {
+ const findings = [];
+ const envelope = change.webhookEnvelope ?? {};
+ const touchesWebhooks = (change.affectedWebhookTypes ?? []).length > 0 || change.surface === "webhook";
+
+ if (!touchesWebhooks) {
+ return findings;
+ }
+
+ if (!envelope.usesCloudEvents) {
+ findings.push(finding("warning", "event-envelope", "Webhook changes should keep a standard event envelope for routing and replay."));
+ }
+ if (policy.requireWebhookSchemaVersion && !envelope.schemaVersionField) {
+ findings.push(finding("blocker", "schema-version", "Webhook payloads need an explicit schema version field."));
+ }
+ if (!envelope.idempotencyKeyField) {
+ findings.push(finding("blocker", "idempotency-key", "Webhook payloads need a stable idempotency key for enterprise replay safety."));
+ }
+ if (!envelope.signatureVersion) {
+ findings.push(finding("warning", "signature-version", "Webhook signing version should be explicit so consumers can rotate verifiers safely."));
+ }
+
+ return findings;
+}
+
+function evaluateConsumerReadiness(change, affectedConsumers, policy) {
+ const findings = [];
+ const blockedConsumers = affectedConsumers.filter((consumer) => consumer.readiness === "blocked");
+ const staleSandboxConsumers = affectedConsumers.filter((consumer) =>
+ consumer.warnings.includes("sandbox evidence is stale or absent"));
+ const missingNoticeConsumers = affectedConsumers.filter((consumer) =>
+ consumer.warnings.includes("notification not acknowledged"));
+
+ if (blockedConsumers.length > 0) {
+ findings.push(finding("blocker", "consumer-blockers", `${blockedConsumers.length} affected integrations are blocked.`));
+ }
+ if (policy.requireSandboxEvidence && staleSandboxConsumers.length > 0) {
+ findings.push(finding("warning", "sandbox-evidence", `${staleSandboxConsumers.length} affected integrations need fresh sandbox evidence.`));
+ }
+ if (missingNoticeConsumers.length > 0) {
+ findings.push(finding("warning", "notice-ack", `${missingNoticeConsumers.length} affected integrations have not acknowledged migration notice.`));
+ }
+
+ return findings;
+}
+
+function evaluateRestrictedData(change, affectedConsumers, policy) {
+ if (!policy.requireDpaForRestrictedExports || !change.restrictedResearchData) {
+ return [];
+ }
+
+ const missingDpa = affectedConsumers.filter((consumer) =>
+ consumer.blockers.includes("restricted-data integration missing DPA"));
+ if (missingDpa.length === 0) {
+ return [];
+ }
+
+ return [finding("blocker", "restricted-data-dpa", "Restricted research data cannot flow to integrations without DPA evidence.")];
+}
+
+function buildDashboard(reviews) {
+ const byState = Object.fromEntries(STATE_ORDER.map((state) => [state, 0]));
+ for (const review of reviews) {
+ byState[review.state] += 1;
+ }
+
+ const affectedConsumers = reviews.flatMap((review) => review.affectedConsumers);
+ return {
+ totalChanges: reviews.length,
+ byState,
+ blockedChanges: reviews.filter((review) => review.state === "blocked").length,
+ holdOrBlockedChanges: reviews.filter((review) => ["hold", "blocked"].includes(review.state)).length,
+ criticalIntegrationsImpacted: affectedConsumers.filter((consumer) => consumer.criticality === "critical").length,
+ consumerBlocks: affectedConsumers.filter((consumer) => consumer.readiness === "blocked").length,
+ uniqueInstitutionsImpacted: new Set(affectedConsumers.map((consumer) => consumer.institution)).size,
+ highestRiskScore: reviews.reduce((highest, review) => Math.max(highest, review.riskScore), 0)
+ };
+}
+
+function buildExportManifest(portfolioId, reviews) {
+ return {
+ manifestId: `api-change-governance:${portfolioId ?? "unknown"}`,
+ generatedFor: [
+ "institutional-admin-dashboard",
+ "api-consumer-notice-pack",
+ "webhook-delivery-review",
+ "compliance-export-evidence"
+ ],
+ readyChangeIds: reviews.filter((review) => review.state === "ready").map((review) => review.changeId),
+ reviewChangeIds: reviews.filter((review) => review.state !== "ready").map((review) => review.changeId),
+ evidenceDigests: Object.fromEntries(reviews.map((review) => [review.changeId, review.evidenceDigest]))
+ };
+}
+
+function buildWebhookEvent(review, signingKeyId) {
+ const event = {
+ specversion: "1.0",
+ type: `scibase.enterprise.api_change.${review.state}`,
+ source: "/enterprise/api-change-governance",
+ id: sha256(`${review.changeId}:${review.state}:${review.evidenceDigest}`).slice(0, 32),
+ subject: review.changeId,
+ time: review.effectiveDate,
+ datacontenttype: "application/json",
+ data: {
+ changeId: review.changeId,
+ state: review.state,
+ riskScore: review.riskScore,
+ affectedConsumers: review.affectedConsumers.length,
+ blockers: review.findings.filter((item) => item.severity === "blocker").map((item) => item.code),
+ evidenceDigest: review.evidenceDigest
+ }
+ };
+ const signaturePayload = stableStringify(event);
+ return {
+ ...event,
+ signatureKeyId: signingKeyId ?? "synthetic-signing-key",
+ signature: createHmac("sha256", signingKeyId ?? "synthetic-signing-key")
+ .update(signaturePayload)
+ .digest("hex")
+ };
+}
+
+function buildAdminActions(change, findings, affectedConsumers) {
+ const actions = findings.map((item) => {
+ if (item.severity === "blocker") return `Block ${change.changeId}: ${item.message}`;
+ return `Review ${change.changeId}: ${item.message}`;
+ });
+
+ for (const consumer of affectedConsumers) {
+ if (consumer.readiness === "blocked") {
+ actions.push(`Open migration review for ${consumer.institution} (${consumer.integrationId}).`);
+ } else if (consumer.readiness === "needs-review") {
+ actions.push(`Request sandbox acknowledgement from ${consumer.contactGroup} for ${consumer.integrationId}.`);
+ }
+ }
+
+ if (actions.length === 0) {
+ actions.push(`Approve ${change.changeId} for enterprise rollout with monitored release notes.`);
+ }
+
+ return [...new Set(actions)];
+}
+
+function buildMigrationPlan(change, affectedConsumers, findings) {
+ const blockers = findings.filter((item) => item.severity === "blocker").map((item) => item.code);
+ return {
+ changeId: change.changeId,
+ noticeAudience: affectedConsumers.map((consumer) => consumer.contactGroup),
+ sandboxFixturePack: change.sandboxEvidence?.fixturePack ?? null,
+ rollbackPlanDays: numberOrZero(change.rollbackPlanDays),
+ parallelRunDays: numberOrZero(change.parallelRunDays),
+ requiredBeforeRelease: blockers.length > 0 ? blockers : ["publish release note", "monitor webhook deliveries"],
+ suggestedOrder: [
+ "publish contract diff and migration notes",
+ "run sandbox fixtures against affected integrations",
+ "collect critical-consumer acknowledgement",
+ "ship parallel version",
+ "monitor signed webhook evidence"
+ ]
+ };
+}
+
+function stateFromFindings(findings) {
+ const blockers = findings.filter((item) => item.severity === "blocker").length;
+ const warnings = findings.filter((item) => item.severity === "warning").length;
+ if (blockers >= 3) return "blocked";
+ if (blockers > 0) return "hold";
+ if (warnings > 0) return "watch";
+ return "ready";
+}
+
+function riskScore(findings, affectedConsumers) {
+ const blockerScore = findings.filter((item) => item.severity === "blocker").length * 22;
+ const warningScore = findings.filter((item) => item.severity === "warning").length * 8;
+ const criticalScore = affectedConsumers.filter((consumer) => consumer.criticality === "critical").length * 6;
+ const blockedConsumerScore = affectedConsumers.filter((consumer) => consumer.readiness === "blocked").length * 12;
+ return Math.min(100, blockerScore + warningScore + criticalScore + blockedConsumerScore);
+}
+
+function finding(severity, code, message) {
+ return { severity, code, message };
+}
+
+function daysUntil(dateString, asOf) {
+ if (!dateString) return 0;
+ const date = new Date(`${dateString}T00:00:00.000Z`);
+ return Math.max(0, Math.ceil((date.getTime() - asOf.getTime()) / 86_400_000));
+}
+
+function ageInDays(dateString, asOf) {
+ if (!dateString) return null;
+ const date = new Date(`${dateString}T00:00:00.000Z`);
+ return Math.max(0, Math.floor((asOf.getTime() - date.getTime()) / 86_400_000));
+}
+
+function numberOrZero(value) {
+ return Number.isFinite(Number(value)) ? Number(value) : 0;
+}
+
+function toDigestableReview(review) {
+ return {
+ changeId: review.changeId,
+ state: review.state,
+ riskScore: review.riskScore,
+ findings: review.findings,
+ affectedConsumers: review.affectedConsumers.map(toDigestableConsumer),
+ evidenceDigest: review.evidenceDigest
+ };
+}
+
+function toDigestableConsumer(consumer) {
+ return {
+ integrationId: consumer.integrationId,
+ readiness: consumer.readiness,
+ blockers: consumer.blockers,
+ warnings: consumer.warnings
+ };
+}
+
+function sha256(value) {
+ return createHash("sha256").update(value).digest("hex");
+}
diff --git a/enterprise-api-change-governance/test/api-change-governance.test.js b/enterprise-api-change-governance/test/api-change-governance.test.js
new file mode 100644
index 0000000..2c82a81
--- /dev/null
+++ b/enterprise-api-change-governance/test/api-change-governance.test.js
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import sample from "../data/sample-change-plan.json" with { type: "json" };
+import {
+ buildApiChangeGovernance,
+ evaluateChange,
+ redactSensitiveFields,
+ stableStringify
+} from "../src/api-change-governance.js";
+
+describe("enterprise API change governance", () => {
+ it("approves additive versioned API changes with fresh sandbox evidence", () => {
+ const additive = sample.changes[0];
+ const review = evaluateChange(additive, sample.integrations, sample.policy, new Date(sample.asOf));
+
+ assert.equal(review.state, "ready");
+ assert.equal(review.breaking, false);
+ assert.ok(review.riskScore < 20);
+ assert.equal(review.findings.length, 0);
+ assert.ok(review.adminActions.some((action) => action.includes("Approve")));
+ });
+
+ it("blocks breaking webhook removals without versioning, idempotency, or adequate notice", () => {
+ const breaking = sample.changes[1];
+ const review = evaluateChange(breaking, sample.integrations, sample.policy, new Date(sample.asOf));
+ const codes = review.findings.map((finding) => finding.code);
+
+ assert.equal(review.state, "blocked");
+ assert.ok(codes.includes("breaking-diff"));
+ assert.ok(codes.includes("removed-fields"));
+ assert.ok(codes.includes("short-deprecation-window"));
+ assert.ok(codes.includes("schema-version"));
+ assert.ok(codes.includes("idempotency-key"));
+ });
+
+ it("detects restricted-data DPA and sandbox gaps before funder webhook rollout", () => {
+ const restricted = sample.changes[2];
+ const review = evaluateChange(restricted, sample.integrations, sample.policy, new Date(sample.asOf));
+ const funder = review.affectedConsumers.find((consumer) => consumer.integrationId === "funder-reporter-nightly");
+
+ assert.equal(review.state, "hold");
+ assert.equal(funder.readiness, "blocked");
+ assert.ok(funder.blockers.includes("restricted-data integration missing DPA"));
+ assert.ok(review.adminActions.some((action) => action.includes("Open migration review")));
+ });
+
+ it("builds deterministic dashboard, manifest, webhook events, and digest", () => {
+ const first = buildApiChangeGovernance(sample, { generatedAt: sample.asOf });
+ const second = buildApiChangeGovernance(sample, { generatedAt: "2035-01-01T00:00:00.000Z" });
+
+ assert.equal(first.auditDigest, second.auditDigest);
+ assert.equal(first.dashboard.totalChanges, 3);
+ assert.deepEqual(first.dashboard.byState, { ready: 1, watch: 0, hold: 1, blocked: 1 });
+ assert.equal(first.exportManifest.readyChangeIds.includes("api-project-export-v3"), true);
+ assert.equal(first.webhookEvents.length, 3);
+ assert.match(first.webhookEvents[0].signature, /^[a-f0-9]{64}$/);
+ });
+
+ it("redacts private credentials before echoing input into review artifacts", () => {
+ const redacted = redactSensitiveFields({
+ apiKey: "secret-api-key",
+ nested: { signingKey: "secret-hmac-key", safe: "visible" }
+ });
+ const serialized = stableStringify(redacted);
+
+ assert.equal(serialized.includes("secret-api-key"), false);
+ assert.equal(serialized.includes("secret-hmac-key"), false);
+ assert.equal(serialized.includes("[redacted]"), true);
+ assert.equal(serialized.includes("visible"), true);
+ });
+});