---
title: "Connected devices"
description: "Optional plumbing for any fleet of devices or agents your app manages - enrolment, device identity and attestation, signed telemetry, policy sync, approved commands and health - with nothing loaded until you declare it."
section: "interfaces"
status: "Building for 1.3 · ships as a beta API"
spec: "device-identity"
canonical: "https://mainsdk.dev/docs/interfaces/device"
updated: "2026-10-28"
---

# Connected devices

> Optional plumbing for any fleet of devices or agents your app manages - enrolment, device identity and attestation, signed telemetry, policy sync, approved commands and health - with nothing loaded until you declare it.

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

Optional plumbing for any fleet of devices or agents your app manages - enrolment, device identity and attestation, signed telemetry, policy sync, approved commands and health - with nothing loaded until you declare it.

## At a glance

- Status: Building for 1.3 · ships as a beta API
- Phase: 4 (Fleet)
- Milestone: 1.3 Fleet: see
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/device-identity.md
- Category: Platform, OS and network

Calls:

- `device.enrolments.create({ group, uses, expiresIn })`
- `device.enrolments.list({ cursor })`
- `device.enrolments.revoke(id, { reason })`
- `device.get(id)`
- `device.list({ group, state, cursor })`
- `device.update(id, { name, group, labels })`
- `device.revoke(id, { reason })`
- `device.handler()`
- `device.telemetry.on(stream, handler, { schema })`
- `device.summary.get({ group })`
- `device.policy.define(schema, { tighten })`
- `device.policy.publish(content, { target, reason, rollout })`
- `device.policy.get(version, { target })`
- `device.policy.list({ target, cursor })`
- `device.policy.rollback({ target, to, reason })`
- `device.policy.tighten({ target, path, value, expiresIn, reason })`
- `device.commands.define(type, { input, risk, approve, undo })`
- `device.commands.create(type, { to, input, reason })`
- `device.commands.get(id)`
- `device.commands.list({ device, state, cursor })`
- `device.commands.cancel(id, { reason })`
- `device.commands.revert(id, { reason })`
- `device.mcp.tools()`
- `deviceClient({ url, keys, dir })`
- `fileKeyStore(dir, { alg })`
- `memoryKeyStore({ alg })`
- `webCryptoKeyStore({ db })`
- `softwareEvidence({ software })`
- `simulateDevices(app, { count })`
- `deviceConformance({ driver })`

In main.yaml:

```yaml
device:
  driver: local                 # local | taps (from @mainsdk/device-taps)
  path: /mainsdk/device
  authority: secret:MAINSDK_DEVICE_KEY
  enrol: { proofs: [token, code] }
  attest: { require: none }
  telemetry: { streams: { app.status: {} }, max-batch: 1MB }
  heartbeat: { every: 60s }
  policy:
    rollout: { stages: ["5%", "25%", "100%"], hold: 1h }
    tighten: { report.interval: higher }
  commands:
    ttl: 15m
    max-targets: 100
    reaction-max: 50
```

Guarantees, whatever the driver:

- Off until declared in main.yaml and imported; no route, table, timer or cost otherwise, and never re-exported from mainsdk
- Device keys are made on the device and never leave it
- Every device message is signed, fresh and bound to one tenant
- Telemetry is delivered once and in order, with redaction on the device and on the server
- A device keeps its last good policy when the app is unreachable and never accepts an older one
- Commands run only registered handlers, expire, and need people's approval when risky; an agent never approves
- No detection rules, endpoint event schemas or code delivery, now or later (ADR-0076)


