Disciplines · Runbooks

Runbook: V1 Model Provider Outage (gd:model-provider-outage)

among its registered mitigations, which is the detector and the mitigation at once — and S10.5's finding applies here: a breaker's error rate falls to near zero on a dependency that is one hundred percent down, because the calls stop

13sections5 minread

On this page

Owner: workbench-platform on-call (rota:workbench-platform). Last reviewed: 2026-08-14. Version: 1.

The primary model provider stops answering. The standby rests on the same gateway, so this is a test of the gateway boundary as much as of the provider: a plan that fails over to the standby has to say why the standby is reachable when the primary is not.

Related: provider-failover.md is the estate-wide procedure for a degraded AI, search, payment, messaging or media provider. This runbook is narrower and answers to a registered scenario: it names the dependency the drill injects into, the capabilities that stop, and the observation that would refute a pass.

1. Detection and declaration#

Detection signal. dep:model-provider-primary carries circuit-breaker among its registered mitigations, which is the detector and the mitigation at once — and S10.5's finding applies here: a breaker's error rate falls to near zero on a dependency that is one hundred percent down, because the calls stop being made. Read slo:provider-backed-success-rate and the caller-failure rate, not the dependency's own error rate.

The three capabilities that stop are "run a model-backed gate", "run an assisted action" and "generate a suggestion". None of them is a read of stored state, so nothing looks broken to an actor who is only browsing.

  • Detection authority: workbench-platform on-call, contact rota:workbench-platform.
  • Declaration authority: production operations duty officer, contact rota:prod-ops.
  • Gap, stated. docs/operations/on-call.md describes a weekly primary/secondary rotation and names no person and no rotation config; none of the register's rota: identifiers appears in it or in docs/operations/alert-routing.md. The name above is registered; the route from it to whoever is carrying the pager is not.

2. Communications#

Audience Within Content
Actors 15 min that assisted actions and suggestions are unavailable, and that stored work is untouched
Operators 5 min that this runbook is running, and by whom
Provider 60 min the failing request ids and the observed onset, where the provider takes them

All travel over dep:notification-channel, which this scenario leaves standing.

3. Dependency assumptions#

  • dep:provider-gateway is healthy. This is the assumption the scenario is designed to test. Both dep:model-provider-primary and dep:model-provider-standby rest on the gateway in the S10.5 substrate, so a failover to the standby is only a recovery if the gateway is not the thing that failed. If it is, the standby is already gone and this runbook does not apply.
  • dep:primary-store is healthy: the record of what was requested and what came back is stored there, and it is what makes a duplicate dispatch detectable.

4. Recovery infrastructure#

In place. The failover is a routing decision at the gateway. Nothing is provisioned and nothing moves.

5. Secrets#

  • The standby provider's credentials, which must be present before the incident. A failover to a provider whose key was never configured fails closed at the worker, which is correct behaviour and a longer outage.

Recoverable out of band: the configuration-and-secrets state class is in the off-box backup (infra/hetzner/backup/offbox-backup.sh).

6. Restore order#

One dependency: dep:model-provider-primary comes back when the provider does, or when traffic is routed back to it from the standby. No reference edge touches it, so there is no ordering constraint against the graph — the constraint is §10's: do not route back while duplicate dispatch is unresolved.

7. Validation#

scripts/isis/run_provider_outage_drill.sh is the real instrument and it now does what its name says. Two properties matter, both repaired in the same pass that wrote this register:

  • SUBMIT_SYNTHETIC_JOB submits a real job (SYNTHETIC_JOB_TYPE, default text-to-image) and polls it to a terminal state, so the check is a completion rather than a health endpoint;
  • the read-only gate honours an explicit false. It previously read jq -r '.isReadOnly // true', and jq's alternative operator treats a literal false as absent, so the expression returned true for every input and the drill could never inject its fault.

Run it with DRY_RUN=false. In dry run the mutations are not applied and every verification records skip — and a skipped check is not a pass.

8. Fencing — NOT APPLICABLE, and the reason is specific#

The standby holds no authoritative state, so there is no old primary whose writes must be rejected: two providers answering is not split-brain. The real risk of this failover is duplicate dispatch — an in-flight request the primary already accepted, reissued to the standby — and the control for that is an idempotency key on the request, not a fencing token on the provider. Naming this section "fencing" and filling it would be a false signal; the risk is recorded in §10 where it belongs.

9. Failover#

  1. Confirm dep:provider-gateway is healthy. If it is not, stop: §3.
  2. Route model traffic to dep:model-provider-standby.
  3. Record the instant. Recovery time runs from the fault, not from this step.

run_provider_outage_drill.sh performs the flag mutations that stand in for this and reverses them from an EXIT/INT/TERM trap, so a failure between injection and restoration no longer exits with the fault applied.

10. Reconciliation#

The question this outage leaves is whether a request was dispatched twice: once to the primary before it stopped answering, once to the standby after.

scripts/operations/v1-restore-reconcile.mjs compares restored provider effects against what the outside world observed, and is the control that answers it. providerEffects is one of the seven collections it validates, and it is keyed by idempotencyKey — which is the field that decides whether the second dispatch was the same request or a new one. It is also the reason safeToResumeAutonomy exists.

Give it expected counts. An empty collection reconciles clean.

11. Failback#

Route traffic back to the primary once it has answered a synthetic job successfully, and not before: a provider that has recovered its health endpoint before its capacity fails the first real request. Restore the flags the drill mutated — the drill's trap does this, but a manual failover has no trap and the flags are the estate's memory of the incident.

12. What this runbook does not cover#

  • A gateway outage. Both providers rest on it, so that is a different scenario and this procedure makes it worse.
  • Provider credential rotation — scripts/isis/rotate_runpod_credentials.sh covers that path.
  • Cost or quota exhaustion, which presents as a provider outage and is not one.

13. Machine-checkable statement#

json
{
  "id": "runbook:v1-model-provider-outage",
  "scenarioId": "gd:model-provider-outage",
  "version": "1",
  "detectionAuthority": {
    "role": "service on-call",
    "name": "workbench-platform on-call",
    "reachableOver": ["dep:notification-channel"]
  },
  "declarationAuthority": {
    "role": "operations duty officer",
    "name": "production operations duty officer",
    "reachableOver": ["dep:notification-channel"]
  },
  "communications": [
    {
      "audience": "actors",
      "channelDependencyId": "dep:notification-channel",
      "withinMs": 900000
    },
    {
      "audience": "operators",
      "channelDependencyId": "dep:notification-channel",
      "withinMs": 300000
    },
    {
      "audience": "provider",
      "channelDependencyId": "dep:notification-channel",
      "withinMs": 3600000
    }
  ],
  "assumes": ["dep:provider-gateway", "dep:primary-store"],
  "recoveryInfrastructureDependencyId": null,
  "secrets": [
    {
      "id": "standby provider credentials",
      "heldInDependencyId": "dep:model-provider-standby",
      "recoverableOutOfBand": true
    }
  ],
  "restoreOrder": ["dep:model-provider-primary"],
  "fencing": null,
  "promotesAStandby": false,
  "validation": [
    "run_provider_outage_drill.sh with DRY_RUN=false submits a synthetic job and polls it to a terminal state on the standby",
    "slo:provider-backed-success-rate recovers, read from the caller side rather than from the dependency error rate"
  ],
  "failback": "route back to the primary only after it completes a synthetic job, and restore the feature flags the drill mutated"
}