---
title: "Incident controls"
description: "Switch off one action, route, interface, driver or agent, put the app in read-only mode, or sign everyone out, in seconds and without a deploy."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "incident-controls"
canonical: "https://mainsdk.dev/docs/interfaces/incident"
updated: "2026-10-28"
---

# Incident controls

> Switch off one action, route, interface, driver or agent, put the app in read-only mode, or sign everyone out, in seconds and without a deploy.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/incident · Updated: 2026-10-28

Switch off one action, route, interface, driver or agent, put the app in read-only mode, or sign everyone out, in seconds and without a deploy.

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/incident-controls.md
- Category: Security and privacy

Calls:

- `incident.status()`
- `incident.set(req, change, { reason, expires? })`

In main.yaml:

```yaml
incident:
  kill: []
  read-only: false
  read-only-allow: [/api/auth/*, /api/webhooks/*]
  sessions-after: null
  tenants: {}
  expires: {}
```

Guarantees, whatever the driver:

- A control only tightens what main.yaml allows; a push cannot open anything
- Reaches every instance through the config store and survives restarts
- Audited with who, why and when; cleared by deleting it
- Never reads the store in a request; fail static


## Drivers

Pick one in main.yaml with `incident.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (pushes through the config driver) | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-INCIDENT-0004` | check | A kill target is unknown or malformed, or names a protected interface |
| `MAIN-INCIDENT-0007` | check | A control with no `expires` has been in force for 24 hours (warn) |
| `MAIN-CONFIG-0020` | check | A config value fails its schema; the last good value is kept |
| `MAIN-INCIDENT-0001` | runtime | Switched off by an incident control (503, retryable) |
| `MAIN-INCIDENT-0002` | runtime | The app is read-only during an incident (503 for requests; thrown by writes) |
| `MAIN-INCIDENT-0003` | runtime | The session was issued before the incident sign-out cut-off (401) |
| `MAIN-INCIDENT-0005` | runtime | `mainsdk incident` could not write: no `--reason`, or the config driver has no `write` |
| `MAIN-INCIDENT-0006` | runtime | `incident.set` refused: no `incident.manage`, or no fresh phishing-resistant session (403) |
| `MAIN-CONFIG-0001` | runtime | Unsupported `schema` value in `main.yaml` |
| `MAIN-CONFIG-0002` | runtime | `main.yaml` can't be read: bad indent, a tab, an anchor or tag, or a duplicate key; the error names the line |
| `MAIN-CONFIG-0003` | runtime | A value has the wrong format: money (`"$0.05"`), a rate (`$5/day`), a size (`25MB`) or a duration (`1h`) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| incident section in main.yaml and the tighter-only push rule | 1 | 0.4 | `incident-controls#schema` |
| Kill switch for actions | 1 | 0.4 | `incident-controls#kill` |
| Read-only mode | 1 | 0.4 | `incident-controls#read-only` |
| Sign-out cut-off for the app or one tenant | 1 | 0.4 | `incident-controls#sessions-after` |
| Config driver write | 1 | 0.4 | `incident-controls#driver-write` |
| mainsdk incident on | 1 | 0.4 | `incident-controls#cli` |
| incident.set for an admin screen | 1 | 0.4 | `incident-controls#in-app` |
| Controls in the status header and mainsdk info | 1 | 0.4 | `incident-controls#visibility` |
| Expiry and the 24 hour warning | 1 | 0.4 | `incident-controls#expiry` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| fleet | One switch across many apps (spec fleet-defend) |


## Quick start

```yaml title="main.yaml"
incident:
  kill: []
  read-only: false
  read-only-allow: [/api/auth/*, /api/webhooks/*]
  sessions-after: null
  tenants: {}
  expires: {}
```

```ts title="app/example.ts"
import { incident } from "mainsdk";

declare const req: Request;   // the request of your admin route

// what is switched off right now, from memory
const state = incident.status();
console.log(state.controls.length);

// from an admin screen: needs incident.manage and a fresh passkey sign-in
await incident.set(req, { kill: ["action:orders.refund"] }, { reason: "refund abuse, ticket 812", expires: "4h" });
```

## Configuration

| Key | Type | What it does |
| --- | --- | --- |
| `incident.kill` | list | Targets switched off: `action:<name>`, `route:<glob>`, `interface:<id>`, `driver:<id>`, `agent:<name>`, `tool:<name>`. Defaults to `[]` |
| `incident.read-only` | boolean | Refuse writes in `db`, `files` and `jobs`, and unsafe requests. Defaults to `false` |
| `incident.read-only-allow` | list | Paths that may still take unsafe requests while read-only |
| `incident.sessions-after` | RFC 3339 string or null | Sessions and refresh tokens issued before this instant end |
| `incident.tenants.<tenant>.sessions-after` | RFC 3339 string | The same cut-off for one tenant |
| `incident.expires.<entry>` | RFC 3339 string | Instant after which one control stops applying |

