---
url: https://mikrojs.dev/registry-spec.md
description: >-
  The contract a server must satisfy to publish builds, enroll devices, and
  serve update offers to Mikro.js devices
---

# OTA Registry Spec

This is the contract for building an **OTA registry**: the server that hands out app updates to
Mikro.js devices. You run it on whatever backend you already have. The device firmware, the
`mikro/ota` runtime module, and the `mikro ota pack` / `mikro ota push` / `mikro ota release` /
`mikro ota enroll` commands are all part of the Mikro.js repo. This document describes what your
server has to do for them to work.

The spec has two parts:

* **Required.** The endpoints that the fixed Mikro.js code (the CLI and the runtime) calls.
  Implement these and the whole toolchain works against your server unchanged.
* **Advisory.** The check-in protocol that the reference device app and the reference registry
  use. Adopt it unless one of the [reasons to replace it](#advisory-the-reference-check-in-protocol)
  applies. The device side of a check-in is application code that you write and own, so you can
  replace this half entirely, as long as the required half still holds.

## Background

Mikro.js is a JavaScript runtime for microcontrollers. Its over-the-air (OTA) update system
replaces a device's **app build** (the compiled JavaScript, shipped as a gzipped tar) without a
cable. The device downloads a build over whatever connection its app has, and the firmware
installs it on a trial. If the trial fails, the firmware rolls back automatically.

A **registry** is the server in that system. It stores builds, answers device check-ins with an
update offer (or with nothing), records what each device is running, and provides endpoints for
publishing builds and enrolling devices.

**Base URL and the `/api/v1` prefix.** The registry serves its API under `/api/v1`. Clients are
configured with only the registry's origin: on the workstation through `.mikro/registry.json`
(written by `mikro ota setup`) or `--registry`, and on the device through enrollment, where it
is stored next to the update key. The clients add `/api/v1` themselves. Every endpoint path in
this document is relative to `/api/v1`.

**The identity document.** `GET` the base url (with `Accept: application/json`) returns a small
JSON document identifying the registry:

```json
{"name": "mikro-registry", "version": "<build id>"}
```

`name` must be exactly `mikro-registry`; that is how `mikro ota setup` recognizes a registry before
it commits a token, so a typo'd or wrong url is caught up front rather than at the first publish or
enroll. It warns and asks whether to continue (it does not hard-fail) when the url answers without
this document, since a proxy could rewrite the root. `version` is an optional build identifier (for
example the deployment's commit sha); it is informational, and clients never key on it. Serve a
human page to browsers at the same url if you like; API clients ask for JSON.

## Required: the contract

### 1. The build artifact

`mikro ota pack` produces a gzipped tar. Its root holds `mikro.app.json` plus the `app/` tree.
The manifest is `{app, version, firmwareVersion, bytecodeVersion}`, with optional source fields
`{repository, directory, commit, dirty}` and, when the app declares a config schema, a
`configSchema` (described under [the publish endpoint](#_2-the-publish-and-release-endpoints))
plus `configDefaults`: the partial default config the schema materializes (every field a default
fills; possibly `{}`), which the device spreads a served overlay over and reads on its own when
it holds no document (see [Config sync](#config-sync)).
The `firmwareVersion`/`bytecodeVersion` fields record
what the build was compiled against; they are not a policy about who may run it. The source
fields, when present, are captured at pack time (`repository` and `directory` from
`package.json`, `commit` from HEAD, `dirty` when the repository had uncommitted changes) so a
build carries a link back to its source; a registry may store and surface them, and MUST ignore
them if it does not.

They live inside the manifest, not merely on the upload, so that a build packed in one CI stage
and pushed from another describes the source it was packed from rather than whatever project the
pushing machine sits in. A `push --tarball` holds only these bytes, and for the commit there is
no second chance: nothing else can recover it afterwards.

The cost is that the checksum covers them, so the same app tree packed at two commits is two
builds, and re-pushing one version from a moved HEAD is a checksum conflict rather than an
idempotent no-op.

`dirty` is repository-wide. It says the build *may* not match `commit`, not that it does not: an
uncommitted change to a file outside this app sets it too.

The build's identity is the **SHA-256 of the `.tgz`, in lowercase hex**. `mikro ota pack`
computes it, and the device firmware verifies it after downloading. Store it and serve it as the
offer's `checksum`. If it does not match, every download fails verification.

### 2. The publish and release endpoints

`mikro ota push` uploads a build here. `POST /builds`, `multipart/form-data`:

| Part              | Type             | Value                                                                                                 |
| ----------------- | ---------------- | ----------------------------------------------------------------------------------------------------- |
| `app`             | string           | application lineage, from the build's `mikro.app.json`                                                |
| `version`         | string (semver)  | app version, from the build's `mikro.app.json`                                                        |
| `checksum`        | string           | SHA-256 (lowercase hex) of the `.tgz`                                                                 |
| `size`            | string           | build size in bytes (decimal string)                                                                  |
| `firmwareVersion` | string (semver)  | from the build's `mikro.app.json`; must be valid semver                                               |
| `bytecodeVersion` | string           | from the build's `mikro.app.json` (decimal string)                                                    |
| `note`            | string, optional | free-text note about the build (omitted when unset)                                                   |
| `create`          | string, optional | truthy flag (`--create`): create an unknown app on first publish instead of rejecting it              |
| `channel`         | string, optional | serve the stored build on this channel (`push --release <channel>`); absent stores it without serving |
| `repository`      | string, optional | source repository URL (http(s), no credentials), from the build's `mikro.app.json`                    |
| `directory`       | string, optional | app's path inside `repository` (relative, no `..`); only valid with `repository`                      |
| `commit`          | string, optional | commit SHA the build was packed from (hex), from the build's `mikro.app.json`                         |
| `dirty`           | string, optional | truthy flag: the repository was dirty at pack time; only valid with `commit`                          |
| `configSchema`    | string, optional | serialized config schema (JSON), from the build's `mikro.app.json`                                    |
| `build`           | file             | the `.tgz` (content-type `application/gzip`)                                                          |

Auth is `Authorization: Bearer <token>`. The CLI never parses the token, so any token scheme
works; `--token` and `MIKRO_OTA_TOKEN` are sent verbatim.

All four are read back off the artifact, so a `--tarball` push sends exactly what the pack
recorded. Each is independently optional: a project with a `repository` but no git repo publishes
one without the other. A registry with an app-level record may prefer to keep `repository` and
`directory` there, using the first publish that carries them as the seed, since they describe the
app rather than the build.

**The source fields are asserted by the publisher, not proven.** Nothing verifies them against
the uploaded `.tgz`, so they attest to nothing on their own: anyone holding a publish token
chooses them. Validate `repository` as an http(s) URL with no userinfo (a tokenised remote in a
publisher's `package.json` would otherwise be stored and shown), reject a `directory` that is
absolute or contains `..`, and render the link as untrusted outbound content
(`rel="noopener noreferrer"`) rather than as a verified provenance claim.

**Storing a build and serving it are separate steps.** A build is stored the moment it uploads,
but it is offered to a device only once a **channel** points at it. A channel is a movable pointer
to a build, keyed on `(app, channel, firmwareRange)`; the build record stays tag-less (its
checksum is still its primary key), so one build can be served on more than one channel. The
optional `channel` part decides serving: absent, it is served to nobody (a bare `push`); present,
that channel is pointed at it. `channel=main` promotes the
build (it sets `promotedAt`, the pre-channels mechanism described under
[Offer selection](#offer-selection)); any other name writes a channel pointer. The default channel
is `main`, so a build or device with no channel reads as `main`. Channel names must match
`^[a-zA-Z0-9][a-zA-Z0-9._-]*$` and be at most 64 characters; an invalid one is a `400`.

A build's **firmware range** is derived from its `firmwareVersion` at publish time: the left-most
non-zero segment, which is where npm caret semantics place breaking changes. That is `1` for a
`1.x` version, `0.17` for `0.17.x`, and `0.0.3` for an exact `0.0.3`. Two builds share a range
exactly when offer rule 3 draws the same caret boundary for both. The registry derives the range;
a client never sends it. One mikro toolchain release compiles against exactly one
`(firmwareVersion, bytecodeVersion)` pair, so the range also identifies the toolchain line a build
came from; `bytecodeVersion` stays on the build record as metadata and as the sanity gate in offer
rule 1.

Releases are immutable. Publishing the same `(app, version, firmwareRange)` again with the
same checksum is a success (a `2xx`), so a CI retry is safe. Publishing it with a different
checksum is a `409`. Keying on the range rather than on `bytecodeVersion` is what lets one version
carry a variant per firmware line: two toolchains, say mikro `0.16` and `0.17`, often share a
bytecode version, and a bytecode key would reject the second publish as a conflicting re-publish,
blocking exactly the transition the variant mechanism exists for. On any failure, respond with a
non-`2xx` status and a text body; the CLI shows the status and the body to the user.

A successful publish or release response is JSON and MAY carry `warnings`, an array of strings
the CLI prints verbatim, one line each. This is where the advisory reports under
[Schema changes between releases](#config-sync) travel; a registry with nothing to say omits the
field.

**The config schema.** An app that takes operator-editable config declares a schema for it, and
`pack` serializes that schema into `mikro.app.json` as `configSchema`. `push` then sends it as
the `configSchema` part the same way the other manifest fields travel: the manifest is the
record, and the part spares the registry from unpacking the tarball. A registry that implements
[config sync](#config-sync) stores it; one that does not MUST ignore it, as with the source
fields.

The serialized form is the `mikro/schema` AST as JSON: plain nodes discriminated by `kind`, the
structural fields of the [schema language](/api/schema), and annotations carried as extra
properties on a node.

Annotations divide in two, and the division is normative. **Display annotations** (`title`,
`description`, `mask`) never change what validates: ignore any you do not recognize, which is
how new ones are added. **Constraints** (`min`, `max`, `integer`, `minLength`, `maxLength`,
`minItems`, `maxItems`, `format`, `unit`) do change what validates, and you MUST NOT ignore one
you do not recognize: doing so means accepting a value the author ruled out. Reject an
unrecognized `format` or `unit` at publish, where whoever can fix it will see it.

Enforce every constraint you accept when validating an operator's config. The overlay `PUT` is
the only place a constraint ever meets an operator-supplied value, because a config schema never
reaches a device: whatever a device stores arrives already validated. (The reference runtime does
check bounds wherever it runs, for the app's own schemas, but that is beside the point here.) The
publishing toolchain sees only the author's own defaults. A registry that stores constraints
without enforcing them protects nothing.

**Never normalize annotation text.** Compare and hash `title` and `description` exactly as
authored. Schema identity is a structural comparison and the config `rev` is a hash. An NFKC pass rewrites
the micro sign to Greek mu, and superscript digits to plain ones, so normalizing anywhere makes an
unchanged republish conflict with nothing to show for it. Unit keys are ASCII precisely
so this cannot bite them.

Reject with a `400`, at publish, what
config sync could never serve: a schema containing `unknown()`, an `optional()` wrapping an
object or an array (an overlay needs every absence to mean exactly one thing; see
[Config sync](#config-sync)), a node kind you do not know, a `default` in a position where it
could never apply (on an `object()`, on an `optional()` or the node it wraps, or anywhere
inside an `array`, `tuple`, `union`, or `taggedUnion`, each of which is filled from its own
whole-value default or not at all), an empty `union` or a branchless `taggedUnion`
(unsatisfiable, so they could only fail at serve), `__proto__`, `constructor` or `prototype`
as a field name or branch tag (writing one through `out[key]` reaches the prototype rather
than a property), a serialized size over 32 KiB,
nesting more than 8 levels deep, or a schema whose materialized defaults alone encode over the
4 KiB effective-document cap. The defaults are part of every effective document, so with
defaults that big every authored config would exceed the cap and rule 5 would pause every
rollout, discovered one device at a time. Publish is where the author
can still rename a field or add a default; storing a schema and failing at serve time helps
nobody.

Annotations count toward release identity like anything else in the schema, so correcting a typo
in a `description` and republishing the same version is a conflict. That is deliberate: a version
names one source tree. Say it in the conflict, though. Report which fields differ, and say when
the only differences are annotations. Otherwise an author who changed one word gets back
"different config schema" and nothing else.

The schema belongs to the release, not the build. Every variant of `(app, version)` is packed
from the same source, so each carries the same schema, and a publish whose `configSchema`
differs from the one already stored for `(app, version)` is a `409`, exactly like a differing
checksum. What may change between releases, and what to tell the operator when it does, is
covered under [Schema changes between releases](#config-sync).

A minimal registry may ignore `create`, for example by auto-creating apps or serving a single
implicit app. It exists so that a multi-app registry can refuse to start a new app lineage from
a typo in the app name.

Cap request bodies. Publish is the only route that carries more than a few kilobytes. A registry
that buffers bodies has to enforce the limit while reading and answer `413` once it is passed,
not after routing, so that an unauthenticated POST cannot exhaust memory. The reference `serve`
adapter defaults to 16 MiB (`maxBodyBytes`). The reference registry also caps its own routes,
since a bare `{fetch}` export deployed to another host has no adapter ceiling at all: 16 KiB for
the authenticated JSON and CBOR routes (enroll, release, the device config write, and check-in)
and 64 KiB for the unauthenticated login routes, each answering `413` once passed.

**Pointing a channel at an already-stored build.** `mikro ota release` uses this to serve a build
that is already uploaded. `POST /releases`, JSON body `{app, version, channel}`,
`Authorization: Bearer <token>`, app-scoped exactly like publish (an app-scoped token that names
another app is a `403`). It points `channel` at every already-stored build for `(app, version)`
(one build per firmware range, since a build pins the firmware line it was compiled against), and
responds
`{ok: true, released: <count>}`. It answers `404` when no such build exists. `channel` follows the
same rule as at publish. One operation covers two jobs: graduation (point a second channel at a
proven build) and rollback (point a channel at an older one); the direction does not matter. The
two endpoints split on upload versus serve: `push --release <channel>` uploads a new build and
points a channel in one call; `release` points a channel at a build already stored.

**Compatibility.** Absent channel reads as `main` everywhere, so existing devices and stored builds
keep working with no migration pass: today's fleet is "everyone on `main`", and a build promoted to
`main` serves exactly as it did before channels. Two changes to the previous contract follow from
decoupling storage from serving, both to be stated for anyone tracking the CLI:

* A bare `push` no longer serves the build. The previous `mikro ota publish` uploaded and promoted
  in one step; that is now `push --release main` (or `release <version> main`), and a `push` with no
  channel uploads without serving.
* The CLI command `publish` is renamed to `push`.

Re-keying from `bytecodeVersion` to the firmware range needs no migration for `main`: it is derived
from `promotedAt` on build records, and a stored build's range derives from its `firmwareVersion`
on read. A registry holding named-channel pointers keyed by bytecode version must re-key them,
either by joining each pointer's checksum to its build and deriving the range, or by re-pointing
each channel (`release` is idempotent). Stored builds stand as they are; the new key only changes
which future publishes conflict.

### 3. The offer object and the download

The device passes a check-in response body straight to `ota.settle()`. Anything that is not
a valid offer is read as "no update": an empty body, `null`, `{}`, or a `204` all mean that. A
valid offer is:

| Field      | Type             | Meaning                                                          |
| ---------- | ---------------- | ---------------------------------------------------------------- |
| `url`      | string           | HTTPS URL of the `.tgz` build (path must end `.tgz`)             |
| `checksum` | string           | SHA-256 (lowercase hex), the device verifies after download      |
| `size`     | number           | Build size in bytes                                              |
| `version`  | string, optional | The release's app version; shown to operators, unread by devices |

An offer says **what to fetch and how to verify it**, and nothing else. It carries no
`firmwareVersion` or `bytecodeVersion`. Those fields choose which build to offer, and only the
registry can act on them, because only the registry holds the other builds. A device that is
handed a build it cannot run can say "not this one" but never "give me that one instead", so the
choice belongs entirely to the registry (see [Offer selection](#offer-selection)). A registry
that sends the extra fields anyway does no harm: the device ignores fields it does not know.

Because the choice sits with the registry, a custom registry has to implement the offer rules or
its devices will be handed builds they cannot run. A device does not decline such a build
quietly. It installs it, the install fails, the trial rolls it back, and the next check-in
reports the failure through `lastInstall`, which takes the build off that device's list. That
recovery works, but it costs a download and a failed install each time, so it is not a
substitute for choosing correctly.

The device rejects an offer whose `url` path is not `https://….tgz`, whose `checksum` is empty,
or whose `size` is not a positive integer; `ota.settle()` and the built-in client apply the
same rules. It does not check the `url`'s host. The check-in is
authenticated, so the registry is trusted to say where the build lives, including a CDN or object
store on a different host. (`allowInsecure` lets development setups accept `http`.)

The download must serve the exact bytes at `url`. **Range requests are recommended**
(`Accept-Ranges: bytes`, then `206` with `Content-Range`). The reference client streams the build
straight to flash and resumes an interrupted download from what it already has, so a dropped
connection costs only the remaining bytes. Answering `200` with the whole build is also correct;
the client skips the prefix it already holds. Static storage (S3, a CDN, nginx) provides ranges
for free.

**Authenticating the download.** The download callback is app code, so the device can attach
whatever the URL needs. A registry may pick any of these:

* **The device update key**, which is what the reference registry uses. The download takes the
  same update key as the check-in and answers `401` before revealing whether a build exists, so
  de-enrolling a device stops it downloading. The reference client sends the update key only when
  the URL is on the same origin as the registry it enrolled against, so a URL pointing elsewhere
  never receives it.
* **A signed URL** issued at check-in: short-lived, with the signature in the query string.
  Nothing long-lived is sent, revocation is simply not issuing another, and it works when builds
  live in object storage on a different host. No device change is needed, because the URL is
  opaque to the device.
* **The checksum as an unauthenticated capability.** Simplest, and weakest. Checksums are not
  secret: `mikro ota push` prints them and they end up in CI logs. Anyone who learns one can
  fetch that build forever, and a de-enrolled device keeps its access.

Whichever you choose, scope the download to the requesting device's app. Authenticating the
device only proves the caller is *a* device. On a registry that serves more than one app, that
alone would let any enrolled device fetch any other app's build using a checksum from a log.

### 4. The enroll endpoint (optional)

`mikro ota enroll` uses this to set a device up from a workstation. The CLI reads the hardware id
off the connected device, calls the registry, and writes the returned update key back to the
device. A registry that issues update keys some other way can skip this endpoint; users then
provision with `mikro ota enroll --update-key <secret>`.

`POST /devices`, JSON body `{deviceId, name?, app?, channel?}`, response `{device: {...},
credential: string}`. The update key is returned exactly once, so store only a hash of it. Auth is
an opaque bearer token. Any tenancy (org, project) is resolved from the token, never from the URL
path, so the CLI uses one path shape against every registry.

`name` is optional on the wire, but `mikro ota enroll` always sends one. It uses an explicit
`--name` if given, otherwise the name already on the device, otherwise a default it derives from
the `deviceId` and writes to the device at the same time. So an enrolled device has a real name
on both sides, and a registry never has to reproduce that derivation to show the same string the
CLI shows (see [Name sync](#name-sync)).

`app` binds the device to one app lineage, the only app it will ever be offered builds for (see
[Offer selection](#offer-selection)). An app-scoped token binds to its own app and rejects a
conflicting `app` with `403`; an unscoped token may name any app. `app` is optional on the wire,
but enrollment is the only place a binding is ever set, so **always send it**. A device enrolled
without one is offered nothing until it is re-enrolled with an app.

`channel` puts the device on a named release channel (see [Offer selection](#offer-selection)). It
is optional and defaults to `main`, so a device enrolled without one, like every device enrolled
before channels existed, is on `main`. The channel is registry-side state the device never learns:
it checks in, and the registry offers whatever its channel points at. Only a channel other than
`main` is stored, and its name follows the same rule as at publish
(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`, at most 64 characters).

If the `deviceId` is already enrolled, respond `409`. The CLI then offers
`POST /devices/:deviceId/key` (through `--re-enroll`), which returns `{credential}`: a
fresh update key that invalidates the old one the moment it is returned.

Both this endpoint and `GET /devices` are scoped by the token, not just gated by it. An
app-scoped token lists only its own app's devices, and rotating the update key of a device in
another app returns `404`, not `403`, so the token cannot be used to find out that other apps'
devices exist.

### 5. Browser login (optional)

`mikro ota setup` uses this to get a token without the user pasting one. The CLI asks the
registry for a login session, sends the user to a browser to log in and approve, then polls for
the token. Registries with static or externally issued tokens skip this, and the CLI falls back
to prompting for a token.

`POST /auth/sessions`, unauthenticated. The JSON body may carry context for the approval page:
`{app?: string}`, the app lineage of the project the CLI is running in (from its `package.json`
name). The response is served `Cache-Control: no-store`:

| Field       | Type             | Meaning                                                                                           |
| ----------- | ---------------- | ------------------------------------------------------------------------------------------------- |
| `loginUrl`  | string           | Absolute URL the user opens in a browser to approve. Carries no session; the same value each time |
| `code`      | string           | Session code, the secret used to poll; returned once                                              |
| `userCode`  | string           | Short code the CLI displays; the user types it into the approval page                             |
| `expiresIn` | number           | Seconds until the session expires                                                                 |
| `interval`  | number, optional | Minimum seconds between polls (default 3)                                                         |

`GET /auth/sessions/:code/token` returns `202` while the login is pending, `200` with
`{token: string}` exactly once when it is approved (the registry then discards the session), and
`404` for an unknown, expired, denied, or already-claimed code (the CLI starts over). Served
`Cache-Control: no-store`.

**The two codes do different jobs, and both are needed.** `code` is the secret the CLI polls
with; only the CLI has it. `userCode` is short and easy to type, and the approval page refuses to
approve until the operator types a matching one. Without `userCode`, the session-creation
endpoint would let anyone start a session, send an operator the `loginUrl`, and poll for the
token that the operator's approval mints. `userCode` is what ties the browser doing the approving
back to the CLI that started the session, because only that CLI's terminal shows it.

The CLI prints `userCode` in its own terminal, next to `loginUrl`, so only the operator who
started the session ever sees it. Given that, a registry must:

* Generate `userCode` from an alphabet with no ambiguous characters (no vowels, no `0`/`O`, no
  `1`/`I`/`L`), and compare it case-insensitively, ignoring whitespace and separators, so it
  survives being read off one screen and typed into another. The CLI shows the code exactly as
  sent. The leniency is for what the operator types, not permission to reformat a value that is
  sent back to the registry.
* Put **nothing session-specific in `loginUrl`**: not `code`, not `userCode`, not a per-session
  handle. It is a constant page, and the code the operator types is what selects the session. A
  URL is a poor place for a secret: it lands in server access logs, browser history, and the
  `Referer` header on anything the page links to. Putting the secret in the URL fragment only
  hides it from the server, not from the browser. This is RFC 8628's `verification_uri`. That
  spec also defines `verification_uri_complete`, with the code embedded, and warns against it for
  the same reasons.
* Because the code is now the lookup key, generate one that no live session is already using.
* Reveal nothing until the code is entered. The page should say what approving would grant (which
  app, what the token can do) so that a forwarded link looks suspicious. But showing that to
  anyone who merely opens the URL reveals which app the CLI is authorizing, so ask for the code
  first, then show the grant and ask for the credential.
* Rate-limit a wrong code the same as a wrong credential, so the code cannot be used to probe for
  live sessions.
* Cap how many logins can be pending at once, answering `429` with `Retry-After` past the cap.
  Starting a session is unauthenticated, so without a cap anyone can grow that state without
  bound. Look sessions up by an index rather than scanning them, or the unauthenticated endpoint
  becomes quadratic in the number of pending logins.
* Return the same message for a wrong code and a wrong password, so neither reveals which was
  wrong.
* Rate-limit approval attempts per client address **and** globally, with backoff. The reference
  implementation allows three failures per address before backing off, doubles the delay up to a
  minute, and forgets a count after an hour idle; it answers `429` with `Retry-After` while a
  bucket is backing off. Count an attempt when the request is admitted, not when it fails.
  Everything in between is asynchronous, so a limiter that counts at the end limits round trips
  rather than guesses, and a single burst of concurrent requests slips through.
* Count a wrong credential against the global bucket **even when the code names a live session**.
  Starting a session is unauthenticated, so an attacker can mint their own code and guess the
  credential behind it. The per-address bucket alone does not stop this, because addresses are
  cheap and an attacker with a range of them avoids the backoff by rotating. The global bucket is
  what a distributed guess runs into.
* Consult the global bucket only for a wrong credential. A correct credential, and the CLI's
  high-entropy `code` poll (`GET /auth/sessions/:code/token`, not the `userCode` a person types on
  the approval page), must pass whatever state the bucket is in. Otherwise a sustained guess keeps
  the login flow closed for every operator, and the exemption gives an attacker nothing, since
  using it means already knowing the credential.

Per-address limiting needs the peer address, and a `fetch` handler is not given one: a `Request`
has headers, not a socket. The host adapter has to supply it. The reference Node adapter reads
the connection's remote address and sets it as an internal header (`mikro-client-ip`), overwriting
anything the client sent so it cannot be spoofed, and the registry reads the address from there. A
custom adapter (a Worker, a different server) has to do the same from whatever its platform
exposes, such as `request.cf` or a trusted `X-Forwarded-For` hop. Without it, every caller shares
one bucket and per-address limiting does nothing. The global bucket still applies, so guessing is
still bounded, but the per-address defence is only as good as the adapter makes it.

**The rate-limiting rules above are the reference's realization of a smaller, model-independent
invariant.** State it directly if your credential model is not a shared password. There are two
dimensions. A **per-caller** dimension (the client address here, or an account, or whatever the
platform gives you) may throttle anyone. A **global** dimension may throttle only a caller who has
not proven the secret, a wrong guess: a caller who presents the correct secret passes whatever
state the global dimension is in, and clears it. Charge the per-caller dimension when a request is
admitted, before the async check; charge the global dimension only on a proven wrong guess.
Everything above follows from that.

Name the secret abstractly. The reference has two guessable secrets on the approval page, the short
`userCode` and a shared password, and bounds both. If your second factor is not a guessable secret,
for example when the approver is an already-authenticated session and there is no password to be
"wrong", then the `userCode` is your only guessable secret, and the global dimension must bound its
lookup: treat a wrong `userCode` the way this section treats a wrong password. What never needs the
global bound is the CLI's `code` poll: that `code` is 128-bit, so guessing it is not a threat the
global dimension defends against.

The exemption for a correct secret is safe **because** the global bound already makes guessing the
secret infeasible, not on its own. Thirty-odd free attempts and doubling-to-a-minute backoff over a
20-bit-or-larger space is effectively unguessable, so "using the exemption means already knowing the
secret" holds. Pick a small `userCode` space or a large free-attempt count and that premise breaks,
and the exemption becomes a real hole.

One residual is intended: the secret is checked before the global bucket is consulted, so a fresh
caller always gets exactly one guess before a global `429` can apply. That is fine (one guess over a
large space reveals nothing) and is what lets a correct secret bypass a backing-off bucket. Do not
"fix" it by moving the global check to admission, which would break that exemption. This is the same
reasoning as RFC 8628 §5.1's guidance on brute-forcing the user code.

Browser login mints **a single token**, not one per operation, and it has to authorize
everything the CLI does over a connection: publishing and releasing builds (§2) and enrolling
devices (§4). The
CLI never parses it, like every other token here. The `app` context lets the registry scope it to
one project: a token good for publishing that one app and enrolling that app's devices, rather
than inheriting the approving user's full authority.

**Tokens expire and can be revoked.** A minted token carries an expiry (the reference
implementation uses 90 days) and is refused past it. A stored token with no readable expiry is
treated as expired, not as eternal. Record a coarse last-used time so an operator can see which
tokens are still live. The reference implementation exposes these to the registry secret only (a
minted token gets `403`):

* `GET /auth/tokens`, returning `{tokens: [{tokenHash, app?, createdAt, expiresAt, lastUsedAt?}]}`.
  It returns hashes, never tokens: the hash is how a token is named after it has been handed out.
* `DELETE /auth/tokens/:tokenHash`, returning `200`, or `404` if there is no such token. The token
  stops working immediately.

Because a token can be revoked, the CLI treats a `401` on a token that was working as "this token
is gone" and points the user back at `mikro ota setup`, rather than retrying it as a temporary
failure.

**Discovery is the endpoint itself.** `mikro ota setup` sends `POST /auth/sessions`, and a `404`
or `405` means the registry does not support browser login. No `401` anywhere needs a special
shape: a rejected command points the user at `mikro ota setup`, which finds out again whether
login is supported on its next run.

Security notes. The `code` is the only thing needed to claim the token, so it must be
high-entropy (128 bits or more), short-lived (minutes), and known only to the CLI that started the
session. The login page should show what is being authorized (which registry, and that a CLI
requested it) so that a forwarded `loginUrl` looks suspicious to the person approving. The page
must not be cached (`Cache-Control: no-store`). Compare submitted secrets in constant time, or by
comparing digests of both sides rather than the secrets directly.

## Advisory: the reference check-in protocol

The device side of a check-in is application code, so none of this section binds you. It is what
the reference client and the reference registry speak, and what the hosted mikro-registry builds
on. Replace it freely, and keep the required half.

Nothing in the fixed Mikro.js code sends the check-in request: the CLI and the runtime never call
`/checkin`. Only the reference `examples/ota/app/updates.ts`, a file you copy and own, does. The
runtime gives you the pieces around it: `ota.report()`, `ota.settle()`, `ota.applyOffer` (with
a download callback you write), `ota.bearer()`, `ota.registry()`, and `ota.reconcile()`. How the device asks
a server what to run is up to you. Reasons to write your own instead of adopting this one:

* **A different transport.** This is an HTTPS `POST`. Devices on MQTT, CoAP (Thread or another
  mesh), BLE, or LoRaWAN run the "is there an update?" exchange over that instead. `applyOffer`
  takes bytes from any link.
* **An existing device backend.** A fleet platform that already authenticates devices and signals
  firmware (your own, AWS IoT, Balena, and so on) can drive OTA directly. It names a build as
  `{url, checksum, size}`, the app builds an `Offer` from that, and calls `applyOffer`. There is
  no second protocol to stand up.
* **Push instead of poll.** This polls at startup. A backend that pushes over a live connection
  skips the check-in and calls `applyOffer` when it has something.
* **A different auth model.** This uses a bearer update key issued at enrollment. Mutual TLS,
  signed JWTs, or a hardware security module would replace it. Enrollment (§4) is itself optional,
  so a fleet can provision update keys its own way.
* **Side-channel delivery.** Builds from an SD card, a UART gateway, or pinned into the app need no
  server at all. Get the bytes, and verify the checksum through `applyOffer`.

Adopt the reference when you are building a plain registry server for WiFi devices and want the
`examples/ota` flow to work against it unchanged. That is the common case, and why it is the
reference.

### `POST /checkin`

Auth is `Authorization: Bearer <device update key>` (from `ota.bearer()`), issued at enrollment.
**Update keys never travel in check-in responses.** A `401` means the update key no longer works
(it was rotated, or the device was deleted), and the device needs to be re-enrolled at a
workstation. Devices treat only a `401` this way; a temporary error keeps the update key and the
normal cadence. There is no fallback secret and no pending state.

The body is one map, encoded as JSON (`content-type: application/json`) or CBOR
(`content-type: application/cbor`), and a registry answers a `200` in the encoding the request
declared. A registry must accept both: the built-in device client (`mikro/ota/client`) speaks
CBOR only, while older firmware and hand-rolled check-ins speak JSON. A registry that does not
understand CBOR answers `415`, which the device reports as "upgrade the registry"; it never
falls back to JSON. Error bodies may stay JSON regardless of the request encoding; devices act
on the status code alone and do not parse them. In both encodings an optional field is omitted
entirely, never sent as null/undefined: an absent `free` means "no figure to report".

Because the client ships in firmware, the shape below is frozen per firmware release: a registry
must keep accepting the check-ins of every firmware generation still in its fleet. Extend the
wire by adding optional fields, never by renaming or repurposing existing ones.

Request body:

| Field          | Type                                        | Meaning                                                                                                   |
| -------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `deviceId`     | string                                      | Hardware-derived id the device reports about itself                                                       |
| `firmware`     | string (semver)                             | Firmware version                                                                                          |
| `firmwareHash` | string                                      | Firmware build hash                                                                                       |
| `bytecode`     | integer                                     | Bytecode version the device can load                                                                      |
| `board`        | string, optional                            | Board name of the firmware build (e.g. `esp32c6-generic`)                                                 |
| `running`      | map `{checksum?, version?, trial}`          | The build executing right now                                                                             |
| `lastInstall`  | map `{reason, detail?}`, optional           | Diagnostic from a failed install, sent once                                                               |
| `lastDecline`  | map `{checksum, reason, detail?}`, optional | Why the last offered build was not taken, sent once (see Declined offers)                                 |
| `name`         | `[rev, name?]`, optional                    | The device's name and revision (see Name sync)                                                            |
| `free`         | integer, optional                           | Bytes free to download and stage one build                                                                |
| `configRev`    | string, optional                            | Token of the config the device holds, or of the failed document after a config rollback (see Config sync) |
| `configError`  | map `{rev, message, path?}`, optional       | A client that validates on device rejected the held config (see Config sync)                              |

`running` is read from the live app. A device that has only staged a build still reports its
previous build until the new one is actually executing, so `running` is never a guess.

Every field here is untrusted input that ends up in a stored record, so validate the shapes and
cap the lengths rather than storing whatever arrives. The reference implementation answers `400`
with `{"error": "Invalid <field>"}` for a malformed field instead of dropping it silently. It
requires `running.checksum` to match `^[0-9a-f]{64}$`; `running.version` and `firmware` to be at
most 64 characters; `bytecode` to be an integer; `board` to be at most 64 characters and match
the board-name format below; `lastInstall` to be exactly `{reason, detail?}` with `reason` at
most 64 characters and `detail` at most 256, with unknown keys dropped;
`lastDecline` to be exactly `{checksum, reason, detail?}` with the same caps as `lastInstall` and
a `checksum` matching `^[0-9a-f]{64}$`; `name` at most 64 characters; `configRev` at most 64
characters; and `configError` to be exactly
`{rev, message, path?}` with `rev` at most 64, `message` at most 256, `path` at most 64, and
unknown keys dropped. `deviceId` at enrollment is capped at 128 characters. A `400` is not
a `401`, so the device keeps its update key and its normal cadence.

**Board names.** `board` names the board the firmware was built for: lowercase, matching
`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`. A generic per-chip build reports `<chip>-generic` (e.g.
`esp32c6-generic`). `+` is not part of a board name: a registry answers `400` to it. A check-in
without `board` is firmware that predates the field, and stays valid. The Mikro.js firmware build
accepts a name of at most 47 characters, because the device keeps it in a 48-byte buffer.

`free` stops a registry offering a build the device has no room for. Without it, that failure
shows up only once the download is under way and the staging write hits a full filesystem.
Implement it unless you have a reason not to.

It is still optional on the wire, because a device may replace this whole protocol, so a registry
has to behave sensibly when `free` never arrives. A device that has never reported `free` is not
size-gated at all. A check-in that omits `free` leaves the last reported value in place rather
than clearing it, so the gate keeps using the most recent report.

A registry that uses `free` should offer a build only when its `size` fits, and **withhold the
offer entirely rather than offering a smaller build**. The device is asking for the newest build
it can hold, not the largest one that happens to fit. The value is the bytes free where the build
is downloaded and staged, which the reference client reads from the app filesystem
(`storageUsage().free` in `mikro/sys`).

The response is the offer object from the required half, or nothing, plus the optional `name`
and `config` fields described in the sections below. The response may carry further top-level
fields, and **devices ignore fields they do not know**. That is how extensions are added;
[config sync](#config-sync) itself arrived through this seam.

### Identity

The update key is the identity. Key device records by the hash of the update key, and treat the
self-reported `deviceId` as a label chosen at enrollment.

The display name is editable from **both** ends: renamed in the registry, or over the cable with
`mikro name set`. So it is synchronized between the two sides rather than owned by one.

Enrollment always sets a name (§4), so a registry only ever sees devices that have one, and shows
it as stored. The id-derived default is a **seed**, used once at enrollment, not a shared
algorithm. No registry has to reproduce it, and changing it can never silently rename devices that
already exist. An unenrolled device may have no stored name, in which case the CLI derives one for
display on the spot.

Because the enroll endpoint carries no revision, a freshly enrolled device holds its name at
revision 1 while the registry has it at revision 0. The first check-in reconciles that under rule
1 below, with no visible effect. If the name was edited in the registry before that first
check-in, it reconciles under rule 4 instead, and the registry's edit wins, which is what you
want.

### Name sync

A wall-clock timestamp cannot decide a rename, because a device may have no valid time before it
reaches an NTP server. So each side stores the name with a **logical revision**, and every
deliberate rename adds one to it. Both the check-in request and its response carry the name and
revision as one field:

```
"name": [rev, name]     // named, at revision `rev`
"name": [rev]           // name cleared at revision `rev`
                        // field omitted entirely = no change, or not supported
```

The name and revision travel together, so a name can never be seen at the wrong revision.
Clearing a name is a normal write with a higher revision, so a deletion propagates instead of the
old name coming back.

Given the device's name and revision at check-in, the registry compares revisions:

| Case                                  | Registry                                    | Response          |
| ------------------------------------- | ------------------------------------------- | ----------------- |
| 1. Device revision higher             | adopts the device's pair                    | no `name` field   |
| 2. Registry revision higher           | keeps its own                               | its `[rev, name]` |
| 3. Equal revisions, same name         | in sync, nothing happens                    | no `name` field   |
| 4a. Equal revisions, registry unnamed | adopts the device's name                    | no `name` field   |
| 4b. Equal revisions, different names  | registry wins, stores its name at `rev + 1` | `[rev + 1, name]` |

Rule 4b is the concurrent-edit case: both sides renamed the device before either synchronized.
The registry wins so that the two sides converge and do not flip back and forth. The loss is
visible: whoever renamed the device at the serial port sees it change back, and can redo the
rename, which then wins at a higher revision.

Rule 4a tells "never named" apart from "deliberately cleared", and the revision is what
distinguishes them. A registry that cleared a name has a revision recording that it did, while one
that never had a name sits at revision 0. Without this split, a device enrolled without a name
would meet its own seed name at revision 0, which reads as a concurrent edit and orders the device
to clear the only name either side has.

A device that receives a `name` field adopts and stores it. **A response with no `name` field
always means "no change"** and must never be read as an instruction to clear the name. A response
lost after a rule-1 adoption looks the same as one that never arrived, and re-sending the same
pair at the next check-in settles it as rule 3.

Compatibility: firmware that sends no `name` field keeps its previous behavior, so engage sync
only when the field is present. A registry that stores no revision treats old records as revision 0. That leaves one accepted migration case: a device renamed over the cable before the registry
learned about revisions reports revision 1 against a legacy 0, so the device's name wins that
first sync even if the registry had renamed it earlier. This happens once, corrects itself, and
favors whoever has the hardware in hand.

### Config sync

An app that publishes a [config schema](#_2-the-publish-and-release-endpoints) can have
remote configuration delivered to its devices with check-in responses. The device stores what it
receives without understanding it, and the app reads it back through `ota.config()` as one typed
value. All validation happens where the schema lives, in the registry, never on the device: the
same parties ship the code the device runs, so a config the registry validated needs no second
check. The device's only assembly step is a single top-level spread, described below. There is
no second protocol: config rides the check-in, and a registry that does not implement it changes
nothing else.

**Overlays in storage and on the wire.** What an operator authors, what the registry STORES, and
what it SERVES are all deviations from the schema defaults, never the defaults themselves: derive
the stored overlay by dropping unknown keys, stripping values structurally equal to the field's
default, and pruning empty objects and empty arrays. The device resolves what it receives with a
single top-level spread over the defaults its own build carries, `{...configDefaults, ...doc}`,
and nothing deeper. So the served overlay is **wholesale at the top level**: every key present
carries a COMPLETE top-level value. A deviating leaf inside a nested plain object therefore ships
its whole top-level key, defaulted siblings included, and a union, tuple, tagged-union, or array
value is compared to its default as one unit: equal as a whole, omitted; different in any way,
present in full. The same wholesale rule governs storage, where nothing inside such a value is
stripped or pruned, so empty objects and arrays inside one survive.

**The wholesale rule is normative and the device cannot check it.** The device holds no schema,
only its defaults, so it cannot tell a complete top-level value from a pruned one; it spreads
whatever arrives. A registry that prunes inside a value corrupts the config silently, and this
spec is the only thing that prevents it. The case that motivates the rule: with a default
`{"mode": {"kind": "a", "x": 1}}` and an effective config `{"mode": {"kind": "b", "x": 1}}`, the
wire MUST carry `{"mode": {"kind": "b", "x": 1}}` in full, the equal-looking `x` included. The
`x` under branch `b` is a different schema node than the `x` under branch `a`, so "equal to the
default" is not defined across them, and dropping it leaves the device spreading branch a's field
into branch b's value. No layer anywhere does a shape-directed deep merge.

**A complete effective document is itself a valid overlay:** every key is present, and spreading
it over the defaults yields it verbatim. A registry that serves complete documents is therefore
still correct, at the cost of wire bytes and of the device's stored copy. The reverse does not
hold, so a device that expects a complete document reads an overlay as the whole config.

Storing overlays is what lets defaults evolve: when a new release changes a default, every device
without an override picks it up by installing the build (the new defaults ride in the build's own
manifest as `configDefaults`), with no config write anywhere, and the next serve against the new
release recomputes the deviations against those defaults, so what a device holds stays limited to
what an operator actually set. Two consequences
are worth stating to operators rather than discovering. Setting a field to its default is the
same as never setting it, so nothing can pin today's default against a future release changing
it. And clearing a field, which for a list means removing every item, falls back to the default
rather than storing an empty value; emptiness is never a deliberate state in an overlay.

**The response field.**

```
"config": {"rev": "<opaque>", "version": "1.4.0", "doc": {…deviations from the defaults…}}
```

`rev` is an opaque token, at most 64 characters, that identifies the EFFECTIVE config this
device should hold, not the overlay that expresses it. The registry defines it (a hash of the
version and the key-sorted effective document works, and so does a revision counter over that
document); the device never computes one, it stores the token and echoes it verbatim as
`configRev` on later check-ins. Two overlays that resolve to the same effective config MUST
share a rev. That is what keeps an edit which lands a value back on its default, changing the
overlay and nothing else, from redelivering a config the device already runs. `version` names
the release whose schema the config was computed against, and the device refuses to surface a
document stamped for a version other than the one actually running. `doc` is the deviation
overlay: the top-level entries of the effective config that differ from that release's defaults,
each complete, and the device resolves `{...configDefaults, ...doc}` when the app reads it.
Values travel as they were authored, on the wire, in registry storage, and in the device's NVS
copy. Send a `doc` only when at least one top-level value
deviates: a device whose config is the defaults holds no document at all and runs on its own
manifest's `configDefaults`, so bare defaults never travel. Sending
`config` with no `doc` key is the deliberate clear: the device deletes its stored document,
falls back to its manifest defaults, and stops echoing `configRev`. The absent `doc` key alone
is what the device keys the clear on; a `rev` riding along is ignored, so a clear SHOULD omit
it. Omitting the `config` field entirely means "no change", so the omit-never-null rule holds.

**When to send.** Send `config` whenever the `configRev` the device echoed is not the token of
what the registry currently wants it to have, including when no `configRev` arrived at all. No
field announces whether the firmware supports config sync: firmware that does stores the
document and echoes the token, which stops the sending; firmware that predates it ignores the
unknown field, and the registry re-sends a few hundred bytes per check-in to no effect, which is
harmless and disappears when the fleet's firmware catches up.

**Which version the config is for.** A response that carries an offer carries the config for the
*offered* release, staged with the build and applied together with it, so the new version boots
its trial with the config that matches it. A response without an offer carries the config for
the *running* release. `config.version` states the choice either way. During a trial the running
build is the trial build, so a check-in made mid-trial is answered against the trial's release;
if the trial rolls back, the reference client restores the previous document together with the
previous build and echoes its token again, and the next check-in reconciles as usual. One edit
therefore waits: a change to the running release's config while an offer is pending is not
delivered until the offer settles, and self-heals at the first check-in after adopt or rollback.

**Config trials.** A document delivered for the *running* release goes on trial on the
device, exactly as an offered build does: the previous document is kept as the rollback
baseline, and the trial is adopted at the next completed check-in after the app has read the
new document. A schema-valid value can still be fatal to the app (a GPIO the board does not
have), and the crash it causes can fire before any check-in runs, so the device accounts the
trial in `ota.config()` itself: each boot that reads config burns one trial boot, and when the
budget is spent the read restores the previous document, which is what breaks a crash loop.
After a rollback the device keeps echoing the FAILED document's rev as `configRev`, alongside a
`configError` report. A registry needs no logic for any of this: echoed-equals-expected already
stops it re-serving the failed document, and the next document it serves, after an operator
changes the config, starts a fresh trial. A document staged with an offer needs no separate
trial; it rides the build's.

**Serving safely.** Never send a document that does not validate; **withhold it and surface the
failure to the operator instead**, the same posture the `free` gate takes toward builds. The
device applies whatever arrives without checking it: validation is entirely the registry's
responsibility, which is why it MUST happen on every serve. The stored overlay is adapted to
the target release per serve, as a pure function: drop keys the target schema does not know
(from the wire, not from storage), strip values equal to the target schema's defaults, prune
empty containers, merge the defaults in, validate the merged document, and serve the top-level
entries of that document which deviate from the target's defaults. Stored overlays are never
rewritten to fit a newer schema. A fleet runs many releases at once and a rollback can resurrect
an old one, so the same stored overlay must keep serving every release the device might run, and
each serve derives its own view. Cap the EFFECTIVE document at 4 KiB encoded, where it is
computed: at authoring time and at every serve. That document is what the device materializes
by spreading the overlay over its defaults, so the cap bounds its heap, and it also bounds the
overlay, which is a subset of the document's top-level entries. The device stages the overlay in
NVS and parses it from the check-in response buffer.

**Which defaults a device runs on.** The defaults follow from the app archive, and the check-in's
`running.checksum` identifies that archive exactly. A registry MAY therefore treat a running
checksum matching none of the release's builds as a device running defaults it has never seen,
and withhold or flag config sync for it. An archive rebuilt under an unchanged version is exactly
this case.

**`configError`.** A device report that a registry SHOULD accept and show to the operator:
`rev` names a document, and `message` (with an optional `path`) says what happened to it. The
built-in client sends it after a config trial rolls back; hand-rolled clients that validate on
device may also use it. It stands until a new document is delivered or the config is cleared.
It needs no automatic reaction, since the `rev` echo already prevents re-serving the failed
document; its job is to tell the operator why the value they set is not in effect.

**Schema changes between releases.** Diff the incoming schema against the app's latest release
at publish and warn in the response body; the CLI shows it while the author can still act on it.
Three kinds of change exist. Safe: a new defaulted or optional field, a new array or a new
object of such fields, loosening `required` to defaulted or optional, adding a union member,
loosening a bound (raising a `max`, lowering a `min`, or dropping either), dropping a `format`,
and any change to `title`, `description` or `mask`. Needs an operator: a new required field,
tightening to `required`, changing a kept field's type, removing a union member that stored
overlays still use, tightening or adding a bound, adding or changing a `format`, and changing a
`unit`. A device affected by one of those is not offered the new release until someone supplies
or fixes the value (offer rule 5). Informational: a changed default alters behavior on every device
without an override for it, and a removed field leaves its stored overrides in place, no longer
served.

A changed `unit` is the one entry on that list no validation catches. `30` is valid whether the
unit is `s` or `ms`, so nothing fails and every stored value quietly means something else. Gate
it and say which unit it was.

Count union members by shape when deciding whether one was removed. Two members can differ only
in their bounds, so a member of that shape surviving does not mean none was lost: dropping one of
`union([number({max: 10}), number({min: 100})])` strands any override only the removed range
accepted. Compare bounds within members separately from whether a member is still there, or a
merely loosened bound reads as a removal and gates a release that widened what validates. At release time, report against
the actual fleet:
which devices on the channel hold overlays that will not validate under the new release, before
anything is served. One drift no machinery catches: a field that keeps its name and type while
its meaning changes validates everywhere and misbehaves everywhere. Change the key name when the
meaning changes.

**Out of scope.** How operators author config is the registry's own business: a dashboard form
rendered from the schema, an API, per-fleet defaults layered under per-device values, anything.
The wire defines delivery only, and nothing in it changes if the authoring model does.

Config values are plaintext end to end: an operator-facing read returns what is stored, the
check-in response carries it in the clear inside TLS, and the device holds it as NVS plaintext.
The device-side docs state that as a property of config rather than leaving it a surprise.
Credentials belong in env vars, which are set over the cable and never travel through a
registry. A required field with no default is the tool for a value an operator must supply:
until one is set, rule 5 withholds the release from that device.

### Offer selection

**The app is the device's, never the check-in's.** A device is bound to one app lineage at
enrollment (§4), and that binding decides which app's builds it may be offered. `running.checksum`
is a status report, not a routing input. Deriving the app from it would let any enrolled device
pull any app's build by naming that app's checksum. A registry that sees a device reporting a
build that belongs to another app should log the mismatch and keep offering only the device's own
app.

The same holds for a device with no binding yet (an older enrollment, or an unscoped token that
named no app). **An unbound device is offered nothing and stays unbound.** A registry must not
infer the binding on the first check-in: not from the build the device reports running (as above),
and not from being the only app on the registry (true today, false the moment a second app is
published, and the devices bound to the first keep that binding). Withholding is the safe answer
to a binding that no one has made.

**Bind at enrollment.** It is the one point where the operator says which app a device belongs to,
and the recommended pattern is to always name it: pass `app`, or enroll with an app-scoped token,
which supplies its own. A registry does not have to refuse an enrollment without one, and this one
does not. Whether an unbound device is an error or a device awaiting a decision is the operator's
call, not the registry's. The only consequence is that it receives nothing until it is re-enrolled
with an app.

Keep one **current build** per `(app, channel, firmwareRange)`; pointing a channel at a build
makes it that channel's current build for the build's firmware range. A device follows one channel
(`device.channel`, set at enrollment, absent reads as `main`), and its current build is whatever
that channel points at. For a named channel that is the `(app, channel, firmwareRange)` pointer.
For `main` it is the pre-channels current build: the highest-`promotedAt` build for
`(app, firmwareRange)` among those that have a `promotedAt`, so a build stored by a bare `push`,
which has none, is never `main`'s current. Track "current" explicitly rather than deriving it from a
creation timestamp, and give each channel's pointer a total order, so that two writes in the same
millisecond cannot leave "current" ambiguous. Pointing a channel at an already-stored build must
work too, not only a fresh push: that is how a fleet graduates a proven build or rolls back to an
earlier one, and the pointer still moves even though the write is otherwise idempotent.

Given a check-in, offer the current build only when all of these hold:

1. The device is bound to an app, its reported `firmware` derives a firmware range (a version
   that is not valid semver derives none and withholds the offer rather than widening the gate),
   a current build exists for that app and the device's channel at that range, and the build's
   `bytecodeVersion` equals the device's `bytecode`. The bytecode check is a sanity gate, not the
   compatibility key: one toolchain produces one `(firmwareVersion, bytecodeVersion)` pair, so the
   range already implies the bytecode, and a mismatch means a firmware whose version string
   over-promises. If no build matches, offer nothing: the device needs a firmware reflash first,
   which happens out of band.
2. `build.checksum != running.checksum`, so you do not offer what is already running.
3. `device.firmware` satisfies `^build.firmwareVersion` (npm caret semantics): at or above the
   build's firmware version, within its left-most non-zero segment. That is the major for `1.x`
   (`^1.3.0` serves `1.9`, not `2.0`) and the minor for `0.x` (`^0.16.0` serves `0.16.x`, not
   `0.17`). During `0.x`, semver treats a minor bump as breaking, so a plain "same major" check
   does nothing there, since every `0.x` version shares major `0`. The caret boundary is where
   breaking changes are, which is why variants and channel pointers key on the firmware range:
   `bytecodeVersion` does not cover them, because it tracks the engine, and a release can remove a
   `mikro/*` API without changing QuickJS. Rule 1's range lookup fixes the breaking boundary; this
   rule enforces the lower bound within it, at or above the build's `firmwareVersion`.
4. The checksum has not failed on this device before. `lastInstall` reports failures, and the
   device also refuses to re-download a build it has abandoned.
5. When the build's release carries a [config schema](#config-sync), the device's config for
   that release validates. The offer and its config are a pair, and a device is never booted
   into a release whose config cannot exist. A schema that adds a required field therefore
   pauses each affected device's rollout, visibly, until an operator supplies the value; the
   release-time report under Config sync is what makes the pause visible before anyone wonders
   why nothing is updating.

The device record is what these are read from, so, as with `free`, the most recent reported
`firmware` and `bytecode` stand when a check-in leaves them out.

Failures must not be permanent. A build can fail for a reason that is later fixed (a full
filesystem, a bad network), and a device whose failure list grows forever eventually refuses every
build. So bound the list (the reference implementation keeps the 16 most recent) and give
operators a way to clear it: `DELETE /devices/:deviceId/failures`, returning `200`, scoped by the
token like the other device routes (`404` outside its app).

### Success tracking

Record `running` and `lastInstall` on every check-in. An update **succeeded** when a later
check-in reports `running.checksum == offeredChecksum` with `running.trial == false`. A download,
a staged build, or a build still on trial is not success. This is why `trial` is required whenever
`running` is sent: if it is left to stand from a previous check-in, it never turns false, and the
update never reads as having landed.

Clear the recorded offer once it succeeds. `lastInstall` carries no checksum, so a failure report
is attributed to whatever was offered last. If a settled offer stays on record, an unrelated later
diagnostic marks the build the device is running as failed, and rule 4 above then withholds the
one build known to work on it.

### Declined offers

A device that is offered a build does not always take it, and when it stops trying, nothing about
`running` changes, so a registry that waits for `running.checksum` to turn into the offered
checksum waits forever, showing an update as pending that will never arrive. `lastDecline` is how
the device says so. It carries the checksum it declined, unlike `lastInstall`, so it needs no
attribution against the last offer.

`reason` is one of:

| reason            | meaning                                                                   | retried?                          |
| ----------------- | ------------------------------------------------------------------------- | --------------------------------- |
| `exhausted`       | Repeated download failures spent the device's retry budget for this build | Yes, after a reboot               |
| `abandoned`       | The bytes failed verification, so they can never succeed                  | No, not on this device            |
| `download-failed` | One download attempt failed; the budget still has room                    | Yes, at the next check-in         |
| `install-failed`  | Staging or verification failed                                            | Depends on the kind; see `detail` |

Treat `exhausted` and `download-failed` as "not yet", not as a build that is broken: a device on
poor wifi produces both, and the build may be fine everywhere else. Only `abandoned` is evidence
about the bytes, and it is the one that belongs in `failedChecksums`.

Sent once, on the first check-in after the decline, and dropped on reboot if it was never
delivered. A registry that never sees one has learned nothing either way.

## Suggested data model

* **builds**: `checksum` (primary key), `app`, `version`, `bytecodeVersion`, `firmwareVersion`,
  `size`, `storageRef`, `createdAt`, `promotedAt` (the highest `promotedAt` per
  `(app, firmwareRange)` is `main`'s current build; the range derives from `firmwareVersion`, so
  whether to store it is an implementation choice), and optional source fields `repository`,
  `directory`, `commit`, `dirty` (see the publish table). Records stay tag-less, so one build can
  be served on more than one channel.
* **channels**: `(app, channel, firmwareRange)` (primary key), `checksum`. A movable pointer to a
  build; the key is unique, so a release overwrites it and there is nothing to order. `main` is not
  stored here; it is the highest-`promotedAt` build (see builds).
* **devices**: `deviceId` (primary key), `updateKeyHash`, `app` (the binding, set at enrollment,
  nullable), `channel` (the release channel, set at enrollment, nullable, absent reads as `main`),
  `name` (nullable), `nameRev` (the name's logical revision, absent reads as 0),
  `lastSeen`, `runningChecksum`, `runningVersion`, `trial`, `lastFirmware`,
  `lastBytecode`, `lastFree`, `lastInstall`, `lastDecline`, `lastOfferedChecksum` (cleared once
  the offer succeeds), `failedChecksums` (bounded), and, with config sync,
  `configOverrides` (the stored overlay), `configAuthoredFor` (the version whose schema it was
  last saved against), `lastConfigRev`, `configError`.
* **configSchemas**: `(app, version)` (primary key), `schema`. Release-level, shared by every
  firmware-range variant of the version; the publish `409` above is what keeps it single.
* **tokens**: `tokenHash` (primary key), `app` (nullable), `createdAt`, `expiresAt`, `lastUsedAt`.

File-backed storage with no database is enough for a reference implementation. Records are keyed
by untrusted strings (device ids, token hashes), so a store that indexes them into a plain object
must use a null-prototype object, or `Object.hasOwn` guards. Otherwise `constructor` and
`__proto__` read as existing entries, and writing them corrupts the index.

Keep internal errors in the server log, not the response. A `500` body that echoes an exception
message can hand out filesystem paths. Return a fixed message instead.

## Non-goals

* Producing builds (the CLI does this).
* OTA of the firmware binary (app builds only).
* Any device-side logic; the device and the CLI are implemented in the Mikro.js repo.

## Accepted risks

Two properties follow from the design above. Both are deliberate, and implementers should know
they are choices, not oversights.

**Builds are not signed.** Integrity rests on the SHA-256 in the offer, which arrives in the same
response as the URL, so a registry that answers a check-in decides what the device runs. TLS is
what keeps that authority with the real registry, which is why §3 requires `https`. The
consequence is that compromising a registry means running code on the whole fleet. Recovery may
need physical access: a cleaned-up registry can push a good build over the air, but only if the
build already running still runs the update cycle at all. Signing builds against a key burned in
at flash time would separate "who serves the bytes" from "who may authorize code", and reduce a
registry compromise to a denial of service. It is not part of this version.

**The registry does not block downgrades.** This is about the registry, not the device: the
firmware still rolls back a build that fails its trial. What a registry does not do is stop a
*deliberate* downgrade. Offer selection follows whichever build a channel points at, and pointing a
channel (`push --release` or `release`) is what sets a channel's "current", so releasing an older
build is itself the downgrade. The registry never checks whether an offer is older than what a
device is running. A publisher can therefore move a fleet to any earlier build, including one with a
known defect. Registries that need to prevent this should refuse to release a version below a
channel's current one, or track a per-channel counter that only moves forward.

## References

* **The reference client is `examples/ota/app/updates.ts`** in the Mikro.js repo, an example you
  copy and adapt rather than a fixed client: it shows the `/checkin` request, how it consumes an
  offer (`ota.settle`), and the streaming download.
* Device and CLI behavior: the [Over-the-air Updates guide](/ota) and the
  [`ota` API reference](/api/ota).