## Drivers

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

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local (did:key, Ed25519 or P-256, no dependency) | `local` | yes |  |
| TAPS credentials, attestation and delegation | `taps` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-DEVICE-0001` | check | `mainsdk/device` is imported but `main.yaml` has no `device` section; device calls throw it at runtime (500) |
| `MAIN-DEVICE-0002` | check | `main.yaml` declares `device` but no code imports `mainsdk/device`; nothing is served |
| `MAIN-DEVICE-0003` | check | The `device` section is invalid: unknown driver or key, a value out of range, `authority` written as a literal instead of `secret:NAME`, more than 2 authority keys, an `oidc` entry without `issuer`, `audience` or `tenant`, or a proof not in the list. The app doesn't start |
| `MAIN-DEVICE-0004` | check | `driver: taps` without `@mainsdk/device-taps` installed, or an `@taps/*` import outside that package |
| `MAIN-DEVICE-0016` | check | Production runs with no `device.authority`, or with the development authority key that `mainsdk dev` generated |
| `MAIN-DEVICE-0022` | check | The `device.attest` section is invalid: `require: hardware` or `environment`, `formats`, `roots`, `builds` or `min-security-level` with `driver: local`; an unknown format; a root or builds file that doesn't exist; `every` or `grace` out of range |
| `MAIN-DEVICE-0038` | check | The `device.telemetry` section is invalid: a stream name that doesn't match the pattern, a size or rate out of range, `redact` that isn't a list of paths |
| `MAIN-DEVICE-0039` | check | `device.telemetry.on` names a stream that isn't declared, or a declared stream has no handler |
| `MAIN-DEVICE-0050` | check | The `device.policy` section is invalid: stages that don't increase or don't end at 100%, `hold` outside 5m to 7d, `rollback-if` outside 1% to 50%, `approve` above 2, or an unknown `tighten` direction |
| `MAIN-DEVICE-0066` | check | A `client.commands.handle` handler passes its input to a process or an evaluator (`system.run`, `child_process`, `eval`, `new Function`, `vm`, dynamic `import()`) |
| `MAIN-DEVICE-0067` | check | A command type is unsafe to declare: its input schema has an unbounded string field named `command`, `cmd`, `script`, `code`, `shell`, `exec` or `args`; it uses the `mainsdk.` prefix; it is `destructive` with `approve: 0`; or it is `reaction: true` while `destructive`, or `write` without `undo` |
| `MAIN-DEVICE-0071` | check | The `device.commands` section is invalid: a value out of range, or `ttl` over `max-ttl` |
| `MAIN-DEVICE-0082` | check | The `device.heartbeat` section is invalid: `every` outside 15s to 1h, `late-after` outside 2 to 10, `silent-after` outside 3 to 100 or not above `late-after` |
| `MAIN-DEVICE-0092` | check | The `device.taps` section is invalid: a `did:web` without a key-history log, `TAPS-CS3`, an unknown delegation target, or a status `refresh` outside 5m to 24h |
| `MAIN-DEVICE-0005` | runtime |  |
| `MAIN-DEVICE-0006` | runtime |  |
| `MAIN-DEVICE-0007` | runtime |  |
| `MAIN-DEVICE-0008` | runtime |  |
| `MAIN-DEVICE-0009` | runtime |  |
| `MAIN-DEVICE-0010` | runtime |  |
| `MAIN-DEVICE-0011` | runtime |  |
| `MAIN-DEVICE-0012` | runtime |  |
| `MAIN-DEVICE-0013` | runtime |  |
| `MAIN-DEVICE-0014` | runtime |  |
| `MAIN-DEVICE-0015` | runtime |  |
| `MAIN-DEVICE-0017` | runtime |  |
| `MAIN-DEVICE-0018` | runtime |  |
| `MAIN-DEVICE-0020` | runtime |  |
| `MAIN-DEVICE-0021` | runtime |  |
| `MAIN-DEVICE-0023` | runtime |  |
| `MAIN-DEVICE-0024` | runtime |  |
| `MAIN-DEVICE-0030` | runtime |  |
| `MAIN-DEVICE-0031` | runtime |  |
| `MAIN-DEVICE-0032` | runtime |  |
| `MAIN-DEVICE-0033` | runtime |  |
| `MAIN-DEVICE-0034` | runtime |  |
| `MAIN-DEVICE-0035` | runtime |  |
| `MAIN-DEVICE-0036` | runtime |  |
| `MAIN-DEVICE-0037` | runtime |  |
| `MAIN-DEVICE-0045` | runtime |  |
| `MAIN-DEVICE-0046` | runtime |  |
| `MAIN-DEVICE-0047` | runtime |  |
| `MAIN-DEVICE-0048` | runtime |  |
| `MAIN-DEVICE-0049` | runtime |  |
| `MAIN-DEVICE-0051` | runtime |  |
| `MAIN-DEVICE-0060` | runtime |  |
| `MAIN-DEVICE-0061` | runtime |  |
| `MAIN-DEVICE-0062` | runtime |  |
| `MAIN-DEVICE-0063` | runtime |  |
| `MAIN-DEVICE-0064` | runtime |  |
| `MAIN-DEVICE-0065` | runtime |  |
| `MAIN-DEVICE-0068` | runtime |  |
| `MAIN-DEVICE-0069` | runtime |  |
| `MAIN-DEVICE-0070` | runtime |  |
| `MAIN-DEVICE-0080` | runtime |  |
| `MAIN-DEVICE-0081` | runtime |  |
| `MAIN-DEVICE-0083` | runtime |  |
| `MAIN-DEVICE-0090` | runtime |  |
| `MAIN-DEVICE-0091` | runtime |  |
| `MAIN-DEVICE-0093` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Opt-in subpaths and half-declaration checks | 4 | 1.3 | `device-identity#opt-in` |
| Enrolment by token, approved code and workload OIDC | 4 | 1.3 | `device-identity#enrol-token` |
| Renewal, rotation and revocation | 4 | 1.3 | `device-identity#renew-rotate` |
| Device client and key stores | 4 | 1.3 | `device-identity#client` |
| Simulated devices and the conformance suite | 4 | 1.3 | `device-identity#sim` |
| Attestation levels, nonces and appraisal policy | 4 | 1.3 | `device-attest#policy` |
| Signed ordered telemetry with an offline buffer | 4 | 1.3 | `device-telemetry#ingest` |
| Heartbeat | 4 | 1.3 | `device-health#states` |
| Policy bundles with staged rollout and rollback | 4 | 1.4 | `device-policy#rollout` |
| Tighten-only policy overlays as playbook steps | 4 | 1.4 | `device-policy#overlays` |
| Signed commands, approvals, receipts and undo | 4 | 1.4 | `device-commands#deliver` |
| TAPS credentials, status lists, evidence and delegation | 4 | 1.4 | `device-taps#credentials` |

## MainSDK Cloud

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

| Plan | What it adds |
| --- | --- |
| fleet | Fleet enrols MainSDK apps themselves through this plumbing; the in-app interface stays free |


## Quick start

```yaml title="main.yaml"
schema: 1
device:
  driver: local                 # local | taps (from @mainsdk/device-taps)
  path: /mainsdk/device
  authority: secret:MAINSDK_DEVICE_KEY
  enrol: { proofs: [token, code] }
  attest: { require: none }
  telemetry: { streams: { app.status: {} }, max-batch: 1MB }
  heartbeat: { every: 60s }
  policy: { rollout: { stages: ["5%", "25%", "100%"], hold: 1h } }
  commands: { ttl: 15m, max-targets: 100 }
```

```ts title="app/example.ts"
import { device } from "mainsdk/device";             // not exported from "mainsdk"
import { deviceClient, fileKeyStore } from "mainsdk/device/client";

const { token } = await device.enrolments.create({ group: "lab", uses: 10 });
device.telemetry.on("app.status", async (records) => { console.log(records.length); });

// on the device
const client = deviceClient({ url: "https://app.example.com/mainsdk/device", keys: fileKeyStore("/var/lib/acme") });
await client.enrol({ token });
await client.start();
client.send("app.status", { ok: true });
```

## Calls

Enrolment and identity are built (spec `device-identity`); the other calls follow their own specs (telemetry, policy, commands, health) and are still being built. Server calls are from `mainsdk/device`; device-side calls are from `mainsdk/device/client`. Server calls read the tenant from the context.

`device.policy` and `device.commands` are not loaded with `mainsdk/device`, which stays small for apps that only enrol devices. Call `mountPolicy(device)` from `mainsdk/device/policy` and `mountCommands(device)` from `mainsdk/device/commands` once where the app starts. On the device, `mountPolicy(client)` and `mountCommands(client)` from `mainsdk/device/policy/client` and `mainsdk/device/commands/client` add `client.policy` and `client.commands`. Using one before its mount call throws an error that names the subpath.

| Call | What it does |
| --- | --- |
| `device.enrolments.create({ group, uses, expiresIn })` | Makes a one-time enrolment token (prefix `mde_`) bound to a group, with a number of uses and an expiry. The token is shown once and only its hash is stored. `uses` defaults to 1 |
| `device.enrolments.list({ cursor })` | Lists enrolments a page at a time. It never returns the token |
| `device.enrolments.revoke(id, { reason })` | Revokes an enrolment so its token can't enrol more devices |
| `device.get(id)` | Returns one device: its did, group, labels, state, attestation level, credential expiry and last-seen time |
| `device.list({ group, state, cursor })` | Lists devices a page at a time, filtered by group or by state (`pending`, `online`, `late`, `silent`, `revoked`) |
| `device.update(id, { name, group, labels })` | Changes a device's name, group or labels |
| `device.revoke(id, { reason })` | Revokes a device on the next request on every instance. A revoked id is never reactivated; the device enrols again under a new id |
| `device.handler()` | Returns the `(req: Request) => Promise<Response>` handler for the device routes. The adapters mount it at `device.path` when `device` is declared |
| `device.telemetry.on(stream, handler, { schema })` | Registers the handler that receives a stream's records, in order per device, inside the device's tenant. A batch is acknowledged only after every handler resolves. Storing records is the handler's job. Returns an unsubscribe function |
| `device.summary.get({ group })` | Returns counts only (pending, online, late, silent, revoked, policy failed, attestation lapsed, clock skewed, commands waiting), optionally for one group |
| `device.policy.define(schema, { tighten })` | Declares the schema every policy bundle must pass, and the paths an overlay may tighten and in which direction. Call it once at start, before the first publish |
| `device.policy.publish(content, { target, reason, rollout })` | Publishes a signed policy bundle as the next version for a target (the tenant or one group), rolled out in stages with a hold between them. Content that fails the schema is refused |
| `device.policy.get(version, { target })` | Returns one policy version with its state and the counts of devices that applied it, failed or are pending |
| `device.policy.list({ target, cursor })` | Lists policy versions a page at a time |
| `device.policy.rollback({ target, to, reason })` | Publishes an old version's content again as a new version, so devices' anti-rollback holds |
| `device.policy.tighten({ target, path, value, expiresIn, reason })` | Adds an overlay that makes one declared path stricter for at most 24 hours. An overlay that would loosen a value, or change a path not declared under `tighten`, is refused |
| `device.commands.define(type, { input, risk, approve, undo })` | Registers a command type with its input schema, its risk (`read`, `write` or `destructive`), how many people must approve it and the type that undoes it. Define a type before creating it |
| `device.commands.create(type, { to, input, reason })` | Creates one signed command per target device (a device, a list or a group) and returns the batch. A command expires, and waits for approval when its type needs it |
| `device.commands.get(id)` | Returns one command with its state, result and approvals |
| `device.commands.list({ device, state, cursor })` | Lists commands a page at a time, by device or by state |
| `device.commands.cancel(id, { reason })` | Cancels a command, only before it is delivered |
| `device.commands.revert(id, { reason })` | Sends the command type's `undo` command to the same device, with the original input and result. Refused when the type has no `undo` or the command isn't `done` |
| `device.mcp.tools()` | Returns `device.commands.create` as an action for the MCP server that always waits for a person to approve. An agent can ask, never approve |
| `deviceClient({ url, keys, dir })` | Makes the client for the program that runs on the device (`mainsdk/device/client`). It has `enrol`, `start`, `stop`, `rotate`, `state`, `send`, `flush` and `heartbeat`, and needs no `main.yaml`. Pass `keys` or let `dir` hold the key |
| `fileKeyStore(dir, { alg })` | Key store for Node, Bun and Deno. Writes the key as a PKCS#8 file with mode 0600, in a directory with mode 0700. `alg` is `Ed25519` or `ES256` |
| `memoryKeyStore({ alg })` | Key store held in memory, for tests |
| `webCryptoKeyStore({ db })` | Key store for browsers and kiosks. The key is a non-extractable `CryptoKey` in IndexedDB |
| `softwareEvidence({ software })` | Evidence provider for the `software` attestation level, naming the program and its version. Pass it as the client's `attest` option |
| `simulateDevices(app, { count })` | From `@mainsdk/testing`: enrols simulated devices, each a real `deviceClient` on a memory key store, and drives them on the fake clock |
| `deviceConformance({ driver })` | From `@mainsdk/testing`: the suite every device driver passes |

## Configuration

```yaml title="main.yaml"
device:
  driver: local                       # local | taps (taps from @mainsdk/device-taps)
  path: /mainsdk/device               # where device.handler() is mounted
  audience: acme-fleet                # the aud every device envelope carries; default the project id
  authority: secret:MAINSDK_DEVICE_KEY   # Ed25519 PKCS#8 that signs credentials; a list of 2 rolls it over
  max: 10000                          # devices per tenant; 1 to 1,000,000
  concurrency: 64                     # device requests in flight per instance
  enrol:
    proofs: [token, code]             # token | code | oidc | attested
    token-ttl: 24h                    # default and longest expiresIn for a token; 5m to 30d
  credential:
    ttl: 24h                          # credential lifetime, renewed by the heartbeat; 1h to 30d
    renew-grace: 30d                  # an expired credential can still be renewed with the device key
```

| Key | What it does |
| --- | --- |
| `driver` | `local` runs on did:key with Ed25519 or P-256 and no dependency. `taps` comes from `@mainsdk/device-taps`. |
| `path` | Where `device.handler()` is mounted. The adapters mount it when `device` is declared. |
| `authority` | The key that signs credentials, policy and commands. Always `secret:NAME`, never a literal. Two entries roll it over. |
| `max`, `concurrency` | The device cap per tenant, and the device requests in flight per instance. Over the second, devices get `MAIN-DEVICE-0012` with `Retry-After`. |
| `enrol.proofs` | Which proofs may enrol a device: a one-time `token`, a `code` a signed-in person approves, an `oidc` token, or `attested` evidence. |
| `credential` | How long a credential lives and how long past expiry the device key can still renew it. |

A revoked device is refused on the next request on every instance. A third party that checks a credential offline learns of a revocation only when the credential expires, so `credential.ttl` bounds that window.

## What you get

Enrolment by one-time token, code, OIDC token or attested evidence. A device key made on the device that never leaves it. A did:key identity, a signed credential with renewal, rotation and revocation, every message signed, fresh and bound to one tenant, and a registry in the tenant's region. The device side is `mainsdk/device/client`, which needs no `main.yaml`.

## Examples

```ts title="app/enrol.ts"
import { device } from "mainsdk/device";

const { token, uses } = await device.enrolments.create({ group: "lab", uses: 3 });
const page = await device.list({ group: "lab" });
```

## Drivers

| Driver | Provider | |
| --- | --- | --- |
| `local` | Local (did:key, Ed25519 or P-256, no dependency) | default |
| `taps` | TAPS credentials, attestation and delegation |  |

## What it always does

- Off until declared in main.yaml and imported; no route, table, timer or cost otherwise, and never re-exported from mainsdk
- Device keys are made on the device and never leave it
- Every device message is signed, fresh and bound to one tenant
- Telemetry is delivered once and in order, with redaction on the device and on the server
- A device keeps its last good policy when the app is unreachable and never accepts an older one
- Commands run only registered handlers, expire, and need people's approval when risky; an agent never approves
- No detection rules, endpoint event schemas or code delivery, now or later (ADR-0076)

## Errors and checks

| Code | When |
| --- | --- |
| `MAIN-DEVICE-0001` | `mainsdk/device` is imported but `main.yaml` has no `device` section; device calls throw it at runtime (500) |
| `MAIN-DEVICE-0002` | `main.yaml` declares `device` but no code imports `mainsdk/device`; nothing is served |
| `MAIN-DEVICE-0003` | The `device` section is invalid: unknown driver or key, a value out of range, `authority` written as a literal instead of `secret:NAME`, more than 2 authority keys, an `oidc` entry without `issuer`, `audience` or `tenant`, or a proof not in the list. The app doesn't start |
| `MAIN-DEVICE-0004` | `driver: taps` without `@mainsdk/device-taps` installed, or an `@taps/*` import outside that package |
| `MAIN-DEVICE-0005` | An envelope was refused: payload not in JCS form, unknown payload type, bad signature, unknown key id, `aud` not this app (401) |
| `MAIN-DEVICE-0006` | A request was not fresh: `iat` more than 10 minutes from server time, or the nonce was seen in the last 20 minutes (401, with `serverTime`) |
| `MAIN-DEVICE-0007` | An enrolment proof was refused: the token is unknown, expired, used up or revoked; the code wasn't approved or expired; the OIDC token doesn't match a declared issuer, audience and subject (403). Evidence below a required level is `MAIN-DEVICE-0023` |
| `MAIN-DEVICE-0008` | The device is revoked; every request from it is refused (403, with a signed `revoked` object) |
| `MAIN-DEVICE-0009` | The credential expired more than `renew-grace` ago; the device must enrol again (401) |
| `MAIN-DEVICE-0010` | A rotation was refused: a signature from the old or new key is missing, `prev` doesn't match the current credential, or the new key was used before (409) |
| `MAIN-DEVICE-0011` | The tenant has `device.max` devices that aren't revoked; enrolment refused (403) |
| `MAIN-DEVICE-0012` | The device API is busy or its store doesn't answer; try later (503, `Retry-After`) |
| `MAIN-DEVICE-0013` | The device belongs to another region; the response names the `base` to use (421) |
| `MAIN-DEVICE-0014` | A `device.*` call was refused: no tenant in context, or no `device.manage` or `device.view` permission (403) |
| `MAIN-DEVICE-0015` | A client call was refused: `enrol` on an enrolled client without first deleting its key, or a key store that can't be read or written |
| `MAIN-DEVICE-0016` | Production runs with no `device.authority`, or with the development authority key that `mainsdk dev` generated |
| `MAIN-DEVICE-0017` | Too many enrolment attempts for one token or one address; try later (429, `Retry-After`) |
| `MAIN-DEVICE-0018` | The device or enrolment doesn't exist in the caller's tenant; a device of another tenant is reported the same way (404) |
| `MAIN-DEVICE-0020` | Evidence was refused: format not accepted, binding or nonce wrong or older than 60 seconds, a chain to a root the tenant doesn't accept, failed boot verification, a security level under `min-security-level`, or a measurement not in `builds`. The device gets the code only; the server log names the check (403) |
| `MAIN-DEVICE-0021` | A device missed its re-attestation past `grace`; its level dropped to `none` (logged, `device.attestation.failed` signal) |
| `MAIN-DEVICE-0022` | The `device.attest` section is invalid: `require: hardware` or `environment`, `formats`, `roots`, `builds` or `min-security-level` with `driver: local`; an unknown format; a root or builds file that doesn't exist; `every` or `grace` out of range |
| `MAIN-DEVICE-0023` | An enrolment, a renewal, a policy target or a command needs a higher attestation level than the device holds (403, with `need` and `have`) |
| `MAIN-DEVICE-0024` | The device's TCB is `out-of-date` past its grace or `revoked`; renewal refused until it re-attests on a fixed platform (403) |
| `MAIN-DEVICE-0030` | `client.send` refused a record: the stream isn't declared in the client or by the server, the record isn't a JSON object, it is over `max-record`, or it fails the stream's schema on the device |
| `MAIN-DEVICE-0031` | A batch is over `max-batch` or holds more than 1,000 records (413) |
| `MAIN-DEVICE-0032` | A batch is out of order: `seq` isn't the next one, or `prev` doesn't match the last accepted batch (409, with `expect`) |
| `MAIN-DEVICE-0033` | A record was rejected: unknown stream, over its stream's `max-record`, or it fails the stream's schema on the server |
| `MAIN-DEVICE-0034` | A record was rejected because the detectors found a secret, or personal data in a stream with `personal: false` |
| `MAIN-DEVICE-0035` | Telemetry is rate limited: the device's `rate` or the tenant's `daily` bytes is reached (429, `Retry-After`) |
| `MAIN-DEVICE-0036` | A handler threw or took longer than 10 seconds; the batch wasn't acknowledged and will come again (503) |
| `MAIN-DEVICE-0037` | The client's buffer is full or holds records older than `queue.age`; the oldest were dropped (logged once an hour with counts per stream) |
| `MAIN-DEVICE-0038` | The `device.telemetry` section is invalid: a stream name that doesn't match the pattern, a size or rate out of range, `redact` that isn't a list of paths |
| `MAIN-DEVICE-0039` | `device.telemetry.on` names a stream that isn't declared, or a declared stream has no handler |
| `MAIN-DEVICE-0045` | A publish was refused: no `device.policy.define`, content that fails the schema, an unknown group, or `minClient` that isn't a version (400) |
| `MAIN-DEVICE-0046` | An overlay was refused: its path isn't under `tighten`, or the value wouldn't tighten the value in force (400) |
| `MAIN-DEVICE-0047` | The device refused a bundle: bad signature, wrong `aud` or tenant, a version not above the one it holds, an overlay that loosens, or a `minClient` above its own; it kept its last good policy and reported why |
| `MAIN-DEVICE-0048` | The device's apply handler threw; it kept its last good policy and reported `failed` with the error code |
| `MAIN-DEVICE-0049` | A rollout stopped: the failed share passed `rollback-if`; the previous content was published again as a new version (logged, audited, `device.policy.rolledback` signal) |
| `MAIN-DEVICE-0050` | The `device.policy` section is invalid: stages that don't increase or don't end at 100%, `hold` outside 5m to 7d, `rollback-if` outside 1% to 50%, `approve` above 2, or an unknown `tighten` direction |
| `MAIN-DEVICE-0051` | The publish waits for approval; it applies when the people `approve` asks for have approved (202) |
| `MAIN-DEVICE-0060` | A create was refused: the type isn't defined, the input fails its schema, the target is empty, or `expiresIn` is over `max-ttl` (400) |
| `MAIN-DEVICE-0061` | The create targets more than `max-targets` devices without `confirm` equal to the count (409, with `count`) |
| `MAIN-DEVICE-0062` | The command waits for approval (202); an app agent's approval was refused |
| `MAIN-DEVICE-0063` | The device refused a command: bad signature, wrong `aud`, tenant or device, expired, an id it already ran, a type it hasn't registered, a risk that differs from its handler's, or fewer approvals than its floor; the receipt says `refused` |
| `MAIN-DEVICE-0064` | The command expired before it was delivered or before a receipt came back |
| `MAIN-DEVICE-0065` | The handler threw, timed out or returned a result that fails its schema; the receipt says `failed` |
| `MAIN-DEVICE-0066` | A `client.commands.handle` handler passes its input to a process or an evaluator (`system.run`, `child_process`, `eval`, `new Function`, `vm`, dynamic `import()`) |
| `MAIN-DEVICE-0067` | A command type is unsafe to declare: its input schema has an unbounded string field named `command`, `cmd`, `script`, `code`, `shell`, `exec` or `args`; it uses the `mainsdk.` prefix; it is `destructive` with `approve: 0`; or it is `reaction: true` while `destructive`, or `write` without `undo` |
| `MAIN-DEVICE-0068` | A cancel or revert was refused: the command was already delivered, its type has no `undo`, or it isn't `done` (409) |
| `MAIN-DEVICE-0069` | A receipt was refused: bad signature, an unknown command, or a digest that doesn't match the command sent (400) |
| `MAIN-DEVICE-0070` | The device's command-log head differs from the one the server computes from its receipts (security event `device.command_log_mismatch`, high) |
| `MAIN-DEVICE-0071` | The `device.commands` section is invalid: a value out of range, or `ttl` over `max-ttl` |
| `MAIN-DEVICE-0080` | A heartbeat was refused: its `seq` isn't above the last one (401) |
| `MAIN-DEVICE-0081` | The device's clock is more than 5 minutes off the app's; the offset is recorded and corrected for (logged once a day per device) |
| `MAIN-DEVICE-0082` | The `device.heartbeat` section is invalid: `every` outside 15s to 1h, `late-after` outside 2 to 10, `silent-after` outside 3 to 100 or not above `late-after` |
| `MAIN-DEVICE-0083` | A device went silent (logged at info, `device.silent` signal) |
| `MAIN-DEVICE-0090` | The status list couldn't be refreshed; the last list is used until its `validUntil`, after which third parties treat credentials as unverifiable, while the app's own checks go on (logged once per outage) |
| `MAIN-DEVICE-0091` | A delegation chain or approval decision on a command or publish is invalid: a broken chain, authority that widens down the chain, a revoked or expired link, or an approval by a key not in the tenant's DID document; the server refuses to send it and a device refuses to run it |
| `MAIN-DEVICE-0092` | The `device.taps` section is invalid: a `did:web` without a key-history log, `TAPS-CS3`, an unknown delegation target, or a status `refresh` outside 5m to 24h |
| `MAIN-DEVICE-0093` | An agent passport was refused: an operator not listed, a passport not countersigned by the tenant, or a heartbeat reporting a model or version the passport doesn't name (TAPS-ATTEST §3: a change needs a new passport) |

See [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

```yaml title="main.yaml"
device:
  driver: taps                        # needs @mainsdk/device-taps
```

The `taps` driver issues the TAPS credential form from the same device record. Nothing else in your code changes.

## Testing

`simulateDevices(app, { count })` from `@mainsdk/testing` enrols simulated devices, each a real `deviceClient` on a memory key store, and drives them on the fake clock. `deviceConformance({ driver })` is the suite every driver passes.

## Next

- [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)
- [Permissions](https://mainsdk.dev/docs/interfaces/access)