Deployed values are usually empty; controls arrive as `main.incident.*` pushes. See the `incident.*` rows in [main.yaml reference](https://mainsdk.dev/docs/reference/main-yaml).

## What you get

- A control only tightens what main.yaml allows; a push cannot open anything
- Reaches every instance through the config store and survives restarts
- Audited with who, why and when; cleared by deleting it
- Never reads the store in a request; fail static

### Calls

| Call | What it does |
| --- | --- |
| `incident.status()` | Returns the controls in force or expired, with when, who and why. Reads memory, never the config store. |
| `incident.set(req, change, { reason, expires? })` | For an admin screen. Needs the `incident.manage` permission and a fresh passkey sign-in, else `MAIN-INCIDENT-0006` (403) and nothing is written. Makes the same push and audit record as the CLI. |

### What a control does

| Control | Effect | Code |
| --- | --- | --- |
| `kill` | The named action, route, interface, driver or agent is refused: 503, retryable, `Retry-After: 60`. The problem names the target, never the reason | `MAIN-INCIDENT-0001` |
| `read-only` | Writes in `db`, `files` and `jobs`, and unsafe requests, are refused. Reads, sign-in and the audit log go on. `read-only-allow` lists paths that may still take unsafe requests | `MAIN-INCIDENT-0002` |
| `sessions-after` | Sessions and refresh tokens issued before the instant end (401; sign in again). Set it for the app or for one tenant. API keys keep working; revoke a leaked key with `apikeys.revoke` | `MAIN-INCIDENT-0003` |

A kill target is `action:<name>`, `route:<path>`, `interface:<name>`, `driver:<id>`, `agent:<name>` or `tool:<name>`. Some interfaces cannot be killed, because the app needs them to run and to recover. An unknown or malformed target is `MAIN-INCIDENT-0004`.

### Only tighter

Controls arrive as pushes of `main.incident.*` through the config driver, which needs a `write` call. A push is accepted only if it is tighter than or equal to what `main.yaml` allows: adding kill targets, `read-only: true`, a later `sessions-after`, and expiry instants that do not move earlier. A push that would open something is refused with `MAIN-CONFIG-0021`, and a malformed one with `MAIN-CONFIG-0020`; the app keeps the value it had. So a leaked push credential cannot use incident controls to loosen the app.

Every change is audited as an `incident.control` record with the control, target, reason, actor and env, and is also written as a security event. A control with no `expires` that has been in force for 24 hours is `MAIN-INCIDENT-0007` (a warning in `mainsdk check`, and in the log once an hour).

### CLI

```text
mainsdk incident on kill action:orders.refund --reason "refund abuse, ticket 812" --expires 4h --env prod
mainsdk incident on read-only --reason "session store copied"
mainsdk incident on sign-out --tenant tnt_01JD --reason "leaked token"
mainsdk incident off kill action:orders.refund --reason "fixed"
mainsdk incident status --json
```

The command refuses without `--reason` (`MAIN-INCIDENT-0005`) and prints when the change will have reached every instance: within 5 seconds for a driver with a change stream, else within the driver's refresh interval.

### Fail static

A control is held in memory on each instance and the request path reads memory only. If the config store is down, instances keep the controls they last had, and a restart reloads them from the last good value. No incident control ever makes a request wait on the store.

## Errors and checks

| Code | When | What to do |
| --- | --- | --- |
| `MAIN-INCIDENT-0001` | A killed target was used (503, retryable, `Retry-After: 60`) | Clear the control when the incident is over |
| `MAIN-INCIDENT-0002` | The app is read-only and a write was attempted (503) | Clear `read-only`, or list the path in `read-only-allow` |
| `MAIN-INCIDENT-0003` | The session was issued before the sign-out cut-off (401) | Sign in again |
| `MAIN-INCIDENT-0004` | A kill target is unknown or malformed (check, runtime) | Fix the target; a pushed one is refused with `MAIN-CONFIG-0020` |
| `MAIN-INCIDENT-0005` | `mainsdk incident` could not write: no `--reason`, or the config driver has no `write` | Pass `--reason`; use a config driver that can write |
| `MAIN-INCIDENT-0006` | `incident.set` refused: no `incident.manage` or no fresh passkey sign-in (403) | Grant the permission and sign in again with a passkey |
| `MAIN-INCIDENT-0007` | A control with no `expires` has been in force for 24 hours (warning) | Clear it, or set `--expires` |

A push that would loosen what `main.yaml` allows is `MAIN-CONFIG-0021`; a malformed one is `MAIN-CONFIG-0020`. See [check codes](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/errors) for all of them.

## Swapping drivers

There is one driver, `builtin`, and it pushes through the config driver. Controls reach other instances through whichever `config` driver the app uses (`files`, `local` or `redis`); a driver with no `write` method makes `mainsdk incident on` fail with `MAIN-INCIDENT-0005`. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

`createTestApp` accepts a `configDriver`, so two apps can share one in-memory store. Push `main.incident.kill` to it, refresh both apps and the killed action is refused with `MAIN-INCIDENT-0001`. The spec's criteria are covered this way in `packages/sdk/test/incident-controls.test.ts`.

## Next

- [Config and secrets](https://mainsdk.dev/docs/interfaces/config)
- [Security events](https://mainsdk.dev/docs/interfaces/security)
- [Main.yaml reference](https://mainsdk.dev/docs/reference/main-yaml)
