# Vendor-attributed SLA/SLO tracking — design doc

Date: 2026-06-02
Author: Agent 2 (Claude)
Status: Implemented; open for review (do not merge without Nelson).

## Why

Evidence Action pays vendors — ISP, web host, SSL CA, DNS provider, CDN — each
with a promised uptime in a contract. The product question is **"are the
vendors we pay meeting their SLA, and what is their failure costing us?"** This
feature is about *vendor accountability*, not a generic uptime widget.

A historical `SLATarget`/`SLAReport` feature existed but was removed in AUDIT-2
#18 (unused, 0 rows). This feature is **new and vendor-centric**, not a revival
of that one. It does not re-use the dropped tables or their semantics.

## Data model (additive, non-destructive)

### `Vendor` (new model)

| field          | type            | notes                                            |
|----------------|-----------------|--------------------------------------------------|
| id             | Int @id         | autoincrement                                    |
| name           | String          | e.g. "Cloudflare", "Liquid Telecom"              |
| type           | String          | enum-by-convention: ISP\|HOST\|SSL\|DNS\|CDN\|APP |
| promisedUptime | Float           | contracted uptime percent, e.g. 99.9             |
| contractRef    | String?         | free-text contract identifier                    |
| creditTerms    | String?         | free-text, parseable hint (see below)            |
| notes          | String?  @db.Text |                                                 |
| monitors       | Monitor[]       | one vendor → many monitors                       |
| createdAt/updatedAt | DateTime   |                                                  |
| deletedAt      | DateTime?       | soft-delete (matches every other CRUD model)     |
| createdById / updatedById / deletedById | Int? | audit, matching MonitorGroup etc.    |

`type` is a String, not a Prisma enum. **Decision:** the repo models every
other "enum" (`role`, monitor `type`, `errorClass`) as a `String` with the
allowed set documented in a comment and validated in Zod at the boundary. A
Prisma `enum` would be a non-additive schema shape change inconsistent with the
rest of the codebase. Allowed values enforced in `vendor.validation.ts`.

### `Monitor.vendorId` (new optional column + relation)

`vendorId Int?` + `vendor Vendor? @relation(...)`. Each monitor has at most one
**primary** vendor. The relation is optional and `onDelete` is left to the
default (restrict) — but because Vendor is soft-deleted, we never hard-delete a
vendor that has monitors. Index on `vendorId`.

