From 2ecd7dc41dd2b8bcb7063d598d175eca255feaf9 Mon Sep 17 00:00:00 2001 From: Colonel-Courtz Date: Sat, 16 May 2026 13:11:40 +0000 Subject: [PATCH] feat: add enterprise SLA uptime monitor --- README.md | 4 + enterprise-sla-uptime-monitor/README.md | 46 +++ .../data/sample-sla-input.json | 126 ++++++++ enterprise-sla-uptime-monitor/docs/demo.svg | 30 ++ .../docs/requirement-map.md | 19 ++ enterprise-sla-uptime-monitor/package.json | 13 + enterprise-sla-uptime-monitor/scripts/demo.js | 23 ++ .../src/sla-uptime-monitor.js | 288 ++++++++++++++++++ .../test/sla-uptime-monitor.test.js | 138 +++++++++ 9 files changed, 687 insertions(+) create mode 100644 enterprise-sla-uptime-monitor/README.md create mode 100644 enterprise-sla-uptime-monitor/data/sample-sla-input.json create mode 100644 enterprise-sla-uptime-monitor/docs/demo.svg create mode 100644 enterprise-sla-uptime-monitor/docs/requirement-map.md create mode 100644 enterprise-sla-uptime-monitor/package.json create mode 100644 enterprise-sla-uptime-monitor/scripts/demo.js create mode 100644 enterprise-sla-uptime-monitor/src/sla-uptime-monitor.js create mode 100644 enterprise-sla-uptime-monitor/test/sla-uptime-monitor.test.js diff --git a/README.md b/README.md index d338cf6..5095120 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Enterprise tooling slices + +- [`enterprise-sla-uptime-monitor`](enterprise-sla-uptime-monitor/) — deterministic SLA, uptime, webhook reliability, and export-evidence guardrail for SCIBASE Enterprise Tooling issue #19. diff --git a/enterprise-sla-uptime-monitor/README.md b/enterprise-sla-uptime-monitor/README.md new file mode 100644 index 0000000..77fc813 --- /dev/null +++ b/enterprise-sla-uptime-monitor/README.md @@ -0,0 +1,46 @@ +# Enterprise SLA Uptime Monitor + +A self-contained Enterprise Tooling slice for SCIBASE issue #19. It converts synthetic uptime checks, incident records, webhook delivery outcomes, and export jobs into a deterministic admin review packet for institutional reliability governance. + +This module is intentionally narrow: it does **not** replace the existing admin-dashboard, export, trust-center, data-residency, identity-drift, legal-hold, webhook-replay, or grant-compliance submissions. It focuses only on service-level commitments, error-budget posture, webhook reliability notices, and whether SLA evidence can be exported safely. + +## What it does + +- Computes per-service uptime against tenant SLA targets. +- Tracks unplanned downtime, planned maintenance, MTTR, incident counts, and error-budget remaining minutes. +- Flags `breach`, `watch`, and `within_sla` service states. +- Reviews webhook destinations with elevated failure rates or dependencies on breached services. +- Blocks evidence exports that would report on a breached service before an admin review. +- Produces a SHA-256 evidence digest over the deterministic packet for audit trails. + +## Files + +- `src/sla-uptime-monitor.js` — local evaluator and helper functions. +- `data/sample-sla-input.json` — synthetic tenant/service/incidents/webhook/export data. +- `scripts/demo.js` — terminal demo that prints the admin metrics and action queue. +- `test/sla-uptime-monitor.test.js` — Node test suite covering breach/watch/ready decisions and deterministic digests. +- `docs/requirement-map.md` — mapping back to issue #19 Enterprise Tooling requirements. +- `docs/demo.svg` — static dashboard-style preview. + +## Run locally + +```bash +npm run check +npm test +npm run demo +``` + +Expected demo summary: + +```text +SCIBASE Enterprise SLA Uptime Monitor +Services: 3 +Breaches: 1 +Watch: 1 +Blocked exports: 1 +Queued actions: 4 +``` + +## Safety and privacy + +The module uses only synthetic data and Node built-ins. It performs no network calls, requires no account credentials, and stores no secrets. It is suitable for offline review and CI. diff --git a/enterprise-sla-uptime-monitor/data/sample-sla-input.json b/enterprise-sla-uptime-monitor/data/sample-sla-input.json new file mode 100644 index 0000000..b3a0a1a --- /dev/null +++ b/enterprise-sla-uptime-monitor/data/sample-sla-input.json @@ -0,0 +1,126 @@ +{ + "generatedAt": "2026-05-16T12:00:00Z", + "windowMinutes": 43200, + "tenants": [ + { + "id": "northbridge-university", + "name": "Northbridge University Research Office", + "contractTier": "enterprise", + "defaultSlaTarget": 0.995 + }, + { + "id": "helios-rd", + "name": "Helios R&D Lab", + "contractTier": "enterprise-plus", + "defaultSlaTarget": 0.999 + } + ], + "services": [ + { + "id": "api-core", + "name": "SCIBASE API Core", + "tenantId": "northbridge-university", + "slaTarget": 0.995, + "observedChecks": 8640, + "failedChecks": 31, + "plannedMaintenanceMinutes": 20, + "unplannedDowntimeMinutes": 265, + "owner": "platform-ops" + }, + { + "id": "webhook-dispatcher", + "name": "Webhook Dispatcher", + "tenantId": "northbridge-university", + "slaTarget": 0.995, + "observedChecks": 8640, + "failedChecks": 12, + "plannedMaintenanceMinutes": 30, + "unplannedDowntimeMinutes": 180, + "owner": "integrations" + }, + { + "id": "export-pipeline", + "name": "Enterprise Export Pipeline", + "tenantId": "helios-rd", + "slaTarget": 0.999, + "observedChecks": 8640, + "failedChecks": 7, + "plannedMaintenanceMinutes": 15, + "unplannedDowntimeMinutes": 23, + "owner": "data-platform" + } + ], + "incidents": [ + { + "id": "INC-1042", + "serviceId": "api-core", + "tenantId": "northbridge-university", + "severity": "sev2", + "startedAt": "2026-05-08T11:10:00Z", + "resolvedAt": "2026-05-08T13:55:00Z", + "customerVisible": true, + "rootCause": "database connection pool exhaustion", + "followUp": "increase pool alert lead time" + }, + { + "id": "INC-1047", + "serviceId": "webhook-dispatcher", + "tenantId": "northbridge-university", + "severity": "sev3", + "startedAt": "2026-05-12T09:00:00Z", + "resolvedAt": "2026-05-12T10:26:00Z", + "customerVisible": true, + "rootCause": "downstream retry queue backpressure", + "followUp": "add tenant-specific delivery throttle" + }, + { + "id": "INC-1051", + "serviceId": "export-pipeline", + "tenantId": "helios-rd", + "severity": "sev4", + "startedAt": "2026-05-13T20:00:00Z", + "resolvedAt": "2026-05-13T20:23:00Z", + "customerVisible": false, + "rootCause": "scheduled worker recycle", + "followUp": "none" + } + ], + "webhookDeliveries": [ + { + "destinationId": "dspace-sync", + "tenantId": "northbridge-university", + "serviceId": "webhook-dispatcher", + "attempts": 1250, + "failures": 38, + "lastFailureAt": "2026-05-15T18:42:00Z", + "requiresNotice": true + }, + { + "destinationId": "orcid-sync", + "tenantId": "helios-rd", + "serviceId": "export-pipeline", + "attempts": 2200, + "failures": 3, + "lastFailureAt": "2026-05-10T07:15:00Z", + "requiresNotice": false + } + ], + "exportJobs": [ + { + "id": "EXP-991", + "tenantId": "northbridge-university", + "serviceId": "api-core", + "target": "institutional-admin-dashboard", + "containsSlaEvidence": true, + "requestedBy": "research-office-admin" + }, + { + "id": "EXP-992", + "tenantId": "helios-rd", + "serviceId": "export-pipeline", + "target": "funder-quarterly-pack", + "containsSlaEvidence": true, + "requestedBy": "compliance-ops" + } + ] +} diff --git a/enterprise-sla-uptime-monitor/docs/demo.svg b/enterprise-sla-uptime-monitor/docs/demo.svg new file mode 100644 index 0000000..86c011c --- /dev/null +++ b/enterprise-sla-uptime-monitor/docs/demo.svg @@ -0,0 +1,30 @@ + + Enterprise SLA Uptime Monitor Demo + Dashboard preview showing one breached service, one watch service, and one healthy export pipeline. + + Enterprise SLA Uptime Monitor + Issue #19 slice: uptime, error budget, webhook notice, export evidence + + + API Core + BREACH + Error budget: -49 min + + + + Webhook Dispatcher + WATCH + Notice review queued + + + + Export Pipeline + READY + Evidence export allowed + + + + Queued admin actions + 1. Customer notice · 2. Error-budget watch · 3. Webhook retry review · 4. Evidence export hold + + diff --git a/enterprise-sla-uptime-monitor/docs/requirement-map.md b/enterprise-sla-uptime-monitor/docs/requirement-map.md new file mode 100644 index 0000000..1a05976 --- /dev/null +++ b/enterprise-sla-uptime-monitor/docs/requirement-map.md @@ -0,0 +1,19 @@ +# Requirement map — Enterprise SLA uptime monitor + +This module targets issue #19, **Enterprise Tooling**, with a distinct service-level reliability slice that is not another admin dashboard, export package, trust center, data-residency guard, webhook replay ledger, identity drift checker, or grant compliance packet. + +| Issue #19 capability | Module coverage | +| --- | --- | +| Admin dashboards | Emits tenant-level service health, breach counts, MTTR, error-budget remaining minutes, and queueable admin actions. | +| API & webhooks | Evaluates webhook delivery failure rates, notice requirements, and destination-specific reliability actions. | +| Export pipelines | Marks SLA evidence exports ready or blocked based on service reliability posture and attached evidence. | +| Compliance tracking | Produces auditable SLA summaries, SHA-256 evidence digests, incident follow-ups, and customer-notice recommendations. | +| Institutional use case | Gives research offices a deterministic readout for uptime obligations before reporting to funders or internal governance. | + +## Non-overlap + +Live gate search found existing #19 submissions for broad tooling, export operations, trust centers, audit routers, webhook replay, identity drift, data residency, retention/legal hold, and grant compliance. This slice is limited to SLA/uptime/error-budget governance and does not claim to manage those adjacent workflows. + +## Local-first guardrail + +All data is synthetic. The demo runs offline, performs no network calls, stores no credentials, and does not publish or send notifications. diff --git a/enterprise-sla-uptime-monitor/package.json b/enterprise-sla-uptime-monitor/package.json new file mode 100644 index 0000000..4fc19c5 --- /dev/null +++ b/enterprise-sla-uptime-monitor/package.json @@ -0,0 +1,13 @@ +{ + "name": "enterprise-sla-uptime-monitor", + "version": "1.0.0", + "description": "Self-contained enterprise SLA and uptime monitoring slice for SCIBASE Enterprise Tooling.", + "type": "module", + "private": true, + "scripts": { + "check": "node --check src/sla-uptime-monitor.js && node --check scripts/demo.js && node --check test/sla-uptime-monitor.test.js", + "test": "node --test test/sla-uptime-monitor.test.js", + "demo": "node scripts/demo.js" + }, + "license": "MIT" +} diff --git a/enterprise-sla-uptime-monitor/scripts/demo.js b/enterprise-sla-uptime-monitor/scripts/demo.js new file mode 100644 index 0000000..6aa8647 --- /dev/null +++ b/enterprise-sla-uptime-monitor/scripts/demo.js @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { evaluateSlaPortfolio } from '../src/sla-uptime-monitor.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const samplePath = join(here, '..', 'data', 'sample-sla-input.json'); +const input = JSON.parse(await readFile(samplePath, 'utf8')); +const report = evaluateSlaPortfolio(input); + +console.log('SCIBASE Enterprise SLA Uptime Monitor'); +console.log(`Window: ${report.windowMinutes} minutes`); +console.log(`Services: ${report.adminMetrics.serviceCount}`); +console.log(`Breaches: ${report.adminMetrics.breachedServices}`); +console.log(`Watch: ${report.adminMetrics.watchServices}`); +console.log(`Blocked exports: ${report.adminMetrics.exportsBlocked}`); +console.log(`Queued actions: ${report.adminMetrics.queuedActions}`); +console.log(`Evidence digest: ${report.evidenceDigest}`); +console.log('\nAction queue:'); +for (const action of report.actionQueue) { + console.log(`- [${action.priority}] ${action.type}: ${action.summary}`); +} diff --git a/enterprise-sla-uptime-monitor/src/sla-uptime-monitor.js b/enterprise-sla-uptime-monitor/src/sla-uptime-monitor.js new file mode 100644 index 0000000..068763c --- /dev/null +++ b/enterprise-sla-uptime-monitor/src/sla-uptime-monitor.js @@ -0,0 +1,288 @@ +import { createHash } from 'node:crypto'; + +const DEFAULT_WINDOW_MINUTES = 30 * 24 * 60; +const WATCH_BUDGET_RATIO = 0.25; +const WEBHOOK_FAILURE_WATCH_RATE = 0.02; + +export function evaluateSlaPortfolio(input, options = {}) { + if (!input || typeof input !== 'object') { + throw new TypeError('evaluateSlaPortfolio requires an input object'); + } + + const generatedAt = input.generatedAt ?? new Date(0).toISOString(); + const windowMinutes = toPositiveNumber(input.windowMinutes, DEFAULT_WINDOW_MINUTES); + const tenants = indexBy(input.tenants ?? [], 'id'); + const incidentsByService = groupBy(input.incidents ?? [], 'serviceId'); + const serviceSummaries = (input.services ?? []).map((service) => + summarizeService(service, tenants.get(service.tenantId), incidentsByService.get(service.id) ?? [], windowMinutes), + ); + const serviceMap = indexBy(serviceSummaries, 'serviceId'); + const webhookSummaries = (input.webhookDeliveries ?? []).map((delivery) => + summarizeWebhookDelivery(delivery, serviceMap.get(delivery.serviceId)), + ); + const exportSummaries = (input.exportJobs ?? []).map((job) => + summarizeExportJob(job, serviceMap.get(job.serviceId)), + ); + const actionQueue = buildActionQueue(serviceSummaries, webhookSummaries, exportSummaries); + const adminMetrics = buildAdminMetrics(serviceSummaries, webhookSummaries, exportSummaries, actionQueue); + const evidencePacket = { + generatedAt, + windowMinutes, + serviceSummaries, + webhookSummaries, + exportSummaries, + adminMetrics, + actionQueue, + }; + + return { + ...evidencePacket, + evidenceDigest: digestEvidence(evidencePacket, options.digestSalt ?? 'scibase-sla-offline-demo'), + }; +} + +export function summarizeService(service, tenant, incidents, windowMinutes = DEFAULT_WINDOW_MINUTES) { + if (!service?.id) { + throw new TypeError('service entries must include an id'); + } + + const slaTarget = toRate(service.slaTarget ?? tenant?.defaultSlaTarget ?? 0.995); + const unplannedDowntimeMinutes = toNonNegativeNumber(service.unplannedDowntimeMinutes, 0); + const plannedMaintenanceMinutes = toNonNegativeNumber(service.plannedMaintenanceMinutes, 0); + const budgetMinutes = round(windowMinutes * (1 - slaTarget), 2); + const errorBudgetRemainingMinutes = round(budgetMinutes - unplannedDowntimeMinutes, 2); + const uptime = round(1 - unplannedDowntimeMinutes / windowMinutes, 6); + const failedChecks = toNonNegativeNumber(service.failedChecks, 0); + const observedChecks = toPositiveNumber(service.observedChecks, 1); + const failedCheckRate = round(failedChecks / observedChecks, 6); + const incidentDurations = incidents.map(durationMinutes).filter((duration) => duration >= 0); + const mttrMinutes = incidentDurations.length + ? round(incidentDurations.reduce((sum, duration) => sum + duration, 0) / incidentDurations.length, 2) + : 0; + const status = classifyService({ errorBudgetRemainingMinutes, budgetMinutes, failedCheckRate }); + + return { + serviceId: service.id, + serviceName: service.name ?? service.id, + tenantId: service.tenantId, + tenantName: tenant?.name ?? service.tenantId, + owner: service.owner ?? 'unassigned', + slaTarget, + uptime, + unplannedDowntimeMinutes, + plannedMaintenanceMinutes, + budgetMinutes, + errorBudgetRemainingMinutes, + failedCheckRate, + incidentCount: incidents.length, + customerVisibleIncidents: incidents.filter((incident) => incident.customerVisible).length, + mttrMinutes, + incidentIds: incidents.map((incident) => incident.id).filter(Boolean), + status, + }; +} + +export function summarizeWebhookDelivery(delivery, serviceSummary) { + if (!delivery?.destinationId) { + throw new TypeError('webhook deliveries must include a destinationId'); + } + + const attempts = toPositiveNumber(delivery.attempts, 1); + const failures = toNonNegativeNumber(delivery.failures, 0); + const failureRate = round(failures / attempts, 6); + const serviceStatus = serviceSummary?.status ?? 'unknown'; + const needsReview = failureRate > WEBHOOK_FAILURE_WATCH_RATE || serviceStatus === 'breach'; + + return { + destinationId: delivery.destinationId, + tenantId: delivery.tenantId, + serviceId: delivery.serviceId, + attempts, + failures, + failureRate, + lastFailureAt: delivery.lastFailureAt ?? '', + requiresNotice: Boolean(delivery.requiresNotice), + serviceStatus, + status: needsReview ? 'review_required' : 'healthy', + }; +} + +export function summarizeExportJob(job, serviceSummary) { + if (!job?.id) { + throw new TypeError('export jobs must include an id'); + } + + const serviceStatus = serviceSummary?.status ?? 'unknown'; + const hasEvidence = Boolean(job.containsSlaEvidence); + const blocked = serviceStatus === 'breach' && hasEvidence; + + return { + exportId: job.id, + tenantId: job.tenantId, + serviceId: job.serviceId, + target: job.target ?? 'unspecified', + requestedBy: job.requestedBy ?? 'unknown', + containsSlaEvidence: hasEvidence, + serviceStatus, + status: blocked ? 'blocked_pending_sla_review' : 'ready', + reason: blocked + ? 'Export includes SLA evidence for a service that has exhausted its error budget.' + : 'Export evidence is consistent with current service posture.', + }; +} + +function buildActionQueue(serviceSummaries, webhookSummaries, exportSummaries) { + const actions = []; + + for (const service of serviceSummaries) { + if (service.status === 'breach') { + actions.push({ + type: 'sla_breach_notice', + priority: 'high', + tenantId: service.tenantId, + serviceId: service.serviceId, + summary: `${service.serviceName} exhausted its error budget by ${Math.abs(service.errorBudgetRemainingMinutes)} minutes.`, + }); + } else if (service.status === 'watch') { + actions.push({ + type: 'error_budget_watch', + priority: 'medium', + tenantId: service.tenantId, + serviceId: service.serviceId, + summary: `${service.serviceName} has less than ${Math.round(WATCH_BUDGET_RATIO * 100)}% of error budget remaining.`, + }); + } + } + + for (const delivery of webhookSummaries) { + if (delivery.status === 'review_required') { + actions.push({ + type: 'webhook_reliability_review', + priority: delivery.requiresNotice ? 'high' : 'medium', + tenantId: delivery.tenantId, + serviceId: delivery.serviceId, + destinationId: delivery.destinationId, + summary: `${delivery.destinationId} failure rate is ${(delivery.failureRate * 100).toFixed(2)}%.`, + }); + } + } + + for (const exportJob of exportSummaries) { + if (exportJob.status === 'blocked_pending_sla_review') { + actions.push({ + type: 'export_hold', + priority: 'high', + tenantId: exportJob.tenantId, + serviceId: exportJob.serviceId, + exportId: exportJob.exportId, + summary: `${exportJob.exportId} should not be sent until SLA breach evidence is reviewed.`, + }); + } + } + + return actions; +} + +function buildAdminMetrics(serviceSummaries, webhookSummaries, exportSummaries, actionQueue) { + return { + serviceCount: serviceSummaries.length, + breachedServices: serviceSummaries.filter((service) => service.status === 'breach').length, + watchServices: serviceSummaries.filter((service) => service.status === 'watch').length, + healthyServices: serviceSummaries.filter((service) => service.status === 'within_sla').length, + customerVisibleIncidents: serviceSummaries.reduce((sum, service) => sum + service.customerVisibleIncidents, 0), + averageMttrMinutes: average(serviceSummaries.map((service) => service.mttrMinutes).filter((value) => value > 0)), + webhookDestinationsNeedingReview: webhookSummaries.filter((delivery) => delivery.status === 'review_required').length, + exportsBlocked: exportSummaries.filter((exportJob) => exportJob.status === 'blocked_pending_sla_review').length, + queuedActions: actionQueue.length, + }; +} + +function classifyService({ errorBudgetRemainingMinutes, budgetMinutes, failedCheckRate }) { + if (errorBudgetRemainingMinutes < 0) { + return 'breach'; + } + if (budgetMinutes > 0 && errorBudgetRemainingMinutes <= budgetMinutes * WATCH_BUDGET_RATIO) { + return 'watch'; + } + if (failedCheckRate > 0.01) { + return 'watch'; + } + return 'within_sla'; +} + +function digestEvidence(packet, salt) { + return createHash('sha256') + .update(canonicalize({ salt, packet })) + .digest('hex'); +} + +function canonicalize(value) { + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function groupBy(items, key) { + const groups = new Map(); + for (const item of items) { + const value = item[key]; + if (!groups.has(value)) { + groups.set(value, []); + } + groups.get(value).push(item); + } + return groups; +} + +function indexBy(items, key) { + const index = new Map(); + for (const item of items) { + index.set(item[key], item); + } + return index; +} + +function durationMinutes(incident) { + const started = Date.parse(incident.startedAt); + const resolved = Date.parse(incident.resolvedAt); + if (!Number.isFinite(started) || !Number.isFinite(resolved) || resolved < started) { + return -1; + } + return round((resolved - started) / 60000, 2); +} + +function average(values) { + if (!values.length) { + return 0; + } + return round(values.reduce((sum, value) => sum + value, 0) / values.length, 2); +} + +function toRate(value) { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0 || number >= 1) { + throw new RangeError(`SLA target must be a decimal between 0 and 1, got ${value}`); + } + return number; +} + +function toPositiveNumber(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} + +function toNonNegativeNumber(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : fallback; +} + +function round(value, digits = 2) { + return Number(value.toFixed(digits)); +} diff --git a/enterprise-sla-uptime-monitor/test/sla-uptime-monitor.test.js b/enterprise-sla-uptime-monitor/test/sla-uptime-monitor.test.js new file mode 100644 index 0000000..1b341d7 --- /dev/null +++ b/enterprise-sla-uptime-monitor/test/sla-uptime-monitor.test.js @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + evaluateSlaPortfolio, + summarizeExportJob, + summarizeService, + summarizeWebhookDelivery, +} from '../src/sla-uptime-monitor.js'; + +const baseInput = Object.freeze({ + generatedAt: '2026-05-16T12:00:00Z', + windowMinutes: 1000, + tenants: [{ id: 'tenant-a', name: 'Tenant A', defaultSlaTarget: 0.99 }], + services: [ + { + id: 'service-breach', + name: 'Breached Service', + tenantId: 'tenant-a', + slaTarget: 0.99, + observedChecks: 100, + failedChecks: 3, + plannedMaintenanceMinutes: 5, + unplannedDowntimeMinutes: 15, + owner: 'ops', + }, + { + id: 'service-watch', + name: 'Watch Service', + tenantId: 'tenant-a', + slaTarget: 0.99, + observedChecks: 100, + failedChecks: 0, + plannedMaintenanceMinutes: 0, + unplannedDowntimeMinutes: 8, + owner: 'integrations', + }, + { + id: 'service-ok', + name: 'Healthy Service', + tenantId: 'tenant-a', + slaTarget: 0.99, + observedChecks: 100, + failedChecks: 0, + plannedMaintenanceMinutes: 0, + unplannedDowntimeMinutes: 1, + owner: 'data-platform', + }, + ], + incidents: [ + { + id: 'INC-1', + serviceId: 'service-breach', + tenantId: 'tenant-a', + startedAt: '2026-05-16T10:00:00Z', + resolvedAt: '2026-05-16T10:15:00Z', + customerVisible: true, + }, + ], + webhookDeliveries: [ + { + destinationId: 'archive-sync', + tenantId: 'tenant-a', + serviceId: 'service-watch', + attempts: 100, + failures: 4, + requiresNotice: true, + }, + ], + exportJobs: [ + { + id: 'EXP-1', + tenantId: 'tenant-a', + serviceId: 'service-breach', + target: 'admin-dashboard', + containsSlaEvidence: true, + requestedBy: 'admin', + }, + { + id: 'EXP-2', + tenantId: 'tenant-a', + serviceId: 'service-ok', + target: 'funder-pack', + containsSlaEvidence: true, + requestedBy: 'admin', + }, + ], +}); + +test('summarizes service posture from uptime and error budget', () => { + const breach = summarizeService(baseInput.services[0], baseInput.tenants[0], baseInput.incidents, 1000); + assert.equal(breach.status, 'breach'); + assert.equal(breach.budgetMinutes, 10); + assert.equal(breach.errorBudgetRemainingMinutes, -5); + assert.equal(breach.mttrMinutes, 15); + + const watch = summarizeService(baseInput.services[1], baseInput.tenants[0], [], 1000); + assert.equal(watch.status, 'watch'); + assert.equal(watch.errorBudgetRemainingMinutes, 2); + + const healthy = summarizeService(baseInput.services[2], baseInput.tenants[0], [], 1000); + assert.equal(healthy.status, 'within_sla'); +}); + +test('routes webhook reliability review when failure rate is above threshold', () => { + const delivery = summarizeWebhookDelivery(baseInput.webhookDeliveries[0], { status: 'watch' }); + assert.equal(delivery.status, 'review_required'); + assert.equal(delivery.failureRate, 0.04); +}); + +test('blocks SLA evidence exports for breached services only', () => { + const blocked = summarizeExportJob(baseInput.exportJobs[0], { status: 'breach' }); + assert.equal(blocked.status, 'blocked_pending_sla_review'); + + const ready = summarizeExportJob(baseInput.exportJobs[1], { status: 'within_sla' }); + assert.equal(ready.status, 'ready'); +}); + +test('builds deterministic admin metrics, actions, and evidence digest', () => { + const report = evaluateSlaPortfolio(structuredClone(baseInput), { digestSalt: 'test-salt' }); + assert.equal(report.adminMetrics.serviceCount, 3); + assert.equal(report.adminMetrics.breachedServices, 1); + assert.equal(report.adminMetrics.watchServices, 1); + assert.equal(report.adminMetrics.exportsBlocked, 1); + assert.equal(report.adminMetrics.webhookDestinationsNeedingReview, 1); + assert.equal(report.actionQueue.length, 4); + assert.match(report.evidenceDigest, /^[a-f0-9]{64}$/); + + const again = evaluateSlaPortfolio(structuredClone(baseInput), { digestSalt: 'test-salt' }); + assert.equal(report.evidenceDigest, again.evidenceDigest); +}); + +test('rejects invalid SLA target values', () => { + assert.throws( + () => summarizeService({ ...baseInput.services[0], slaTarget: 1.2 }, baseInput.tenants[0], [], 1000), + /SLA target/, + ); +});