Migration is purely additive: `CREATE TABLE Vendor`, `ALTER TABLE Monitor ADD
COLUMN vendorId`, add FK + indexes. No drops, no type changes. Safe against the
live dev DB and other in-flight branches (per pitfall #7).

## errorClass → vendor-layer attribution

The monitor engine already classifies each failure into an `errorClass` on the
`Heartbeat` row (see `src/lib/monitor-strategies/classify-error.ts`). We map
each class to the **vendor layer responsible** for that failure, as a documented
constant in `src/lib/services/sla/error-class-layer.ts`:

| errorClass    | layer | rationale                                        |
|---------------|-------|--------------------------------------------------|
| DNS_FAIL      | DNS   | name resolution failed → DNS provider            |
| TLS_INVALID   | SSL   | cert/handshake → SSL CA                           |
| TCP_REJECT    | HOST  | SYN refused / host unreachable → web host         |
| TIMEOUT       | ISP   | wall-clock/socket timeout → transit/ISP path     |
| HTTP_5XX      | APP   | server-side error → application                  |
| HTTP_4XX      | APP   | client/app contract error → application          |
| KEYWORD_MISS  | APP   | content assertion failed → application           |
| UNKNOWN       | (none)| unattributed; counted separately as "unattributed"|

This is intentionally a single source-of-truth constant so it can become
operator-configurable later without touching the math. `layer` is a label, not
the same as the vendor `type` — a monitor's *primary* vendor may be its HOST,
but a TIMEOUT on that monitor is attributed to the **ISP layer**. The downtime
attribution view breaks downtime down by layer regardless of which single
vendor a monitor is assigned to.

## SLI / error-budget math (pure functions, TDD'd)

All in `src/lib/services/sla/slo-math.ts`, no Prisma, no I/O. Tested first in
`src/lib/services/sla/__tests__/slo-math.test.ts`.

Inputs: per-window aggregated buckets `{ up, down, maintenance }` (counts of
heartbeats, or minute-equivalents — see denominator note) and a `targetPercent`.

- **windowMinutes** = caller-supplied (default rolling 30 days = 43200 min).
- **Denominator excludes maintenance.** SLI = `up / (up + down)` over
  non-maintenance samples. Matches `report-builder.ts` and the MaintenanceWindow
  doc comment ("excluded from SLA uptime math").
- **SLI (uptime %)** = `up / (up + down) * 100`, or 100 when no samples.
- **attainment** = `sli - target` (percentage points; ≥ 0 means meeting SLA).
- **meetingSla** = `sli >= target`.
- **Error budget** (the SRE formulation):
  - `budgetMinutes = (1 - target/100) * windowMinutes` — total allowed downtime.
  - `downtimeMinutes` = measured downtime in the window (from sample ratio ×
    windowMinutes, or from explicit minutes when the caller has them).
  - `consumedMinutes = downtimeMinutes`.
  - `remainingMinutes = budgetMinutes - consumedMinutes` (can go negative).
  - `remainingPercent = remainingMinutes / budgetMinutes * 100` (clamped display;
    raw value can be negative to signal an over-budget breach). When
    `budgetMinutes === 0` (target 100%), any downtime → `remainingPercent = -Inf`
    semantics collapsed to `0` remaining + `breached = downtime > 0`.

**Downtime minutes derivation.** We prefer `HeartbeatHourly` rollups for the
window (each row already carries `uptimePercent`, `successCount`,
`failureCount`, `totalChecks`). `downtimeMinutes` is derived from the
non-maintenance failure ratio across the window × windowMinutes. For the most
recent hours (within raw retention) we can use raw `Heartbeat` rows for higher
fidelity, but the math function is agnostic — it takes counts.

- **Service credit estimate** (`estimateServiceCredit`): parses `creditTerms`
  for a simple pattern `"<percent>% per <interval>"` or
  `"<percent>% if uptime < <threshold>"`. **Decision:** rather than invent a
  contract DSL, we support a small, documented set of patterns and otherwise
  return `null` (UI shows "—" / "set credit terms"). Patterns recognised:
  - `"10% credit if uptime < 99.9%"` → if measured `sli < 99.9`, credit = 10%.
  - `"5% per 0.1% below target"` → tiered: `floor((target - sli)/0.1) * 5%`.
  The estimate is expressed as a **percent of monthly spend**, since we do not
  store vendor spend (open question below). It never asserts a dollar value.

## MTTR & breaches

- **breaches** = count of distinct down→up transitions (outage episodes) in the
  window, computed from the ordered heartbeat status stream (pure function
  `countOutageEpisodes`).
- **MTTR** = mean duration of those outage episodes (down start → recovery),
  in minutes (pure function `meanTimeToRecovery`).

## Service layer

`src/lib/services/sla/vendor-sla.service.ts` (Prisma-bound) orchestrates:
loads a vendor's monitors, pulls rollups + recent raw heartbeats per monitor for
the window, subtracts maintenance, and feeds the pure functions to produce a
`VendorSlaSummary`. Also a per-monitor `computeMonitorSlo` used by the badge.

## API routes

- `GET/POST /api/vendors` — list / create (POST: ADMIN, EDITOR).
- `GET/PUT/DELETE /api/vendors/[id]` — detail / edit / soft-delete (write: ADMIN,
  EDITOR). GET returns the computed SLA summary for the default 30-day window.
- `POST /api/vendors/[id]/monitors` — assign/unassign monitors to the vendor
  (ADMIN, EDITOR). Body `{ monitorIds: number[] }` sets `Monitor.vendorId`.
- `GET /api/monitors/[id]/slo` — per-monitor SLO badge data + layer attribution.

All gated via the existing `getServerSession` + role-array pattern used by
`/api/maintenance-windows` (the repo's dominant style). RBAC mirrors
`ROLES_WRITE = ['ADMIN','EDITOR']`.

## UI

- `/vendors` page under the dashboard layout. Lists vendors with measured uptime,
  attainment vs promised, error-budget remaining, breach count, MTTR, estimated
  credit. Create/Edit via a `Dialog` (ADMIN/EDITOR only). Per-vendor detail
  drawer shows its monitors + the downtime-by-layer breakdown, and lets an
  editor assign monitors.
- Built entirely with `@/components/ui` primitives (Button, Input, Card, Dialog)
  and semantic token classes (`bg-surface`, `text-fg`, `text-muted`,
  `border-border`, `bg-brand`, `bg-danger`, etc.). No raw `<button>`/`<input>`,
  no `slate-*`/`indigo-*` — to not fight Agent 1's primitive-adoption ratchet.
- Per-monitor SLO badge component surfaced on the monitor detail page
  (`/monitors/[id]`) — minimal, additive insertion.

## Monthly vendor SLA report

`src/lib/services/sla/vendor-report.ts` reuses the report patterns
(`report-builder.ts` style for HTML, and `jspdf` + `jspdf-autotable` already in
deps for the PDF, as `/reports` does). `GET /api/vendors/[id]/report?format=pdf`
streams a per-vendor monthly summary: promised vs measured, attainment, error
budget, breaches, MTTR, downtime-by-layer, estimated credit. The client
download button on the vendor detail can also render the PDF browser-side using
the same jspdf path the analytics page uses.

## Smoke

`scripts/smoke/sla-1-vendor-slo.ts` — structural + pure-function smoke:
exercises `slo-math.ts` (SLI, error budget, MTTR, episodes), the
`errorClass → layer` map completeness (every ErrorClass covered), and asserts
the schema carries `Vendor` + `Monitor.vendorId` and a migration file exists.
Registered in `scripts/smoke/run-all.ts` in the same commit.

## Assumptions made

1. **`type` as String not Prisma enum** — matches repo convention; validated in
   Zod. (See data model.)
2. **No vendor spend stored** — service-credit is expressed as a *percent of
   spend*, not a currency figure, because we have nowhere to store the monthly
   bill. Adding a `monthlySpend` field later would let us show dollars.
3. **Soft-delete** — Vendor uses `deletedAt` like every other CRUD model.
4. **errorClass → layer map** uses the mapping in the task spec verbatim;
   `UNKNOWN` is "unattributed" (not forced onto a vendor).
5. **Maintenance excluded from the denominator** — consistent with
   `report-builder.ts` and the MaintenanceWindow doc comment.
6. **Default window = rolling 30 days** (43200 minutes). Configurable via query.
7. **RBAC**: ADMIN + EDITOR may create/edit/delete vendors and assign monitors;
   all authenticated users may read. Mirrors `/api/maintenance-windows`. Vendors
   are an operator-wide resource (not per-user-owned), like MaintenanceWindow.
8. **Downtime minutes** derived from heartbeat success/failure *ratio* × window
   length (we don't have exact per-second downtime; heartbeats are sampled at
   the monitor interval). This is the same approximation `report-builder.ts`
   makes.
9. **Credit-terms parsing** supports two documented patterns; anything else →
   `null` (no estimate). Not a general contract DSL.

## Open questions for Nelson

1. **Vendor spend / currency.** Do you want a `monthlySpend` (and currency)
   field on Vendor so service-credit can be shown in dollars, and so we can
   surface "what is downtime costing us" as a real figure? Right now it's a
   percent-of-spend estimate only.
2. **Credit-terms format.** Should `creditTerms` stay free-text with the two
   parseable patterns, or do you want a structured form (threshold + percent +
   cadence) in the UI? Structured would make the estimate reliable.
3. **Vendor ownership scope.** I made vendors operator-wide (any ADMIN/EDITOR
   sees all). Should they be scoped by Country/region like some other resources?
4. **Layer map configurability.** The errorClass→layer map is a constant. Do
   you want it operator-editable (e.g. some orgs put TIMEOUT on HOST, not ISP)?
5. **Multiple vendors per monitor.** Spec says one *primary* vendor per monitor.
   A monitor's downtime can implicate several layers (DNS vs ISP vs HOST). Today
   the layer breakdown captures that, but the *vendor* assignment is single. Do
   you want a monitor↔vendor many-to-many later (e.g. a DNS vendor AND a host
   vendor on the same monitor)?
6. **Report cadence / delivery.** Should the monthly vendor SLA report plug into
   the existing `ReportPreference`/`report-scheduler` so it's emailed monthly,
   or stay on-demand (download button) only? I built on-demand; scheduling is a
   small follow-up.
