# Changelog

Every Bitskiff release, newest first, and what it changed in the SDK, the REST api, the webhooks
and the CLI. A release is tagged after its entry here is written, and every package carries its
version.

From 0.2.0 on, each release's packages are at `/downloads/<version>/<scope>-<name>.tgz`, such as
`/downloads/0.2.0/bitskiff-host.tgz`, written once and never replaced. Pin a version in an app you
ship; `/downloads/latest/` follows the newest release once it has rolled and is for trying things.

## [Unreleased]

### What's new

- A developer can cap what a client may do, and cut one user off everywhere in one call.
- The host sees where each client runs and the developer's own data about it.
- The REST ledger counts each client's messages and data.

**Host** (`@bitskiff/host`, `@bitskiff/host-node`)

- The host sees where each client runs: `client.state.environment` holds its OS family, whether
  it is a browser, a webview or a native app, and its SDK and version. The types are
  `ClientEnvironment`, `OsFamily` and `ClientRuntime`. A client back from another environment,
  such as the app a handoff opened, raises `clientChanged` with `environment`.
- `client.state.metadata` is the developer's own map the client joined with.
- `client.state.grantsCeiling` is the most its join token lets the client hold. `setGrants` above
  it is clamped to it.
- `client.state.keyThumbprint` is the key the developer's backend bound into the client's join
  token. Under end-to-end encryption, a client that presents another key is booted with
  `keyUnverified`.

**Client** (`@bitskiff/client`)

- `join`, `joinWithHandoff` and `resume` take `metadata`, the developer's own map the host reads.
  `resume` takes it as `ResumeOptions`.
- `client.apps` is filled after a `resume` too, so a page that takes back its place on a fresh
  load can still offer the developer's app.

**REST api**

- A join token can carry `grantsCeiling`, the most its client may hold. A token minted with
  `["GRANT_RECEIVE_BROADCAST"]` joins watching only, and every later grant change is held to it.
- `GET /v1/sessions` takes `clientSubject`: the sessions where one of the developer's users is a
  client.
- `POST /v1/sessions/boot` takes a `subject` and boots that user from every session in the
  project. It answers `booted`, the places it freed; a repeat answers 0.
- Client records from `GET /v1/project/ledger` carry `messagesReported`, the host's count, and
  `dataMessagesUp`, `dataMessagesDown`, `dataBytesUp` and `dataBytesDown`, the service's own.

**CLI**

- `bitskiff tokens mint --grants-ceiling receive_broadcast` mints a token with a ceiling. It
  takes a comma-separated list.
- `bitskiff sessions list --client-subject` finds one user's sessions, and
  `bitskiff sessions boot --subject` cuts that user off.

### Changed

**Host** (`@bitskiff/host`, `@bitskiff/host-node`)

- A reopening host keeps its stored session through a refusal that does not name it, such as a
  403, a 429 or a timeout. It opens a fresh session only when the stored one is gone or has
  ended.
- The host re-sends what it tells a client until the client confirms it: a boot, a grant change,
  a peer change, a deadline, a new end-to-end encryption key and its goodbye.
- A booted client's `clientLeft` carries the reason its boot gave.

**Sessions**

- Peer connections look for a direct path with Bitskiff's STUN server on both ends.
- A client's reliable messages are held to 40 a second with a burst of 80 at the engine, on by
  default. A client within the host's own limit never meets it.
- A pause no longer costs reliable messages. A host or client back from a stall reads the
  reliable messages that queued during it without a rate limit; lossy messages still drop past
  the burst.
- A receiver too far behind on reliable messages is disconnected and resumes, and the messages
  wait for it. Before, the engine dropped them.

**Hosted join page**

- Back on the join page after opening the developer's app, the offer reads "Open the app" on
  every platform. Returning from the app's link page takes the place back with no code, and other
  tabs say "Continued in another tab".

### Fixed

- Reliable messages are no longer lost when a link ends: a resume delivers every frame the old
  link did not confirm, in order and once, however many there were.
- A client back from a long background receives every message the host held for it, in order.
- A host that restarts inside host grace keeps its clients, and the reliable messages they sent
  meanwhile reach it once.
- Under end-to-end encryption, the first reliable message after a transport hop is no longer
  dropped as a replay.
- A host's `close()` no longer reports its last usage window twice.
- A host's end-to-end encryption drops on a relay link are counted under `relay`.
- A reconnecting client's fresh ticket can be used once only.

## [0.3.0] - 2026-11-06

0.3.0 rolled to dev on 2026-11-07, and `/downloads/latest/` names it. Four earlier rolls of it
were rolled back while the test runs that check a roll were being fixed; none of them found a
fault in 0.3.0.

### What's new

- `bitskiff doctor` checks a setup in one run and names the one thing to fix.
- Each app directory gets its own project. `bitskiff init` makes it without asking, and every
  command uses the project of the directory it runs in.
- The CLI installs from `npx` with 5 packages instead of 100. `bitskiff mcp` fetches the MCP SDK
  the first time it runs.
- `bitskiff init` ends with a report of what it set up, what is left and the next command.
- A fixed code is a join code that never changes, for a printed QR code, a tag or a sign. Each
  join through it reaches the session that claimed it last.
- A host key can allow a native app build, by its bundle id or package name, in place of a web
  origin.
- An organization can be renamed in the console.

### Before you upgrade

Two changes can move what a 0.2.3 setup does. The CLI now takes the project from the nearest
`bitskiff.json` before the profile and the machine default, and `init` no longer sets the machine
default: in an app directory, every command acts on that directory's project. Pass `--project` or
set `BITSKIFF_PROJECT` to pick another. And a session's data bytes now count message payloads
only, not the framing around them, so they read lower for the same traffic: about a fifth lower
on dev's test traffic.

### Added

- `bitskiff doctor` checks the CLI build, the stored credential (its kind, label and where it is
  stored, never its value), the project, the host key's origins, the join URL and the api. It
  prints one line per check, each `ok`, `fail` or `skip`, then the fix for the first failure, and
  exits 0 only when every check is `ok`. It asks the api whether the credential holds. `--format
json` prints one JSON object, and `bitskiff mcp` has it as a tool.
- Every project-scoped command resolves its project in one order: `--project`, then
  `BITSKIFF_PROJECT`, then the nearest `bitskiff.json` (searched up from the current directory),
  then the profile, then the machine default. `whoami` and `doctor` say which one it came from.
  `bitskiff init` in a directory with no `bitskiff.json` makes a new project named for the
  directory, however many projects the organization has, and adds four characters to the name
  when it is taken.
- `bitskiff init --format json` ends with `sessionUrl`, `todo` and `next`: where to watch the
  first session, what is left to do and the command to run next. It names keys by id, never by
  the key itself.
- `GET /v1/sessions/{sessionId}` answers `messagesToHost` and `messagesToClient`, the messages the
  host counted each way. `bitskiff sessions get` shows them, and the console's session view says
  the session works end to end once a client joined and a message went each way.
- Fixed codes. A project makes one with `bitskiff fixed-codes create --label <label>` or on the
  console's Fixed codes page, and `list`, `replace`, `relabel` and `delete` manage them over
  `/v1/project/fixed-codes`. The code is shown once, at create and at replace. A host claims a
  code at open with `host.open({ fixedCode })`, which takes the code's id, never the code, and
  the newest open wins. A client joins with the fixed code as with any join code. With no session
  holding the code the join is refused `nothing_open`, and the hosted join page says "Nothing is
  running here" with Try again. A replaced or deleted code is `invalid_code`. A fixed code carries no
  host fingerprint, so a client does not join an end-to-end encrypted session by one: its inspect
  answers `e2eeRequired`. The host reads how each client came in as `entry` on the client's
  state, `'code'` or `'fixed'`. Joins through one fixed code are limited to 30 a
  minute for each client key, and a refusal records `limit.hit`.
- Claiming a fixed code with a host key takes `sessions:claim` beside `sessions:open`: pick
  `fixed-code-host` when you make the key, in the console or with `bitskiff keys create --preset
fixed-code-host`. A secret key, an organization key and a join token claim under
  `sessions:open`. Without it the open is refused `forbidden` with `missing_permission`.
- A host key's origins can name a native build instead of a web page: its bundle id or package
  name as a custom scheme, such as `com.studio.racer://host`. A key lists one shape only, web
  origins or native builds. The open body takes `origin`, and with no browser `Origin` header the
  api matches it against the key's native builds only. The console marks such a key as a native
  build.
- The C++ SDK has a host: `BitskiffHost` and `Session` over the C ABI's host half. Declare
  channels before the first open, open a session, read its join code and clients, send to one
  client or broadcast, and hear `clientJoined`, `clientChanged`, `clientLeft` and `error`.
  `Pump()` runs every listener on the caller's thread.
- An organization owner or admin can rename the organization on the console, over
  `PATCH /v1/orgs/{orgId}`.

### Changed

- The CLI's install no longer carries the MCP SDK. `bitskiff mcp` loads it where Node finds it,
  else from a copy beside the CLI's config file, else fetches the pinned version there once and
  says so on stderr. The install is 5 packages and 5 MB, down from 100 and 20 MB.
- A session's data bytes count each message's payload, as the metering reference defines them,
  not the frame around it. The engine's node total still counts whole frames.
- Mailed sign-in links open on the console's own address.
- `bitskiff keys rotate`, `update` and `revoke` say which project they looked in, and where that
  project came from, when the key is not there.

### Fixed

- A console account whose address is off the sign-up list now gets its sign-in link. The list
  admits new accounts only; it never locks out an account that already exists.
- The package READMEs name the current release in every install line, CLI command and browser
  module URL. A known issue of 0.2.3: its published tarballs are not replaced, so the host and cli
  READMEs in them still name `host@0.2.2.js` in the no-build import map, the cli README
  installs `/downloads/0.2.0/bitskiff-cli.tgz`, and the host, client, host-node and ui-kit
  READMEs name a `0.1.0` tarball path that is not served. Use the 0.2.3 lines on the
  [downloads page](https://docs.dev.bitskiff.com/docs/downloads/).

## [0.2.3] - 2026-11-03

### What's new

- Hand a client's place from your web page to your own Android app, with no second scan. The host
  sees the move, and an encrypted place stays encrypted in the app.
- An app the OS killed or stopped takes its place back with `client.resume()`.
- A client moves between transports by itself under a hop policy you install
  (`orderedHopPolicy()`), and you can pin or block a transport. Every move is an event at the
  client and a step at the host. The hosted join page moves onto a direct link by itself.
- `relay()` is a transport of its own: a link through Bitskiff's relay, so neither end sees the
  other's address. `peer()` is now direct only and never billed.
- A client can let its server connection go while `peer` or Bluetooth carries it, and take it back.
- C, C++, Unreal and Electron clients have the same transports, and freeze and resume with the
  platform.
- A page can sign its person in after the scan: Bitskiff holds the join for 10 minutes while they
  do.
- Agents drive Bitskiff from an MCP client (`bitskiff mcp`), sign in without waiting on a person,
  read every CLI command as JSON, and prove a project works with no device.
- Organizations manage members and billing accounts, get a monthly invoice, approve limit raises
  and set how much authority their agents have.
- The console's sign-in pages look finished and offer Google and Microsoft sign-in where the
  deployment turns them on.
- Security: a client can no longer skip the host's message rate with made-up hop markers, and an
  encrypted host holds each client to the key it first bound.

### Before you upgrade

Four changes can break a 0.2.2 app. `peer({ relayOnly })` is gone: declare `relay()` for a link
through the relay. A spent place now reads `seat_spent`, not `seat_released`. `client.handoff()`
resolves a `Handoff` (`ticket`, `expiresAt`, `cancel()`), and the `HandoffTicket` type is gone. A
failure inside the SDK itself reads `sdk_internal`, not `internal`. Match on the new names, and
upgrade the host and client packages together.

### Added

- `client.resume()`: an app that was killed, crashed or stopped by the OS takes back its place from its own stored record, with no code, inside the place's budget. It resolves to the client once it is back in its place and to `null` when there is no place to go back to, and a `null` leaves the client new for `join(code)`. In an encrypted place the stored host fingerprint is checked against the next `Welcome`, and a host that answers unencrypted ends it `hostUnverified`. A record written at a handoff redeem is now marked pending until its `Welcome`.
- `joinWithHandoff(ticket)` with the link a stored place came by, as a relaunch from the recent apps list after a kill brings back, resumes that place rather than redeeming the spent ticket again, so an app keeps no handled-link state across a kill; the stored record keeps a SHA-256 digest of the ticket, never the ticket. `client.resume()` of a record written at a redeem before its attach cancels that handoff first, so the page keeps its place at once, and resolves `null`; a place that committed before the app stopped resumes as any.
- `joinWithHandoff(ticket)` with any other ticket than the one the stored place came by now leaves that stored place before it redeems, so the old place's host reads the client leave at once rather than holding it for its reconnect budget.
- Your backend or an agent can now read what a project can open before it opens: `GET /v1/project/capacity` (a secret or organization key holding `project:read`) answers the project's capacity standing and how many more sessions of the shape you name, and the clients they hold, would be placed now. `bitskiff usage --capacity` prints it, and `load-dev` sizes itself from it before falling back to opening until the first `unavailable`. It is a reading, not a reservation, and no SDK exposes it, so a page never learns a limit.
- A new error code, `sdk_internal`, for a failure inside the SDK itself where no request was refused, such as an end-to-end encryption step that throws. It carries the throw as `cause`, is not retryable and does not clear by waiting. `internal` is now only ever the api's own 500, and the host and client SDKs report `sdk_internal` for their own failures.
- The `load-dev` validation run now sizes itself to the capacity the engine has: it opens hosts until the first `unavailable` and runs its load inside the capacity it found, then records the hosts and clients placed and the refusal that set the ceiling in its report, the matrix row's result and the ledger line. A refusal at the ceiling is the expected shape and no longer fails the row.
- A place in an end-to-end encrypted session can now move to your app sealed: `await client.keyThumbprint()` gives your backend the thumbprint to mint the app's join token with (`cnf: { jkt }`), and the ticket `client.handoff()` returns now carries the host fingerprint after a `.`, so the app checks it reached the same host. Pass the ticket whole; a link parser that drops the `.` makes the app end `e2ee_required`. `POST /v1/handoff` takes `e2ee` beside the ticket, refuses a build that cannot encrypt `e2ee_required` without spending the ticket, and answers `e2ee`.
- When a handoff link opens in the browser on Android with your app installed, the sample handoff host page now offers "Open in the app": a link that opens the package you name in its meta tag, whatever the link verification reads, with the store listing as its fallback. It carries the ticket as `?h=<ticket>` in the link's query, since an Android intent cannot carry a fragment, so your app's link handler reads `h=` from the query as well as the fragment. The page keeps the ticket in memory only and puts it in no link until the tap.
- The client and host SDKs now show the handoff as it happens. `client.state.holder` reads `'page'` or `'app'` and moves once, when the app takes the place; `client.state.handoff` reads the move (`pending` at `minted` or `redeemed`, then `done` or `failed` with the step it failed at, and the page's transport as a hint), each change a `state` event with `changed: 'handoff'` or `'holder'`. `client.handoff({ data })` carries up to 4096 bytes to the app, which reads them on `client.handoffData`, and the `Handoff` it resolves has `cancel()`. The page ends `handedOff` when the app takes its place, never `replaced`. On the host the client reads `connection: 'handingOff'` from the app's redeem to the commit, with `clientChanged` `handoff` and `holder`, and never `disconnected` or `reconnected`. In an encrypted session the host holds the app's new key until the app's `cnf` arrives. An app's hop policy re-takes the page's transport at once on the new `handoff` trigger.
- A client's place can now move from the page to the developer's registered app as the same client: the page mints a handoff ticket (`POST /v1/sessions/{sessionId}/clients/{clientId}/handoff`, with an optional transport hint and up to 4096 bytes of handoff data, string or bytes), the app redeems it (`POST /v1/handoff`, answered with its own token and ticket, the hint and the data), and the place commits to the app when the engine reports the app's connection, never before. The page can cancel (`DELETE` on the same path). Until the commit the page keeps its place, its token and its budgets; a redeem pauses a running budget once per stable spell; a failure after the redeem leaves the page where it was. The host is told `HandoffState` from the redeem on (`redeemed`, then `done` or `failed` with its stage), never at the mint, and the evicted page's resume reads `handed_off` (or `handoff_pending` while the engine cannot be read). Six mints a minute per place (`handoff_mints_per_minute_client`), and each handoff ends in one `client.handoff` meter event.
- An agent can now drive Bitskiff from its MCP client: `bitskiff mcp` is a stdio MCP server with one tool per CLI verb, generated from the command table, running under the key or sign-in the CLI already stored. Add it with `{ "mcpServers": { "bitskiff": { "command": "npx", "args": ["--yes", "<CLI tarball URL>", "mcp"] } } }`. `init` and `login` run as `--no-wait`, and a refusal is the tool's error result carrying the api's error body, `details` included.
- An organization owner can now set how much authority its agents have, or switch them off: `orgKeyMinting: console` stops any key making or rotating an organization key (a person in the console still can), `keyMaxLifetime` caps how long an organization key lives, keys already live and the CLI's login key included, and `deviceSignIn: refused` stops `bitskiff login` and `bitskiff init` acting in the organization while browser sign-in to the console stays. Set them with `bitskiff orgs policies --org-key-minting keys|console --key-max-lifetime <duration>|none --device-sign-in allowed|refused` or on the console's Policies page, over `PUT /v1/orgs/{orgId}/policies`; a key holding `projects:read` reads them on `GET`. A refusal is `forbidden` with `details.reason = "policy"` and `details.policy` naming the policy.
- An agent can now sign in without its tool call waiting on a person: `bitskiff init --no-wait` and `bitskiff login --no-wait` print `{ verificationUri, userCode, expiresAt, next }` as JSON and exit at once. Show the person the URL and code, then run `next` (`init --continue` or `login --continue` with the same flags), which waits for the approval and finishes the setup. The pending code stays in the CLI's owner-only config file and is never printed; a second `--no-wait` replaces it.
- `bitskiff login` now refuses a staff key and stores nothing: the api answers `staff` on `GET /v1/orgs` to the key's own holder, and the one line points at `npm run admin-key`, which hands the key to a command's environment instead.
- Electron and Unreal clients now freeze and resume with their platform. The Electron client follows the machine's suspend and resume (`powerMonitor`) by itself, and `client.platformEvent('freeze')` and `'resume'` are there for an app that knows better (`powerMonitor: false` turns the automatic one off). The Unreal subsystem follows the engine's background and foreground delegates, with a Blueprint `PlatformEvent` node and `SetFollowsApplicationLifecycle(false)` for a game that raises them itself. Presence does not move. A resumed native client now treats its session connection as dead and reconnects at once with no probe, as a web page does.
- A native app can now tell its client that the platform froze or resumed it: call `bitskiff_client_platform_event` from the C ABI, or `client.OnPlatformEvent(bitskiff::PlatformEvent::kFreeze)` and `kResume` in C++, from the app's lifecycle delegates. A frozen client closes its `peer` or `relay` connection and reads it `unavailable` until it resumes, then `available`, with presence untouched, as a frozen web page does. In the same pass the native core's `orderedHopPolicy` stays off `peer` after an attempt that found no direct path, as the TypeScript one does, and the Unreal plugin reads `bRepairing` (`bRestarting` stays for this release with the same value) and declares `relay` with `DeclareRelay`.
- A hop policy can now read its base from the context: `client.hops.context().base` is `client.server` by default and the declared `relay` handle when the installed policy has one, and a policy names it with a `base` property (`orderedHopPolicy({ base: viaRelay })` does). `turnBytesPerMonth`'s default reads 64 GiB in the limits guide, as the limits page has it.
- A client can now start on `relay` instead of `server`, so the host's address stays hidden and Bitskiff's engine never reads its data: declare `relay()` at both ends and install `orderedHopPolicy({ base: viaRelay })`, where `const viaRelay = client.transport(relay())`. The data moves onto `relay` at the join and back onto it after a fallback, and `peer` stays the upgrade from there. A host that declares `relay` now asks for each client's relay credential as the client arrives, so the first `relay` connect waits on no extra round trip. Relayed bytes are metered to your project, and the default `turnBytesPerMonth` is now 64 GiB, sized for a session that relays all its data. A client that floods a `relay` connection is now booted at once rather than only moved back to `server`, because every byte it sends through the relay is billed. The default `orderedHopPolicy()` now tries `peer` alone; put `relay` in `order` yourself if you want it as an upgrade.
- Invoices now exist: early each month every billing account gets one invoice for the month before, a line item for each attached project and owned meter, at that meter's rate card parameter, plus the monthly fee parameter once on the organization's default billing account. Every parameter is zero today, so every invoice totals zero and is issued already paid. An issued invoice is numbered in its billing account's own sequence with no gap (`Q2XM9PBZ-0001`), never changes, and keeps its PDF beside it. Read them with `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/invoices`, `.../invoices/{invoiceId}` and `.../invoices/{invoiceId}/pdf` under `billing:read`, on the console's billing page, or with `bitskiff invoices list`, `show` and `pdf`. A billing account can take `invoice.issued`, `invoice.paid` and `invoice.voided` webhooks on its own endpoint, set with `PUT /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/webhooks`. No payment is collected yet. `bitskiff billing accounts list`, `create` and `update` are now verbs of their own, each with its own route in `bitskiff commands`.
- A client whose project requires a token can now sign in for as long as it takes: `POST /v1/join/inspect` with `reserve: { holderHash }` turns the fresh join code into a join grant good for 10 minutes and holding no place, and `POST /v1/join` redeems it once with `grant: { grant, holder }` in the code's place, the token in `Authorization` as before. A copied grant is useless without its holder secret, a repeat within 30 s gets the same ticket and seat, and one that does not redeem is `invalid_code` with `details.reason` `grant_expired`, `grant_used` or `grant_holder_mismatch`. A project holds at most 1,000 unredeemed grants and a session twice its `maxClients` (at least 20); past either the reservation is refused `rate_limited` in `reservation_refused` while the inspect still answers.
- A page can now sign its person in after the scan with the client SDK alone: `client.inspect(code, { reserve: true })` answers `reservation`, a join grant good for 10 minutes, and `client.join(reservation, { token })` redeems it beside the join token your backend minted once the sign-in is done. The holder secret behind the grant stays inside the SDK, in this tab's `sessionStorage`, so the load that comes back from a redirect reads `client.heldReservation()` and redeems with no second scan. A grant that does not redeem is `invalid_code` with `details.reason` `grant_expired`, `grant_used` or `grant_holder_mismatch`; a full ceiling is `inspection.reservationRefused`, a `rate_limited` with its wait. The reference join page takes the sign-in as one `beforeJoin` seam and never calls the join routes itself.
- A client on a web page can now hand its place to your native app with no second scan: register the app on the project (`bitskiff settings set registeredApps '[{ "platform": "android", "appId": ..., "handoffLink": "https://...", "clientKeyId": ..., "name": ..., "gain": ... }]'`), and the hosted join page offers "Continue in the app" once the client is in. On the page, `client.handoff()` mints a 60 s single-use handoff ticket for the client's own place (`POST /v1/sessions/{sessionId}/clients/{clientId}/handoff`); the page opens `<handoffLink>#h=<ticket>`, and in the app `client.joinWithHandoff(ticket)` redeems it (`POST /v1/handoff`, the registered app's client key) and takes the same place as the same client: the host sees one client, the page ends `replaced`, and `client.apps` carries the project's registered apps off the join answer. Refusals: `handoff_invalid` (`expired`, `spent`, `unknown`), `wrong_client` (`other_app`), `forbidden` (`token_required`), `seat_spent`. No handoff in an end-to-end encrypted session.
- An agent can now read the CLI's whole command table as data: `bitskiff commands --format json`, or `bitskiff --help --format json`, prints every verb with its arguments, flags, the routes it calls, the permission each needs, its output shape, whether it pages and whether it needs a sign-in. It needs no sign-in and sends no request, and `bitskiff --help` reads as before.
- An agent can now prove a project works with no host code and no device: `bitskiff init` writes `bitskiff-prove.mjs` and a `package.json` beside the host page, and `npm install && node bitskiff-prove.mjs` opens a session on `@bitskiff/host-node`, joins one client on `@bitskiff/client`, sends a message each way, prints each transport's record and the session's usage record as one JSON object per step, and closes, exiting 1 at the step that fails. It reads a secret key from `BITSKIFF_SECRET_KEY` and a client key from `BITSKIFF_CLIENT_KEY`, and prints neither.
- A project can now be read, renamed and archived by itself: `bitskiff projects get`, `rename <name>`, `archive` and `unarchive` over `GET` and `PATCH /v1/projects/{projectId}` and `POST /v1/projects/{projectId}/archive` and `/unarchive`, and the console's projects page has Rename, Archive with a confirmation, and an Archived filter. Archive never deletes: an archived project refuses new sessions, keys and tokens with `forbidden`, `details.reason` `project_archived`, keeps its keys, ledgers and usage, and an organization owner can unarchive it for 30 days.
- The CLI can now wire a project's webhook and read its events: `bitskiff webhooks set --url <url> --kind session.ended --kind client.gone` sets where events are delivered and which usage records ride along, and prints the signing secret the one time it is shown; `bitskiff webhooks get` reads the config back without it; `bitskiff events` reads the event stream a receiver that was down catches up on, paged by `--cursor` and `--all`. Each takes `--format=json`.
- A client can now let its Bitskiff connection go once its data rides a transport the host allows it on, and take it back at will. Declare `bluetooth({ release: true })` or `peer({ release: true })` at the host, then call `client.server.release()` and later `client.server.rejoin()`; `ifLost` says whether a lost link rejoins (the default) or ends the place. The host serves a released client over that transport, shows it `released`, and can release its own connection with `session.release()` once every client is released. Nothing releases on its own, and released time is logged, never metered or billed.
- The REST api now knows a released client, one that let its server connection go while another transport carries it, and takes it back: with the host token, `POST /v1/sessions/{sessionId}/clients/{clientId}/release` marks the place released, `.../release-report` reports the released link lost (the place waits under its reconnect budget) or the place ended, and `POST /v1/sessions/{sessionId}/host-release` releases the host once every client is released; a client reads its own place with `GET .../clients/{clientId}/place` and its resume token. A released place is held, and its span is logged, never metered or billed. A join while the host is released is refused `host_released`, and a session whose host stays released for 24 hours ends `released_session_ceiling`. The SDK verbs come in a later release
- The SDK now ships its own hop policy, `orderedHopPolicy(options)`, as short readable code you can copy or wrap: `client.hops.use(orderedHopPolicy({ order: [bt, viaPeer] }))` prefers the first ready transport in `order` that fits every channel's rows, waits 20 s between hops, goes back to `server` from a link that stays poor, skips a relayed link unless `keepRelayed`, and backs off from 30 s to 5 min over 6 tries, reset by a network change or a return. With no `order` it prefers the declared `peer`. `@bitskiff/client/testing` adds fixtures for iOS Safari, the Android wrapper and a native iOS app, with `active(t)`, `available(t)`, `unavailable(t, reason)` and `ctx({ active: t })`, so a policy is tested with no device.
- A client can now hop by itself under a policy you install. Pass `new BitskiffClient({ hopPolicy })` or call `client.hops.use(policy)`, and `client.hops.use(null)` to remove it; with none installed nothing hops by itself. A policy is two plain functions: `next(transports, ctx)` names one step on each trigger (`connect`, `prepare`, `offer`, `disconnect` or `waitMs`, each with a `why`), and `nextRetryDelayMs(ctx)` says how long to wait after a failure. `client.hops.step()` runs it once and `client.hops.apply(step)` takes any step you computed. A step taken shows as a `hop` or `offer` event by `policy`, and one not taken as a `decision`. Your pins and blocks, the 30 s retry wait, 6 attempts a minute, a 1000 ms floor on `waitMs`, the row-size check and one ask at a time hold over any policy, and a raised delay is reported. Each transport now reads `retryAt` and `asked`, and the context carries `network` and `power` where the platform reports them.
- A client can now pin, block and clear a transport. `t.pin()` keeps the data on `t` while it is active and ends when `t` fails; `t.block()` keeps any hop off `t`; both last for the client's place through away spells and resumes, and `t.clear()` ends either. `t.connect()` pins and `t.disconnect()` blocks. A hop onto a transport whose `profile.maxRow` is below a channel's `maxBytes` now resolves `failed` before any attempt, at the client's `connect()` and the host's `client.connect(settings)`; pass `{ oversizeRows: 'refuse' }` to hop anyway and have each oversize row refused with `size`. Bluetooth's 3072-byte rows are below the reliable channel default, so a Bluetooth tap needs that option until rows span frames.
- A host can now turn `peer` off and on mid-session: `settings.disable()` moves every client back to `server` with cause `hostOff` and reads `notAttached`, and a host reload keeps it off; `settings.enable(options?)` reads `available` again and connects no one.
- A client now reports every hop: `client.on('hop')` fires on each step (`started`, `done`, `failed` with the stage it reached, `failing` when a link is noticed dead and `fellBack` with `stallMs` when the data is back on `server`), `client.on('decision')` on a step not taken, and `client.on('ask')` when the ask hook is called and answers. `client.hops.history` keeps the last 64, `on(name, fn, { replay: true })` hands them over first, and `client.hops.context()` and `client.hops.on('trigger')` work with no policy. At the host, `clientChanged` with `change: 'transport'` carries the step as `hop`, and `client.hops.history` keeps them per client. `client.disconnect(settings)` moves one client back once with cause `hostDisconnect`. `peer({ silenceMs })` sets how long a quiet link is waited on, 250 to 3000 ms at either end (750 by default), and the first end to notice closes it for both; `idleCloseMs` closes an unused connection with cause `idle` (the reconnect budget by default, `null` for never), and `allocate(waiting)` picks which waiting client gets a freed connection, kept for it 30 s. A hidden host page now closes connections with cause `hostHidden`.
- A native client (C, C++, Unreal, Electron) now uses transports as the TypeScript client does: it declares `peer`, reads each transport's record, pins, blocks and clears a transport, answers an ask only through its own hook, hears every hop, and installs `orderedHopPolicy` or a policy of its own. Each SDK's own tests prove this until the conformance stack carries a real peer.
- The conformance stack now carries a real peer link: a client hops onto `peer` over a real WebRTC connection on the stack's own network and back to `server`, each reliable row once and in order, for the TypeScript client and the native core alike, so the native core's transport records, hop and link reports are proven on real frames rather than a fake.
- A native client (C, C++, Electron) can now let its Bitskiff connection go and take it back, as the TypeScript client does: `client.server.release({ ifLost })` (`Transport::Release` in C++) while its data rides a transport that carries control, and `client.server.rejoin()`. Each step is a `release` event, and a transport's profile reads `carriesControl`. In the C ABI, `client.server` has no `connect`, the state carries every transport record, and `client.hops.on('trigger')` is a listener.
- The racing demo now has a client that is both a web page and an Android app from one build (`apps/race-app`): scan the race code and play in the browser, or open the same controller in its Capacitor shell, where it is also offered Bluetooth. A details panel shows the upgrade ladder: which rung the client is on, each transport's reading and what it costs, and the one offer it may accept.
- A host and a client can now declare `relay()` beside `peer()`: the same WebRTC link, through Bitskiff's relay only. Neither end sees the other's address, and relayed bytes are metered to your project. Declare it at both ends with `host.transport(relay())` and `client.transport(relay())`; leave it out and no relayed bytes are ever billed to you. `orderedHopPolicy()` tries `peer` first and `relay` next, and moves a client on `relay` to `peer` when the network allows. The hosted join page and the Demo host declare both, and their transport lines name each with its cost: `peer` direct, not billed; `relay` through Bitskiff's relay, relayed bytes metered.
- An organization can now manage its members, not only invite them: `GET /v1/orgs/{orgId}/members` lists them with their role, `PATCH /v1/orgs/{orgId}/members/{userId}` changes a member's organization role, and `DELETE` on the same path removes a member, ending their roles on the organization and its projects and their console sessions. The last owner can be neither removed nor demoted, and nobody changes their own role. The console's members page lists the members above the invitations with a role select and a confirmed `Remove`, and the CLI has `bitskiff members list`, `update <userId> --role <role>` and `remove <userId>`; the invitation list moved to `bitskiff members invitations`.
- Billing accounts are now real: an organization has one or more, created with a default one, and every project is attached to exactly one. `GET` and `POST /v1/orgs/{orgId}/billing-accounts`, `GET` and `PATCH /v1/orgs/{orgId}/billing-accounts/{billingAccountId}` (rename, the billed party's details, make it the default) and `POST /v1/projects/{projectId}/billing-account` (attach a project) manage them, and `GET /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/usage?month=YYYY-MM` reads a month's roll-up: each attached project's client minutes, session minutes, relayed bytes and engine data bytes sent, for the days it was attached, and their totals. Reads take `billing:read` on an organization key; writes take a console sign-in. The console's billing page switches between billing accounts, shows the month and attaches a project, and the CLI has `bitskiff billing accounts list|create|update`, `bitskiff billing attach <project> <billId>` and `bitskiff billing usage --month YYYY-MM`. Removing a member now also ends their roles on the organization's billing accounts.
- A limit raise above your plan's ceiling no longer waits forever: an organization owner now approves or denies it. `GET /v1/orgs/{orgId}/limit-raises` lists every pending raise across the organization's projects with the value asked for, the value that applies meanwhile and the ceiling; `POST /v1/orgs/{orgId}/limit-raises/approve` applies one when the plan's ceiling now holds it, and refuses `quota_above_ceiling` naming the ceiling when it does not, leaving it pending; `POST /v1/orgs/{orgId}/limit-raises/deny` clears one with a reason. Approve and deny take an owner's console sign-in, never a key. Both are audited, and a project learns the outcome on its webhook by subscribing to the new opt-in kinds `limits.raise_approved` and `limits.raise_denied`. The console lists the raises on the organization's limits page, `/orgs/{orgId}/limits`, with Approve and Deny, and the CLI has `bitskiff limits raises list|approve|deny`. `GET /v1/project/limits` now answers `tokensMaxTtl` for the calling key, the shorter of the key's own and the project's.
- A project can now lower `join_grants_outstanding`, the ceiling on unredeemed join grants (1,000 by default), with `POST /v1/project/limits/raise`; it is lowered only, never raised. The register rows for `client.heldReservation()`, `Inspection.reservationRefused` and `ClientCoreOptions.holderStore` document what the client half of the join grant already shipped in the bullet above.
- The protocol now carries the handoff's state, ready for the build that uses it: `HandoffState` on `bitskiff.ctl` (`pending`, `redeemed` with the successor's `cnf`, `done`, `failed` with its stage, and the page's transport hint), `ParticipantView.holder` (the holder's kind, `page` or `app`, and the successor while a handoff is under way), the participant state `handingOff`, and the error codes `handed_off` and `handoff_pending` a resume can answer. Nothing sends them yet, and every message a 0.2.x SDK reads decodes as before.
- An unhandled rejection or an uncaught error now fails the test it happens in, with its own message and stack, across the SDK packages, the api and the conformance suite; a page the local conformance tier, the walks or the soak drive fails its scenario, walk or `page-errors` row on one, and the soak's minute line counts them.

### Changed

- A session's `reconnects` meter can count a return the host never read as `disconnected`, and that is the meter working: it counts each time the backend took a client back after its link was detached, and a client closes its own link when it returns from being out for `linkDeadAfterHidden` (10 s by default) or more, or from a freeze. A return over a link that held counts nothing. On a Note9, the device going dark for about 11 s in a half hour counted one reconnect with no `disconnected`; the device dark for under 10 s counted none (`metering.md` 3.2).
- Our dev environment keeps a short list of test addresses that are never mailed, so our own checks still read their sign-in links and invitations once dev sends real mail; every other address is mailed as before, and no environment that serves customers can carry such a list.
- The console's sign-in steps now look finished: each step (sign in, check your email, the two-factor setup and code, approving a terminal sign-in, accepting an invitation, signed out) is one card under the Bitskiff name, in light and dark and at narrow widths. Google and Microsoft sign-in show as coming soon; email sign-in works as before.
- The console now shows its own typeface, Instrument Sans, served from the console itself with no request to another site, and its whole stylesheet now applies under its content policy.
- You can sign in to the console with Google or Microsoft, on the sign-in page and so on the terminal approval and invitation pages too. One verified email is one account whichever way you sign in; an address its provider does not vouch for, or one nobody has invited yet, is told so. An organization owner can let a Microsoft or Google second factor stand in for Bitskiff's own with the new `providerSecondFactor` policy, which is off by default. Each environment turns the two buttons on once its Google and Microsoft clients are set up.
- When a Google or Microsoft sign-in finds that your address already signs in another way, the console now offers "Send link" to add the provider to that account; when the Google or Microsoft account already signs in to a different Bitskiff account, it says so, links nothing, and offers an email link.
- The sample host page now opens its session when you press "Open a session", not when the page loads, so a visit that never presses spends nothing against the project's sample-page bound. A reload after the press opens on load, as every load did before.
- A registered app's handoff link on a host Bitskiff serves (the join host, the handoff host, the docs host or any other name in the deployment's zone) is now refused `validation` naming `handoffLink`, and so is a link with userinfo, a port or no host: the link belongs on your own link host, so another project's tickets can never land in an app that is not yours.
- The sample handoff host page names no app of its own: it reads the registered apps of the ticket's project from the new `GET /v1/handoff/{ticketHash}/apps`, which takes the ticket's SHA-256 and no credential, and offers only the record whose handoff link is the page's own address, so "Open in the app" and the store listing are always your project's app. Our handoff host is a site of its own that serves only that page, at each of our apps' own path prefix, and its association files; the join host serves no `/handoff/`.
- A failure inside the host or client SDK itself, such as an end-to-end encryption step that throws, now reads `sdk_internal` rather than `internal`, and is reported once: one log line at `error` naming the step, and on the host the session's `error` event. The host raises no `error` once the session has ended, and a client raises no `lifecycle` event after its end beyond the one that says why it ended. `setGrants` and `boot` on a host without `manage` now reject `forbidden` rather than `validation`. A host whose key for the current epoch failed to generate tries again on the next client rather than failing every client the same way.
- Your app's handoff link stays on your own host, registered with the app: Bitskiff hosts no link host or join host per project, and `handoff.<env>` serves only our sample handoff host page. That page now says what to do on Android and iOS when the app is missing or the link opened in the browser: install, then scan the code in the app. The hosted join page now serves a web app manifest listing our demo apps, so on Android it can tell whether one is installed.
- `client.handoff()` now resolves a `Handoff` (`ticket`, `expiresAt`, `cancel()`); the `HandoffTicket` type is gone. It no longer refuses an away page or one on `peer` itself: whether a place may hand off is the api's to say, and a page on `peer` hands off as one on `server` does.
- The engine's participants read now carries each connection's ticket attributes, `bitskiff.jti` among them, as `participant_joined` carries them, on the per-session read and the bulk read alike. A client the reconciler attaches from that read is now held to the ticket's single use as the webhook's attach is: a connection presenting a ticket another connection already used is logged and attached to nothing. A row from an engine built before the field attaches as before.
- The published packages are smaller and carry only the wire they speak: `@bitskiff/host`, `@bitskiff/client`, `@bitskiff/host-node` and `@bitskiff/cli` no longer include the metering schema, the meter catalogue or the ledger, organization key, billing and invoice message types, about a quarter less protocol code in each. Nothing on the wire changed and no name your code imports changed.
- Only the sign-in now makes the login key: `bitskiff init` and `bitskiff login` send `login: true` on `POST /v1/orgs/{orgId}/admin-keys`, and that key alone lasts until revoked. A key you or an agent make with `bitskiff keys create --scope org` over the same stored sign-in gets the 90-day default unless `--expires` names another. `login` from any other credential is refused `validation` with `details.field` `login`.
- A key that makes a key now learns its own expiry instead of getting less than it asked for: an `expiresAt` later than the making key's is refused `validation` with `details.field` `expiresAt`, and under a making key with no expiry, such as the login key, a requested expiry is kept (up to one year and the organization's `keyMaxLifetime`) rather than cut to 90 days. Naming none still takes the making key's expiry, or 90 days.
- An organization key now reads its organization and resolves its projects' limit raises. `GET /v1/orgs/{orgId}` and the new `bitskiff orgs show` take a key holding `projects:read`, and `POST /v1/orgs/{orgId}/limit-raises/approve` and `/deny` (`bitskiff limits raises approve|deny`) take a key holding `project:manage`, so an agent signed in with `bitskiff login` no longer needs a console sign-in for them. The plan still bounds an approval: a value above it is refused `quota_above_ceiling` and stays pending, and the audit row names the key.
- The organization key `bitskiff init` and `bitskiff login` make after you approve the sign-in no longer expires after 90 days: it lasts until revoked, as gcloud's stored credential does. It is revoked when the person who approved it loses the organization's admin role or leaves the organization, together with every key it made. The console's Keys page shows it as `Until revoked` and names who approved it, and `KeyView` carries `approvedBy` on it.
- The CLI now keeps your organization key in the OS credential store (Windows Credential Manager, the macOS Keychain, the Secret Service on Linux), with only a marker in its config file, as `gh` keeps its token. Where no store answers, such as a headless Linux box or a container, the key stays in the config file, owner-only, as before. `BITSKIFF_ORG_KEY` still outranks a stored key, `bitskiff logout` deletes the key from wherever it rests, and a key an older CLI wrote to the file moves to the store the first time the CLI reads the file.
- `bitskiff keys create --scope org` now works under a stored key, so an agent signed in once with `bitskiff login` mints organization keys as it drives the rest of the CLI. The stored key must hold `keys:manage` and name no project allowlist, and the new key holds only permissions the stored key holds (else `forbidden`, `missing_permission`), never outlives it, and is revoked with it. `POST /v1/orgs/{orgId}/admin-keys` takes an organization key under the same rule, and a console sign-in no longer steps up to mint one.
- A stored key now revokes and rotates the organization keys it made: `bitskiff keys revoke <keyId> --scope org` and `bitskiff keys rotate <keyId> --scope org` send on the stored key when no console sign-in is stored, with no step-up. The key must hold `keys:manage` and name no project allowlist, and it acts only on an organization key it made or that one made, else `forbidden` with `not_descendant` (or `own_line` for itself and the keys above it); a rotation's successor carries no permission or limit beyond the stored key's and expires no later than it (else `validation` on `expiresAt`). `DELETE /v1/orgs/{orgId}/admin-keys/{keyId}` and `POST /v1/project/keys/{keyId}/rotate` take an organization key under the same rule.
- A transport record now says `repairing`, not `restarting`, while a transport repairs its connection without a hop: an ICE restart on `peer` or `relay`, or the engine transport's reconnect on `server`. `restarting` still reads the same value for this minor release and is then removed, with the router's flag for SDKs that send control on the data channel; no outside app is live yet, so the old name stays only that long. The host now has `host.server`, the `server` transport's settings handle: the same shape as a declared one, always on, its `enable()` and `disable()` throw `validation`, and on a client's handle `allow(host.server)` does nothing while `deny`, `connect` and `disconnect` throw `validation`.
- The server transport is now metered from the engine's own count, never from your host's report. The engine counts each session's data messages and bytes and its control frames and bytes on the session connection, each way, and your usage carries them as `project_data_messages.down` and `.up`, `project_control_messages.down` and `.up` and `project_control_bytes.down` and `.up` beside `project_data_bytes`; a session's meters (`GET /v1/sessions/{sessionId}/meters`) show them as `engine:data_msgs`, `engine:data_bytes`, `engine:ctl_msgs` and `engine:ctl_bytes` fields of `session`, `up` and `down`. The `messages.*` and `bytes.*` usage meters, which came from your host's stats report, are gone from every usage answer, and what your host reports (its traffic by topic and path, and its control counts by kind under `control`) is still shown on the session's meters as your host's figures, never metered.
- Control now rides the session connection only, and the engine's data path is one transport like the others. Every control body (presence, heartbeats, grants, the key handshake, hop marks and acks, transport setup, `Bye`) travels on the signalling WebSocket as a frame of its own beside the signalling JSON, and the engine forwards it as signalling; the engine's data channels carry your channels and nothing else. `server` is a transport record of the common shape at both ends: `client.server.connect()` moves the data back onto it and pins, `disconnect()`, `block()` and `withhold()` throw `validation`, and its record reads `repairing` while the session connection's reconnect re-makes the data path, which is a transport drop and never a hop. A connected `peer` or `relay` link rides out that reconnect for `reconnectBudget` and closes with cause `sessionLost` (was `serverLost`) only past it. The engine keeps the old path behind `-ctl-data-channel` for an SDK before this release; the flag is off by default and goes in the cut after.
- `peer()` is now direct only: it never goes through Bitskiff's relay and is never billed. `relayOnly` is gone at both ends, and passing it throws `validation` naming `peer.relayOnly`; declare `relay()` for a link through the relay.
- Time a client or a host spends released is now only logged, never metered: your usage shows no released minutes, a released client adds no client minutes until it rejoins, and a released host's session minutes stop at its release. The release and rejoin events still record that the session moved to Bluetooth or peer. Bitskiff meters and bills only what its backend serves and can verify: connected time, joins, and traffic through the engine and TURN.
- Relayed bytes are now metered only from Bitskiff's relay's own count, never from what a host or client reports, and your usage shows them as their own lines, "Relayed bytes (TURN)", beside engine bytes and never summed with them. A direct peer link is never metered, because Bitskiff carries none of it, and it no longer appears in your usage; its client's connected time still counts as client minutes.
- Traffic on peer, Bluetooth or any other transport you declare is now only logged, never metered: it no longer counts toward a session's or client's reported bytes, its usage record or its ledger traffic. What your host reported for it is logged on the client's rejoin, the client's end and the session's end events, and the session's meters still list it by transport beside `server`.
- The error a client reads when its place is over (its reconnect or background budget is spent) is now `seat_spent`, no longer `seat_released`, in the REST api, the close code 4003 and the TypeScript SDK, so "released" means only a client that let its server connection go. Match on `seat_spent`.
- Bitskiff's hosted join page now hops by itself: it declares `peer`, installs `orderedHopPolicy()`, and moves onto a direct connection wherever the host declared `peer` and has it on. The Connect directly button and its messages are gone; a closed Details panel shows one line naming the transport and its cost (`Connection: server (connected time metered)`, `Connection: peer (direct, not billed)`), says a direct connection shares the network address, and opens a Privacy page. Inside the Android wrapper the page asks for the nearby-devices permission once the client has joined, and Not now on the wrapper's words now counts as later rather than a refusal.
- The console's first page now loads less: the projects and organization pages and the invitation and device sign-in pages load on demand, so the console's initial bundle is 491 kB, down from 513 kB and inside its 500 kB budget. No page looks or behaves differently.
- The native SDKs (C, C++, Unreal, Electron) now carry the same transport model as the TypeScript client: `relay` is its own transport (`bitskiff_client_relay`, `bitskiff::Relay()`) beside `peer`, and a client declares one of the two; `relay_only` and `keep_relayed` are gone, and a transport's profile reads `may_relay` (`mayRelay` in Electron), `never` or `always`, in place of `relay`. A record carries `repairing` and, for this minor release, `restarting` beside it with the same value; a hop cause `sessionLost` replaces `serverLost`; and the core sends every control body on the session connection.
- The native SDKs' ordered hop policy (C, C++, Unreal, Electron) now upgrades onto the declared `peer` alone by default, as the TypeScript client's `orderedHopPolicy` does: `relay` is an upgrade only where your `order` places it. A `peer` attempt whose link found no direct path now keeps the policy off `peer` until the network changes or the client comes back.
- `@bitskiff/host` now ships only the route rows it calls, not the whole route table: a bundled host page is about 18 kB smaller (the racing demo's is now 1,039,626 bytes), and a client page built beside it no longer carries the table in the chunk the two share (the racing demo's client page went from 390,058 to 372,979 bytes).
- The host's `client.disconnect(s)` now takes `{ retryAfterMs }`, and the record of that transport reads `failed` until `nextAt`, 30 seconds later by default, at the host and the client alike, so an installed hop policy waits that long before moving the client back; `retryAfterMs: 0` reads `available` at once. Before, it read `available` straight away and a policy could hop the client right back.
- `EngineTransport.flush()` now takes `{ signal }`, as `connect` does: the caller's signal is the bound, and with none the drain stops at 300 ms by itself. The SDK hands its own 300 ms bound in as that signal, so a drain stops when its caller stops waiting.
- The CLI now reports its own faults, such as an answer it could not read or a route it has no registry row for, as `sdk_internal` with exit 1, rather than `internal` with exit 9, which means the service failed.
- `bitskiff init` and `bitskiff login` now report an answer missing a field they need (a key made with no key string, a new project with no slug, a sign-in with no refresh credential) as `sdk_internal` with exit 1 too, rather than `internal` with exit 9.

### Fixed

- The console's Continue with Google and Continue with Microsoft buttons are now live only where that sign-in is turned on for the deployment, on the sign-in page and so on the terminal approval and invitation pages too. A provider that is not turned on shows its button disabled with a sentence saying so, rather than a live button that fails. The console learns which are on from `GET /v1/auth/sign-in/providers`, which answers each provider with `enabled` and no client id.
- Signing in to the console with Google or Microsoft now reaches the account that Google or Microsoft account was first linked to, never another account that shares its email. Before, a provider account whose email was changed could sign in to the account at the new address without that account's second factor. An email that matches an existing account now only offers the link, which the person makes from that account with its own sign-in and factor. A refused sign-in now removes only the sign-in record it just created.
- A native app's first join that gets its ticket but never hears from the session is now told, as a web page's is: at the place's own deadline it asks api once more and ends `reconnectBudget` with `seat_spent` when the place is gone, and an api that does not answer leaves it `suspended` with `budgetSpent` at the end of the joining budget. Before, a connect or `Hello` that went unanswered after the ticket kept it `joining` with no word at all.
- An app started again after a stop that cannot take its place back is now told inside the place's own budget: `client.resume()` rejects and the client ends `reconnectBudget` (or `backgroundBudget`) within about two seconds of the instant the host releases the place. Before, a start that sent nothing stayed on `joining` with no word for the rest of the two-minute joining budget.
- `npm run admin-key` now refuses to run a CLI build older than the CLI's source and says to rebuild. Before, an old build in the checkout refused `--scope org` with "an organization key is created in the console", although a key with `keys:manage` makes organization keys under the stored credential.
- A handoff redeemed in the instant a client's reconnect or background budget runs out is now refused `seat_spent`, as the place is over. Before, the redeem could hold the spent budget for the rest of the handoff window and keep the place alive.
- A client can no longer skip the host's message rate by sending made-up hop markers: the host now reads a hop marker only for a transport it is actually connecting or connected to that client on, so a client flooding messages is closed after ten seconds as before.
- A client can no longer earn free re-sent messages by repeating a hop to a connection and back without the connection failing: the host frees re-sent messages once per connection, so a client flooding that way is closed after ten seconds as before.
- The sample host's "Too many session opens" message now says whether this key, this project or this network opened too many sessions, as the refusal names it, instead of always blaming the key.
- An app that stopped and resumed its place now keeps every reliable message on `peer`, both ways. Before, the resumed app never read the host's messages on the direct link, so the host fell back to `server` a few seconds into each `peer` connection and the messages of that connection were lost; and the host dropped the resumed app's first messages on the link as repeats.
- The C++ SDK and the Unreal plugin now do the same: a native app that stopped and resumed its place keeps every reliable message on `peer`, both ways, instead of waiting on messages from before it stopped.
- A client back from away inside its background budget is no longer released when it returns over a resumed link: the host reads it `present` again on its return and its background budget stops, where before the host kept counting and ended the place `backgroundBudget` with the client back.
- When the host moves a client off `peer` because the link stopped delivering its rows (a passed `hopRetainBytes`, `hopHoldBytes` or `hopAckWait`), the host's `clientChanged` and the client's transport status now give the cause `stalled` rather than `failed`.
- That same move now reads `stalled` in the client's hop history too (`client.hops.history`, its `failing` and `fellBack` events), rather than `silence`: when the client's link closes under it, the client waits up to a second for the host to say why before it writes the move.
- The C++ SDK and the Unreal plugin now do the same: that move reads `stalled` in a native client's hop history too, after the same wait of up to a second for the host's word.
- `client.resume()` no longer waits forever when the device's storage does not answer while the client writes its key and record before reconnecting: the joining budget now covers that wait, so the client is suspended with `budgetSpent` and `resume()` rejects, instead of sitting on `joining` and sending nothing.
- `GET /v1/handoff/{ticketHash}/apps`, the handoff host page's keyless read, is now limited per client address like the other keyless routes: past 30 reads a minute from one address it answers `rate_limited` with `handoffAppReadsPerIp` and the wait, and the page shows the page-only path.
- A client whose link went down no longer ends on its reconnect or background budget while the resume it asked is still unanswered: the budget is held, the client stays `joining` and keeps asking, and api's answer moves the budget or confirms the end, as a host's grace already was.
- A client whose place api ends on its reconnect or background budget now ends `budgetSpent` on that budget, the same lifecycle row its own clock gives, rather than `bye`.
- The credentials doc now says what `deviceSignIn: refused` does to the keys a login key already made: they keep working until revoked or expired, as the keys a GCP user created stay after the user's access is revoked, and each is listed with `createdBy` naming the login key. Revoking the login key ends them with it.
- `npm run admin-key` now revokes its key however the run ends, a thrown error and SIGINT, SIGTERM or SIGHUP included, and its key lives one hour unless `--hours` names a longer life, never the api's 90-day default.
- An app that takes back a place a handoff gave it, with `client.resume()`, the link the place came by or a scan of the code, now reads `client.state.holder` `'app'`, not `'page'`.
- A client whose reconnect budget has passed can no longer resume its place in the moment before the service releases it: the resume is refused `seat_spent`, so a host that read the client leave never reads it join again.
- A handoff your app has redeemed is no longer lost when the page asks for a new handoff ticket at the same moment: that request is refused `handoff_pending`, and the move to the app finishes. A refused redeem of the same ticket no longer ends one that passed.
- A handoff's commit now moves the place and ends the handoff in one step, so the host never reads a place that is neither the page's nor the app's, even when the page's resume lands during the commit.
- An app whose handoff was cancelled, lapsed or lost its place while its connection was arriving is now disconnected, and that connection can never be used again: the page keeps the place. Before, the app could connect as an ordinary client while the place was still the page's.
- A handoff redeem of a place whose reconnect or background budget has passed is refused `seat_spent`, as a resume is, so a client that ran out of time is not brought back by the app.
- A handoff path on our handoff host now belongs to one project: a registered app's link there must be the path our apps table gives that app, and a path another project registered is refused `validation` on `handoffLink`. A record saved before this is refused the same way when a handoff reads it.
- The handoff data and the stored redeem answer now leave the service when the place ends during the handoff, not up to a minute after the handoff's window.
- A host no longer moves an away client's data back onto the server transport while the client is away: the move waits until it is back, as its sends do.
- A browser host or client on the server transport now gets its held control messages out as soon as a short network blip ends, not only after a full reconnect; a stale answer from a connection already let go no longer closes the one that replaced it; waiting for the reliable send queue to empty stops after 300 ms rather than polling until the connection closes; and a dropped lossy send is reported as `bufferPressure` only when the send buffer was full, with drops while the link is not carrying logged under their own reason.
- The host session now holds together when two things happen at once. A session that ends while it waits on the api or a connect no longer attaches again afterwards. Its own `close()` ends `hostClosed` even when the api's `Bye` or a scheduled reconnect arrives during it, always ends the session, and rejects only with a `BitskiffError`. An engine resume that gives up is followed by a reconnect rather than the wait for the host grace, and one reconnect runs at a time. A `rejoin()` that races a released session's end reads `failed`, and a persistence slot that refuses a write no longer fails an open or a reconnect.
- A client now runs one join attempt at a time. A join request or connect that never answers suspends the client once its joining budget is spent, where before it waited forever; an attempt overtaken by a suspend or a newer attempt connects nothing and sends no `Hello`. A second `join()` while the first is still starting is refused with `validation`, and a second `leave()` sends no second `Bye`. `dispose()` now fires a `state` event and calls off a leave still retrying, a suspended client stops its probe, stats and deadline-warning timers, and a background budget spent before the first `Welcome` suspends the client rather than ending it. A failure in work the client does not wait on (a presence post, a rejoin under `disconnect()`) is now logged as an error instead of being lost.
- The host keeps each client's state straight through a handoff, a reload and its own absence. A host page that reloads still holds each bound client to its key, so a client that returns with another key is booted `key_unverified`. A handoff that has been committed never goes back to pending on a late copy, and a handoff that fails while the app's key waits for its token boots that app `key_unverified` instead of leaving it unanswered. A client that leaves takes its handoff with it, a client that leaves while its key is being agreed leaves no key behind, a spent budget's question to the api waits while the host is away instead of ending the place with no `clientLeft`, and a client `change` or `left` listener that throws now raises `listener_error` on the session, as a session listener does.
- An end-to-end encrypted client no longer sits unbound when its `KeyConfirm` cannot be sealed or its confirmation digits cannot be computed: the confirm is retried and, past the third retry, the client ends `hostUnverified` as `protocol.md` 10.4 says. A key mint that failed is tried again on the next `Hello` rather than failing every one after it, and a fresh join after a spent place never presents the old place's key. A committed handoff stays `done` when a late `redeemed` or `failed` arrives, and an app that finds at its `Hello` that it cannot do the place's encryption withdraws its redeem so the place is the page's at once. A reliable message sealed while the connection dropped is held and sent on the connection that comes back, a sealed message lost after it was answered is logged once, and `join()` rejects rather than resolves when the client ends while its record is being stored. A failure to load the direct path now drops only the waiting work of the client that asked for it, and a direct signalling message that cannot go out is logged.
- Security: an end-to-end encrypted host now holds a client to the key it was first bound under for as long as the place lasts. A client whose connection dropped and comes back with a different key, a `KeyConfirm` that names another key, or an app that took a place through a handoff and returns with a key its token did not vouch for, is booted `key_unverified`. Before, a dropped connection let the host agree a fresh secret with a new key, and only an honest client's own check refused it.
- Every handoff that ends without the app taking the place is now told to the page: `client.state.handoff` reads `failed` with its stage when the app's redeem is refused (`refused`), when the ticket lapses unredeemed (`minted`), and when the evicted page's resume finds the app gone (`crashed`, on the resume's new `handoff_failed`), and the join page's offer comes back with a fresh ticket. The host now reads a refused redeem as `clientChanged` `handoff` `failed` at `refused`, with its connection unchanged. An app that refuses the place on its own check after its redeem ends with lifecycle reason `localRefusal` and reads its handoff `failed` at `refused`.
- A client in an end-to-end encrypted place now keeps the host fingerprint it verified in its stored record, and a reload is checked against it: an entry that names another host for the same place ends `hostUnverified` before anything connects, leaves the place and deletes the record, never re-binding to the newer host.
- A client holding a host fingerprint, from its join code, its handoff ticket or its stored record, now refuses a session the api answers as unencrypted: it ends `hostUnverified` before it connects (an app cancels the handoff first) instead of joining unencrypted. A join code with no fingerprint follows the api's answer as before.
- A host send checks size, then the byte cap, then pressure, as the client does: a message too large for a client's transport, or over the byte cap, reads `size` or `rate` while the seal backlog is full, never `pressure`.
- An end-to-end encrypted host whose sealing falls behind its send rate now drops the new send with `{ dropped: 'pressure' }` once 256 sends are waiting to be sealed, the way a full reliable queue does. Before, every waiting send stayed in memory until it was sealed, so a long session sending faster than it could seal grew without end. A seal or an open that fails now reaches `session.on('error')`, and the sends and messages behind it carry on. Before, a failed open stopped every later seal and open in the session.
- A host whose reliable send queue is full now logs one warning when the first send is dropped and one line with the count when the queue drains. Before, it logged a warning for every dropped send, which at a hundred sends a second stalled the page itself.
- A host send now answers in the order the reference gives: a client that is not in the session reads `noClient` before a missing grant reads `noGrant`, and a session that is not open reads `notOpen` before a full seal backlog reads `pressure`. A reliable send to an away client is held in its queue of 64 and goes when the client is back; before, it was published to a client that was not reading it.
- Under end-to-end encryption a host send now goes where the session and the client are when its seal finishes, not where they were when it was sent, so a send sealed while a client came back is no longer left in a queue nobody flushes. A sealed send dropped after it was answered `'sent'` (a full queue, a client with no key yet) is logged once per run of drops, with the count. A rekey that was running when the host came back on a new epoch now installs nothing, and a client leaving while a rekey starts no longer causes a second one.
- The host's direct-path signaling no longer leaves an unhandled rejection: a signaling step, an offer or an ICE restart that fails reaches `session.on('error')`, the attempt closes as a failed connection, and the steps behind it carry on. A sealed direct-path body no longer overtakes a message sent after it, which the client used to drop. A relay credential call that fails, and a late close that does not open, now log why.
- An end-to-end encrypted client now drops a new send with `{ dropped: 'pressure' }` once 256 sends are waiting to be sealed, the same budget as the host. Every other end-to-end encryption step that fails, on either end, now reports it and the steps behind it still run: a key agreement, a key confirmation, a rekey, a direct-path signaling body, or a seal or an open on the client. The host reports it on `session.on('error')` with code `internal`; the client, which has no `error` event, logs it at `error`. Before, one such failure was an unhandled rejection that could stop every later seal and open in the session.
- A host page that reloads and resumes its session now has its clients in `session.clients` when `open()` (or `resume()`) resolves, and their return to the new page raises no `clientJoined`. Before, the reloaded page's roster was empty until each client said hello again, and then every client already in the session arrived as a new `clientJoined`.
- The route profile a soak run reads now has its frames again on the session's default transport: the transport stamps each frame when it hands it over and samples the connection's round trip, so the route split and its per-frame segments are measured. Before, every frame read as never handed over and the split was empty. Nothing changes for an app: the profile is off unless a test harness turns it on.
- A host that opens session after session, such as an unattended host under `openForever`, no longer keeps every ended session in memory. A channel object from `host.channel()` held each session it had served so its `counters` could sum them; it now adds an ended session's counters in once and lets the session go. The counters read the same.
- A host whose connect to a node was answered and then dropped before it carried now asks for a new host ticket and comes back. Before, it presented the spent ticket again, the api removed that connection as a replay, and the host ended `participant_removed`, leaving its session with no host and its clients waiting (seen once after a node loss on dev).
- A handoff cancel from a browser or an app's web view now reaches the api: its preflight was refused because the api did not allow `DELETE` cross-origin. Before, an app that refused the host before it connected left the host reading `handingOff` and the page `pending`, where now they read `failed{cancelled}`. In an encrypted session the host also no longer boots the app's key when it reads the commit off a roster view before the app's `Hello`: the key is bound under its `cnf`, as it is when the api's `done` comes first.
- A host that resumes its session from `persist` on `open()` now counts each client's messages and bytes once. Before, every restart counted again everything each client had sent since it joined, so a host restarted five times metered about twice its traffic. `resume(hostToken)` was not affected.
- In an end-to-end encrypted session, a handoff link whose host fingerprint was changed no longer costs the page its place. The page's mint now sends the host key its `Welcome` verified, the redeem answers it to the app, and `joinWithHandoff` checks it against the fingerprint in the ticket before it connects. On a mismatch the app does not connect: it ends `hostUnverified`, cancels the handoff, and the host reads `handingOff`, then `failed` at stage `cancelled`, with the page still holding the place. Before, the app connected first, the place moved to it, and the app then ended.
- In an end-to-end encrypted session, an app whose join token was minted for another key no longer costs the page its place. `joinWithHandoff` now sends the thumbprint of the key its `Hello` will present on the redeem (`HandoffRedeemRequest.key_thumbprint`), and api compares it with the token's `cnf` before anything moves: a token with no `cnf`, or one that names another key, is refused `key_mismatch` (403), the ticket is spent, the app ends `refused` with that code, and the page keeps its place. Before, the app connected, the place moved to it, and the host then booted it `keyUnverified`. The host still checks the key the `Hello` brings.
- `@bitskiff/host-node` now exports the `LinkGrade` type, as `@bitskiff/host` does.
- In an end-to-end encrypted session the host now reports the app's key when the app takes a place by a handoff: a `clientChanged` with `change: 'e2ee'` carries the app's confirmation digits. The race demo app now opens an encrypted place's handoff link, whose ticket carries the host fingerprint after a `.`.
- A page whose connection drops while its handoff is pending now keeps `connection: 'handingOff'` on the host until the app takes the place or the window ends, with no `disconnected` change and no budget running. Before, the host read it `disconnected` and lost `handingOff`. If the handoff fails, the host now reads the page `disconnected` and its reconnect budget runs from then.
- A client the host refuses before it is welcomed now ends with the host's reason. A client whose key the host cannot verify ends `keyUnverified`, and one that cannot run end-to-end encryption on a `required` project ends `refused` with `errorCode` `e2ee_unsupported`. Before, it could end `booted` with no reason. A client that ends itself on its own check now reports the lifecycle reason `localRefusal` instead of `bye`.
- Our own tools no longer ask the owner to sign in: `npm run demo` and `npm run walk:dev` run under the CLI's stored credential through `npm run admin-key`, with no device code and no second factor, and the demo makes the projects and keys its hosted pages run on under the stored credential itself. A deploy whose bench run reads no readout now runs it once more a minute later before it rolls dev back.
- After a handoff, the app now moves up to `peer` the way a fresh join does. The host SDK tells the app's client its transport records once the app takes the place, and offers it `peer` when it asks. Before, the app stayed on `server` for the rest of the session. A page whose handoff fails gets its `peer` back the same way.
- An app that takes a client's place by a handoff and stops before it sends anything is now read `disconnected` by the host within about 3 s, and leaves at the end of `reconnectBudget`, where before the host read it connected until the session ended. A handoff the app redeemed and never attached now ends `failed` at stage `redeemed` when its 60 s window ends: a mint from the page while the app holds the redeemed ticket is refused `handoff_pending` (409, `retry_after_ms` the window's remainder) instead of cancelling the move.
- While a client's handoff is pending, the host SDK now serves the page as it did before: its messages go out at once, its `peer` link stays up and its transport records hold until the app takes the place. Before, the host queued its messages to the page and closed its `peer` link from the redeem, so the page's data fell back to `server` and its acks stalled. The host SDK also counts the app's silence from the moment it takes the place, so an app that stops before it sends anything is read `disconnected` about 3 s later.
- A join no longer hangs on a browser or WebView whose WebCrypto lacks X25519 or Ed25519 (Chrome before 133, Samsung Internet 23, an old Android System WebView). The client SDK now tries both once when the client is made. On a session with encryption off such a browser joins as any other does, with no key on its `Hello`. On a session that requires encryption `inspect` answers `e2eeUnsupported` and `join` rejects with `e2ee_unsupported` before the join request; `joinWithHandoff`, which cannot know first, joins with no key and the host turns it away. Either way, and when a `Hello`'s key cannot be made, the client ends `refused` with `ended.errorCode` `e2ee_unsupported`, the join rejects, the place is given back, and `state.handoff` reads `failed` at `refused` on an app that arrived by a handoff.
- A handoff in an end-to-end encrypted session now stays encrypted: the app's client presents a key of its own when it joins with the handoff ticket, the host binds that key only when the app's join token `cnf` names it and boots it `keyUnverified` otherwise, and a host opened with `e2ee: 'required'` boots any client that presents no key instead of serving it plain.
- A client that takes a subject at a handoff now counts against that subject's one place, so the same person cannot join a second session while the app holds the first; and a handoff that pauses a client's budget can no longer put that budget back on a client the host has just let go.
- A handoff now holds up end to end: the commit puts the app's token `cnf` on the client and gives a client that joined with no subject the app token's subject, so the page's old token is no longer accepted; an app that repeats its redeem with the same `Idempotency-Key` gets the same token and ticket back instead of new ones; the host's client list shows who holds a client and the app it is handing off to (`ParticipantView.holder`, state `handingOff`); a boot or the session's end in the window cancels the handoff, so the app's redeem reads `seat_spent`; a budget that starts during the window is no longer pulled early when the handoff ends. The page's end and its wait have reason rows of their own (`handed_off` on the client, the lifecycle reason `handoff_pending`).
- A prompt can no longer steer the CLI's stored credential through `bitskiff mcp`: the `config` verbs and `logout` are no longer tools, so no tool call moves the endpoint the key is sent to or drops the key, and `--endpoint` and `--profile` stay the server's own.
- A config file an older CLI wrote no longer keeps the organization key in it until some command writes the file: the first command that reads it moves the key into the OS credential store, rewrites the file owner-only with only the marker, and says so once on stderr. Where no store answers, the file stays as it is.
- An owner who sets `deviceSignIn: refused` now stops every machine already signed in, not only new sign-ins: a login key minted before the switch is refused `forbidden` with `details.reason = "policy"` and `details.policy = "deviceSignIn"` on every request in that organization, and acts again when the policy is lifted. A key a person made for CI or a server is not affected.
- A key now patches, rotates or revokes a project key only when that key's permissions, limits and `subjects` are inside its own: a key holding `keys:manage` and no session word is refused `forbidden` `missing_permission` on a key holding `sessions:open`, and a refused rotate hands out no successor, where before the check read only the words the request named.
- An organization key now needs `keys:manage` and no project allowlist to list the organization's keys on `GET /v1/orgs/{orgId}/admin-keys`, the organization admin relation the writes already check: a key holding only `project:read` or `projects:read` is refused `forbidden` naming the permission, a `keys:manage` key bounded to some projects is refused `forbidden` naming the relation, and a console session is unchanged.
- A key now patches, rotates or revokes a project key that leaves a limit empty only when its own value is no lower than the project's, since the empty row inherits the project's value: a key holding `concurrentSessions: 2` is refused `quota_above_ceiling` on a key that names no `concurrentSessions`, where before the empty row was not compared.
- The console's Keys pages now show when each key was last used: every key's use stamps `lastUsedAt`, at most once a minute per key, so a login key no longer reads `never`.
- A handoff ticket is now spent only by the registered app: a redeem with any other client key is refused `wrong_client` and leaves the ticket unspent, so the app's own redeem still takes the place, where before the refused redeem had already spent the ticket and the app read `handoff_invalid` `spent`. The ticket stays single use: of two redeems that race, one wins.
- The CLI now protects the organization key it stores: the config file is written owner-only on every platform, with an owner-only ACL on Windows, and says so when it cannot restrict the file.
- A staff key never touches the CLI config: the CLI reads `BITSKIFF_ORG_KEY`, with `BITSKIFF_ENDPOINT` and `BITSKIFF_ORG`, before its config, and `npm run admin-key` puts the key it mints in the environment of the shell it starts, or of the command `--exec` names, instead of handing it to `bitskiff login`.
- A developer's bill no longer carries a booted client's relayed bytes: a relay credential cannot be revoked, so a client the host boots (the relay flood rule boots one at once) can keep relaying until its credential expires, and what Bitskiff's relay counts on it from the boot on is now kept as relay-measured usage billed to no project, counted on `mrc_relay_bytes_total{kind="booted"}`. The bytes before the boot bill as any relayed byte.
- A client the host boots for flooding its relay connection now ends `booted` at once: the host announces the boot with `Boot` before it removes the client, as any boot is announced, where before the client read a rate close, backed off and, under the default 10 s reconnect budget, ended `reconnectBudget`.
- A client the host boots for exceeding a rate (ten seconds over the message rate, or past twice its channel's row) now reads `booted` at once: a host holding `manage` announces the boot with `Boot` before the boot route closes the client 4001, where before the client read the rate close 4100, backed off and could end `reconnectBudget`. A host without `manage` still closes such a client with the rate close 4100.
- A booted client's relay allocation now ends at the boot, not when its credential expires: the boot route has Bitskiff's relay cancel every live allocation of the booted client on every relay node, without the boot waiting on it, so nothing more is relayed toward the host on it. The credential itself still cannot be revoked, so an allocation the client makes afresh is billed to no project, as above.
- A key's own `tokensMaxTtl` now binds: a token minted on a key holding a shorter value than its project's is held to the key's, both at `POST /v1/tokens` and every time the token is presented, so lowering the key's value ends the longer tokens it allowed. The tighter of the project's and the key's value wins, and a mint past it is refused `validation` on `ttl` with `details.limit` `tokensMaxTtl` and `details.scope` naming which of the two bound.
- An organization key now needs `members:invite` to list, send or revoke invitations: a key without it is refused `forbidden` naming the permission, as the members routes already did, and a console session is unchanged.
- A local engine run stays on loopback: `-loopback` now rewrites an empty or `0.0.0.0` host in `-listen`, `-internal-addr` and `-metrics-addr` to `127.0.0.1` (an explicit address is kept), so the engine no longer listens on a LAN interface or prompts the desktop firewall.
- Three limits fixes: an experimental organization can now raise `linkMessagesPerSecond` up to its ceiling of 120 instead of every raise being held pending; a `samplePageSessions` refusal now records `clears: never` in `open.refused`, because a limit's own `clears` wins over its code's; and the sample join page shows "This page is a sample" for a sample-page refusal instead of the usage-limit message.
- The console's organization pages can be reached from the console: the projects page and every organization page carry a row of links to members, organization keys, limit raises and billing, the current page marked and a link back to the projects. The console's first load is also lighter, down from 500 kB to about 445 kB, because the message codec and the pages' words now load with the pages that use them.
- The Demo host's Connect over Bluetooth tap works again: each of its channels now declares the row size it really sends, so the row-size check against Bluetooth's smaller rows passes without an option.
- In the Unreal plugin, a transport's record now reads the support its handle reads: a peer withheld before Join reads `unsupported` in `GetTransportRecord` at once, where before the record could keep its old support until the session restated it.

### Removed

- The owner tools' device-code sign-in helpers, which nothing has called since `npm run demo` and `npm run walk:dev` moved under the stored credential. The device-approval routes, `bitskiff login` and `bitskiff init` are unchanged.

## [0.2.2] - 2026-10-30

### Added

- The Demo host's Transports line now shows every record of each client as the host sees it: `server`, `peer` and each declared transport with its status and reason, its `support` word, whether it is connected, the link's grade, round trip and whether it is relayed, and who moved it last and why (`by`, `cause`). It reports only; it asks the person nothing.
- The SDK now asks only your ask hook before anything a person would meet. Pass `ask` to `new BitskiffClient({ ask })`, or to a transport's own `peer({ ask })` or `bluetooth({ ask, name })`, and call `t.prepare()` when you are ready to ask: the hook answers `granted`, `refused` or `later`, and `prepare()` resolves the new `support`. Without a hook nothing is ever asked and the transport stays `needsAsk`. `client.asking` names the ask in flight. A Bluetooth client whose permission is not yet granted no longer advertises when the host taps; its host's tap fails until the page's hook grants it.
- Each transport record now says whether this client can carry it and in which role, at both ends: `support` reads `ready`, `needsAsk`, `asking`, `refused`, `unsupported` or `unknown`, and every transport handle has a read-only `profile`, readable before `join` or `open`. Call `withhold()` on a transport before `join` to keep this client off it; the host reads it `unsupported`.
- Each transport record now says whether it is connected and how good its link is, at both ends: `connected`, and while connected a `link` with its grade, round trip, jitter, loss, whether it is relayed and, on `server`, the path. The client grades its own links and the host shows the reading the client sent. A record also says when a connection is `restarting` and, once it reads `available` again, `lastFailedAt`, and `t.on('status')` and the host's `clientChanged` say who moved it (`by`) and why (`cause`). `state.transport` and `TransportInfo` are gone: read a client's engine path as `state.transports.server.link.path` and its peer connection as `state.transports.peer.connected`.
- The protocol now carries each transport's lifecycle between the client, the engine and the host: which transports each side supports and any change to that support, the health of each link in the heartbeats, who set a transport up, and why one closed. The SDKs can now show a developer what each transport is doing and why it ended.

### Changed

- The Android wrapper's Bluetooth asks for nothing on its own: starting Bluetooth no longer raises the nearby-devices permission, and fails as not allowed until it is granted. Only the join page's `bluetooth({ ask })` hook raises it, with the wrapper's own words first and then Android's dialog.
- The connection from a client straight to the host is now called `peer`, since the server path is WebRTC too and only the far end differs. Read each record under `transports.peer`; the options types are `PeerOptions` and `ClientPeerOptions`. Nothing else changes in how it behaves. `p2p` is no longer read anywhere: a host's saved allow and deny lists from an earlier release start empty, and api counts only `peer` traffic rows as the client-to-host transport.
- `peer` is declared at both ends like `bluetooth()`: `host.transport(peer(options))` before `open` or `resume` returns the settings handle you pass to `client.allow` and `client.deny`, and `client.transport(peer({ relayOnly }))` before `join` returns the client's handle with `connect()` and `disconnect()`. A host or client that declares nothing has only `server`. The host's `peer(options)` takes `allowed`, `relayOnly`, `maxConnections` and `peerConnection`. `open({ peer })`, `resume(token, { peer })`, `session.peer`, `client.peer` and `new BitskiffClient({ peer })` are gone, and from JavaScript passing `peer` to `open`, `resume` or the constructor throws `validation` naming the option. Declare `peer()` again before `resume` and the saved allow and deny lists come back. The hosted join page and the Demo host declare `peer()`.

### Removed

- The names 0.2.x kept for one release are gone, and the relay is Bitskiff's alone. Set relay-only with `relayOnly` on `peer`, at the host and the client: the `direct` option, `HostDirectOptions` and `ClientDirectOption` are removed. Your own STUN and TURN servers (`iceServers`) are no longer accepted, so relayed bytes are always Bitskiff's and metered to your project. Passing `direct` or `iceServers` is a type error, and from JavaScript `open` and `new BitskiffClient` throw `validation` naming the option. `Transport` now names only the transport handle (`client.server`, `client.peer`); the engine seam is `EngineTransport`. `createNodeTransport()` takes no argument and `NodeTransportOptions` is gone, as are `TransportPath` (use `EnginePath`) and the exported `CLIENT_RECORD_KEY` (a page reads its record under `clientRecordKey(key)`).

### Fixed

- The engine's metrics are no longer reachable from the internet; they were labelled with session ids, client ids and channel names. The `rtc.` address now answers only the signalling path, and every other path there answers 404.

## [0.2.1] - 2026-10-29

### What's new

- See which key let each client in and the origin it came from, so a leaked key shows up.
- Turn p2p on or off for each client. A client page starts p2p only when the person asks for it.
- p2p carries reliable messages as well as lossy ones.
- Attach another transport beside the server and p2p. Bluetooth is the first, from an Android app.
- Open your own Android app from the host's join link.
- Sessions carry on when an engine node is lost, and the host shows how long each place is held.
- The join page loads less on its first visit.
- A client can no longer flood the host or another client, or guess join codes past the limits.

### Before you upgrade

Four changes can break a 0.2.0 app. A client page must now call `client.p2p.connect()` to start
p2p, because p2p no longer starts by itself. A session whose `defaultGrants` gives every client a
host-only grant is now refused. Code that imports `TransportFactory` for the engine must use
`EngineTransportFactory`, and token mints are now limited per project as well as per key.

### Added

- The host now sees which client key let each client in and the origin it came from, so you can tell
  your join page's clients from your own app's, and a key seen from an origin you do not own has
  leaked. Read them in `client.state.keyId` (the key's id, never the key itself) and
  `client.state.origin`.
- The client's state also says whether the browser sent that origin or a native app declared it, and
  a declared origin is never treated as one the browser enforced. The console's session roster
  shows the key id and that source beside each client.
- Turn p2p on or off for one client with `client.allow(session.p2p)` or `client.deny(session.p2p)`.
  A deny wins over an allow, and the host keeps both in its saved state, so they survive a reload.
- Each client's state now lists its transports, the server and p2p, at both the host and the
  client. On the client page, `client.server` and `client.p2p` report each one's status as it
  changes.
- Attach another transport before open or join, with `transport(factory)` on the host or the client,
  the same way channels are declared. It reads and closes the way p2p does, and a message
  larger than its link carries is refused with `size`.
- Bluetooth is the first such transport. `bluetooth()` carries data between a client in an Android
  app, which advertises over Bluetooth Low Energy, and a browser host page, which connects to it
  over Web Bluetooth when the person taps, with `Client.connect(settings)`.
- The Bluetooth transport reports not attached in every other browser and in Node. It is tested in
  a single process, with the run on real devices still to come.
- Your own app shows the nearby-devices prompt through `bluetooth({ ask })`, and the SDK asks only when
  the host first reaches for the link. The host sees asking, then advertising or refused, and a
  refusal ends the attempt at once while the session carries on over the engine.
- Your own Android app can open from the host's join link. Claim your join domain as a verified App
  Link with `/.well-known/assetlinks.json`, and the link opens the app when it is installed and the
  browser when it is not.
- Usage records for a session and for a day now carry the data bytes the engine measured each way, in
  three `dataBytes` fields (down, up and grade). They read 0 until graded, and the webhook and the
  CSV export carry them too.
- See your project's sample page session count and both its bounds without opening a session. The
  console's limits panel and `bitskiff limits` show them, and `GET /v1/project/limits` returns them.
- A script can write a new key straight to a file. `bitskiff keys create --only secret` (or `--only
id`) prints that one value and a newline, without the JSON body.
- A client page can show its join code, or compare a link against it, without reading storage.
  `client.code` holds it.
- The session's meters count each kind of control message a client sends, and are never billed.
  Oversized hello and ping messages are refused.
- The hosted sample client page shows a Connect directly button when the host offers p2p, with the
  consent notice before the tap and Connecting, then Direct, while it switches. It has a Disconnect
  action, says in words why the host refused or the link failed, and never connects on its own.
- The console's session meters show the messages and bytes each transport carried in and out
  (server, p2p or your own), as the host reported them and never billed. The session meters route
  and the traffic snapshot event carry the same figures as `transports`.
- When an engine node is lost, the SDK notices within about 2.5 s even if no connection closes, and
  moves to the session's new node on its own. The host shows each missing client as disconnected,
  with how long the api holds its place.
- That held time is the one the api enforces. The host and each client keep their place until the
  move's deadline, and a client that never reaches the new node is released at that instant.
- The server benchmark page reads like a speed test at a venue. It shows one live figure while it
  runs, then the typical and 95th-percentile times and a rating graded on what your connection
  adds, beside the distance to the engine. It says why a result is below Good, what could be
  installed here and what refused a run, keeps Wi-Fi, Data and Wired runs side by side, and sends
  only what a default session can send.
- The benchmark page's Two devices mode has a second device scan a QR code, then measures the
  client-to-host leg both on the venue's network and through the engine. Copy results puts the
  result on the clipboard as version 5 JSON.

### Changed

- Every docs page now carries a notice that the pages describe the SDK as of 0.2.0 and are stale
  until the transport rework ships.
- p2p no longer starts by itself. The client page starts it with `client.p2p.connect()`, which
  resolves connected or failed and never throws, and ends it with `disconnect()`.
- The host makes a p2p offer only when a client asks for one, and limits the attempts, the size of
  setup messages and the number of p2p connections it holds. The `declined` close reason is reserved
  and never sent.
- When p2p fails, the client falls back to the server by itself and does not try again on its own.
  The p2p record says why it is not in use: failed until its `nextAt` time, notAllowed after a deny,
  unavailable while the client or the host is away, or available after `disconnect()` or an idle
  connection.
- A network change restarts the path of a live p2p connection in place, instead of ending it.
- p2p carries an ordered, reliable channel beside its lossy one, so reliable messages survive
  ordinary packet loss without falling back to the server. The p2p record reads active only once
  both are open, and messages on the link count on the p2p traffic row.
- When a p2p link's relay credential is about to expire, the link closes as failed and the client
  page connects again. The host no longer offers again unasked.
- `TransportFactory` now names only the factory of a transport you attach, such as `bluetooth()`.
  Code that imports it for the engine must use `EngineTransportFactory`, because the old name has no
  alias.
- The other engine connection types are renamed `EngineTransport` and `EnginePath`. The old names
  answer for one release (see Deprecated).
- Token mints are now limited per project as well as per key, as the limits table says.
  `limits.tokensPerMinute` sets the project's rate (default 600), and each key is held to the lower
  of 120 and that rate.
- The join page's first load is smaller. `@bitskiff/client` bundles only the api routes it calls,
  loads the QR scanner only when a client taps Scan, and loads the engine connection lazily.
- The p2p code loads only once a client's p2p record reads available or the page calls
  `connect()`, so a page whose host never offers p2p never downloads it.
- Installing `@bitskiff/host-node` now brings its WebRTC binding, `node-datachannel` (MPL-2.0), as its
  own dependency. The package no longer asks for a browser peer it never loads.

### Deprecated

- `Transport` in `@bitskiff/host` and `@bitskiff/host-node` still works as an alias of
  `EngineTransport` until 0.3.0.
- `createNodeTransport()` takes no options. `NodeTransportOptions` remains only as an ignored empty
  option until 0.3.0.
- The `direct` option is deprecated for one release and maps onto `p2p`. Turn p2p on with
  the `p2p` option on the host's open and resume and on the client's constructor.
- `TransportInfo` and `TransportPath` are deprecated for one release. In their place, read
  `state.transports` and `client.link`.

### Removed

- Bitskiff runs one engine, its own, behind every session. No SDK, REST or CLI setting names it, and
  nothing you write changes with it.
- The SDK packages no longer depend on the 0.2.0 engine's client libraries. An app that installed
  those libraries only for the SDK can remove them.

### Fixed

- A native client built on the core now hears its host: the core names the binding's channels when
  it joins and announces any it adds later, a text holding a zero byte arrives whole because
  `bitskiff_client_send_text` takes a length, and every native binding reports its device class.
- A session opened with `defaultGrants` now gives each joining client exactly that grant set,
  instead of the full controller set. An open whose default set holds a host-only grant is refused
  as `validation`, naming the field.
- A client can no longer fill another client's backlog or its download meter, even if it learns
  that client's id. A client's messages reach only the host, at a fixed rate.
- A client the host grants receiveInput during the session now hears the clients that send input.
- A host or resume token used on another session is refused as `session_not_found`, so a stolen
  token cannot tell which sessions are live.
- A client can no longer flood the host with control messages or the api with presence changes. The
  host allows 20 control messages a second per client and closes a sustained flood, and presence
  changes are held to one a second and 60 a minute per client, even when the client re-sends hello.
- A client the host closes for sending too fast is removed at once and cannot come back, whether or
  not it honours the close.
- Wrong join codes sent in a parallel burst from one address are held to the per-address bad-code
  allowance, back-off and hard cap. A guessed join token counts against the same allowance as a
  wrong code.
- A page refused as `rate_limited` reads the limit's name, scope and wait, but never the allowance.
- While the api rebuilds its quota counters after losing its cache, a sample-page join waits with a
  retryable `unavailable` instead of passing the sample-page session bound. Session opens are no
  longer refused with 503 in that window, and a project's sample page session count survives the
  loss.
- A token's subject may now be any Unicode letters, digits and marks, up to 128 code points, as the
  credentials page says. The mint used to refuse anything but printable ASCII.
- A subject is stored in its NFC form, so two spellings of one accented name are one subject, in the
  `subject` filters on the ledger and the token list too. A subject accepted before is unchanged.
- A host page the browser freezes keeps its session and says no goodbye, and is reported frozen
  rather than gone. It closes its p2p links while frozen, then offers p2p again as soon as it
  resumes, as a visible page does.
- The away deadline a client sets as it goes to the background now counts only the background time
  it has left, not the whole background budget.
- A client that goes to the background and quickly returns is no longer left away on the host. An
  app killed in the middle of a presence update no longer holds a place with no deadline.
- A client no longer receives one host message twice when its p2p link closes, or p2p is denied,
  in the middle of a broadcast.
- A host that suspends no longer leaves a client's presence change waiting for good.
- A client no longer sees its host as attached again after the host went away, so its p2p record
  cannot read available while the host is gone.
- After a host page reloads during a live p2p connection, the client's p2p record reads available as
  soon as the host is back. It used to read failed with a 30 s countdown for a failure that never
  happened.
- After a node loss, a host that meets a 502 reaching the new node now retries inside its host grace,
  after 250 ms and doubling to 2 s. An expired ticket is replaced once, and if the replacement is
  refused too, the session ends as rejected.
- A client and its host that reach a new engine node at the same moment after a node loss now find
  each other. The client is no longer released after waiting 10 s for an answer that never came.
- A second `open()` on one host without `persist.as` now opens a session of its own, instead of
  taking over the first one and ending it as replaced.
- After a node crash, a client whose rejoin got no answer tries again after 1 s, instead of closing
  as `network_lost` after 5 s.
- A reloaded client page keeps its place while the person is looking at it. The old page's unload no
  longer releases the client 30 s later.
- After an engine node is lost, a session with a host grace of 0s now waits for its host on the new
  node, instead of ending a moment after the move.
- Losing one engine node no longer ends the hosts and clients of sessions on other nodes.
- An engine node stopped on purpose now tells its hosts and clients `server_shutdown` first, so the
  SDK no longer mistakes the stop for a lost node.
- A host or client the engine ends for good now learns why, instead of seeing a lost link. That
  covers being removed or replaced, the session closing, and its node being lost or stopped, and a
  host replaced by a second connection with the same host token ends as `replaced`.
- After an engine node is lost, the api now records and sends `session.host_away` and
  `client.reconnecting`, instead of putting the host away silently.
- `bitskiff init` no longer fails when another project already uses your folder name as its slug.
  It takes the slug the server gives instead and prints it.
- The key `bitskiff init` makes now holds the project's session permissions, so `bitskiff keys
create --type secret` with the open, read and manage session permissions works right after sign-in.
- `bitskiff usage` now says that day D's usage appears at D+2 00:30 UTC, and nothing is final before
  then.
- A client that joins within two seconds of its host attaching no longer reports the host as back
  when it never left.
- When an engine node stops, the api now releases every client of its sessions, not only the host. A
  session that ends through host grace releases its clients before the end is recorded.
- The client page shows the host as disconnected while it is gone.
- A client the api released is never announced to the host as joined again. It ends on
  `seat_released`.
- The host CLI, stopped with Ctrl+C while the session is still opening, now closes that session
  instead of leaving it to run out its host grace.
- The CLI's usage and ledger commands refuse a month outside 01 to 12 instead of rolling into
  another year, and limits set refuses a negative value. Whoami with no stored credential tells you
  to run `bitskiff login`.
- The CLI says when the api refuses a stored organization key as expired or revoked, and where to
  make a new one. A 401 with no JSON body, such as from a proxy, is treated as a sign-in problem and
  exits 3 instead of 8.
- Two `@bitskiff/host-node` processes racing for the same stale slot can no longer both take it.
- Closing a session twice at once sends one goodbye and one close request. The second call waits
  for the first.
- A client that retried its hello before the answer arrived now reports connected once.

## [0.2.0] - 2026-09-28

The first tagged release: every change since the first published version, 0.1.0, to the
development head at the cut.

### Upgrading from 0.1.0

Every 0.1.0 client fails against 0.2.0, and a host may. Upgrade both:

```js
import { BitskiffClient } from '@bitskiff/client';

const client = new BitskiffClient({ key: 'bsk_pk_...' }); // a client key, now required
await client.join(code); // was client.start(code, opts); resolves to the client
// a join token moved from the constructor to the join: client.join(code, { token })
```

**A 0.1.0 app on dev is already broken.** Dev, the only environment, runs untagged development
heads, and these breaks are live:

- Since dev's roll at 2026-09-24 17:01Z, the api reads and writes the signature, project, SDK
  and request-id headers only under their `X-Bitskiff-` names, and since its roll at
  2026-09-25 23:28Z the joins read the network hint only as `X-Bitskiff-Network-Class`, where
  they read `Network-Class` (step 2). A webhook receiver checking the old signature header
  rejects every delivery, and each is retried for 24 hours; a REST caller's old project header
  is ignored, and a caller reading the old request-id header reads nothing. Rejected deliveries
  are not all lost: since dev's roll at 2026-09-25 18:56Z, `GET /v1/project/events`, on a
  secret key with `usage:read`, serves the same events for seven days. A delivery rejected before
  that roll is gone. Fix the receiver, then read back what it rejected before 2026-10-02 18:56Z,
  when the first of them age out.
- Since dev's roll at 2026-09-25 00:04Z, a project or key limit stored under a 0.1.0 name
  (`concurrentParticipants`,
  `participantMinutesPerDay`, `seatResumesPerMinute`, `tokensMaxTtlS`) reads as never set, until
  the 0.2.0 roll reads it under its new name. Set a project's again now (step 17).
- Since a dev roll on 2026-09-26 (UTC), the api reads a session's durations as strings (Wire,
  under Changed), so a 0.1.0 host that sets any duration option (`idleTimeoutMs`,
  `maxSessionTimeMs`, `hostResumeWindowMs`, `warningLeadMs`, `reconnectBudgetMs`,
  `backgroundBudgetMs` or `joinCodeRotationS`) fails its open. Upgrade the host and pass each as a
  duration string under its new name, such as `idleTimeout: '10m'` (step 4).
- A no-build page that loads the hosted module `/sdk/host@0.1.0.js` (or `host@0.1.0/unattended.js`
  or `client@0.1.0.js`) under the import map's `integrity` hash is refused the module by the
  browser, so its script never runs. Dev site publishes rebuilt those files in place, the first
  by 2026-09-24 07:52Z, so a page broke at the first publish after its hash was copied. A dev
  publish from `d321fd70c` (2026-09-28) on rebuilds no versioned file and carries each byte for
  byte, but a hash copied before the last rebuild stays wrong. Move the page as the no-build
  paragraph below says.
- Since about 08:00Z on 2026-09-27, a join without a client key is refused (step 1).
- Since dev's roll at 2026-09-27 20:57Z, codes are 15 characters (step 3).
- Step 10 is live in three parts: since dev's roll at 2026-09-25 01:02Z the routes over one
  client are `/v1/sessions/{sessionId}/clients/{clientId}` and the metering event kinds are
  `client.*`; since its roll at 2026-09-26 05:09Z an unwritable `Accept` is 406; and since its
  roll at 2026-09-27 07:59Z a request with no body and no `Accept` is answered JSON.

Do steps 1 to 19 now, in order, and ship host and client together: holding the build only
lengthens the outage.

To do them now, install the `-0.1.0.tgz` files the site serves today: `bitskiff-host`,
`bitskiff-host-node`, `bitskiff-client` and `bitskiff-ui-kit`, such as
`/downloads/bitskiff-client-0.1.0.tgz`, and the CLI with
`npm i -g https://docs.dev.bitskiff.com/downloads/bitskiff-cli-0.1.0.tgz`. Despite the name they
are build `86e32464d` (2026-09-27 22:48Z), the last site publish before the bump and later than
`a2d809e51`, the build dev runs (rolled 2026-09-27 21:28Z). They carry every rename in the
steps, `setPresence`, `hostResumeWindow` and `host.channel` included, but not what landed after
them: a host `Client`'s and a channel's refusal of an unknown event (step 6, Changed), the
unattended loop's refusal (step 8), its `{ signal }`, `off` and `UnattendedListenerOptions`
(Added), the host's `GET .../budget` question, or any Fixed line. Those arrive with
`/downloads/0.2.0/` at the cut. Vendor them in your app. Once 0.2.0 is cut, move to
`/downloads/0.2.0/`, and the CLI to
`npm i -g https://docs.dev.bitskiff.com/downloads/0.2.0/bitskiff-cli.tgz`. `/downloads/dev/`, the
development build, answers 404 until the next site publish writes it, and is never for a live
app: every publish replaces it.

A no-build page moves the same way. To try things now, load `/sdk/host@dev.js` (or
`host@dev/unattended.js` or `client@dev.js`) with no `integrity` entry, from the next site
publish on; every publish writes them afresh, so they are never for a live app. Once 0.2.0 is
cut, load `/sdk/host@0.2.0.js` with the `integrity` hash the quickstart shows for it, and the
unattended and client modules at 0.2.0 the same way. Only a release publish writes a versioned
file, once, and no publish replaces it; from the next publish on, `/sdk/published.json` lists
each versioned file the site serves with its hash.

Dev rolls to 0.2.0 on 2026-10-06 12:00 UTC.

What the roll changes for a live app:

- Joins per address: `joinsPerMinuteIp` (20 a minute) counts a whole IPv6 `/64` as one address.
  It already counts only your project's traffic on dev. A venue whose clients share one `/64` is
  refused `rate_limited` past 20 joins a minute unless you made step 1's raise.
- The engine: every live session drops, and ends unless its two budgets were raised (below).
- Client budgets: a session opened after the roll defaults to a 10 s `reconnectBudget`, where it
  was 15 s, and a 30 s `backgroundBudget`, where it was 5 minutes (CALL-1230); the ceilings are
  unchanged. A 0.1.0 app that relied on the 5-minute hold loses a backgrounded client after 30 s
  (ended `backgroundBudget`) and a client dropped in the foreground after 10 s (ended
  `reconnectBudget`). To keep the old hold, pass `reconnectBudget: '15s'` and
  `backgroundBudget: '5m'` on open, or, from the roll, set them as the project's session defaults.
- The byte row: `bytesPerSecond` defaults to 8 KiB a second, where it was 16 KiB, so a client that
  sends more is booted by its host after 10 s over it (Changed, below). The row cannot be raised
  before the roll: at the announced hour, once the roll is done, an app that sends more raises it
  back with `bitskiff limits set bytesPerSecond 16384` from the 0.2.0 CLI (`/downloads/0.2.0/`).
- `requireToken` is enforced: a key or project that sets it refuses a client without a token
  (step 17).
- Reloads: a second join as a token subject already in the session ends its earlier client
  `replaced`, so the host hears that client leave and the engine drops its connection, where dev
  releases it `left` and does not tell the host.
- A join token bound to another session is refused `forbidden`
  (`details.reason: 'token_not_for_session'`), where dev answers `session_not_found`.
- Rotations: a rotated key retires or parks, and an organization key's revoke cascades (steps 18
  and 19).

Over `a2d809e51` the roll also adds the per-field key patch, limits read under their 0.1.0
names, the host-ticket raise ceiling of 300 (step 1), plans and their read, the Limits changes,
the ledger, usage records, session defaults, `trimmed` on the events read, `bootCooldown`, and
the Fixed lines.

Dev has one engine node, so the new engine starts only after the old one stops. The old one
drains for up to 120 s first: from the start of the drain the node is picked for no new session,
so opens fail until the new engine runs, and a join to a session still live on it is not
promised. Every live session drops when the old one stops. On the budgets a session opened
before the roll holds by default (a 10 s `hostResumeWindow`, a 15 s `reconnectBudget`) expect
sessions to end and hosts to open new ones.

To carry a session across, open it before the roll with `hostResumeWindow: '5m'` (at most
`'30m'`) and `reconnectBudget: '2m'`, its ceiling; both run from the drop, so they need only
outlast the new engine's start. That start has not been measured on dev, so 2m is not promised:
a client whose budget runs out first leaves the session and joins again with the code.

From 0.2.0 on, a release that changes what a live app sees is announced in this changelog and on
the site's changelog page at least seven days before it rolls, with the roll's date and hour; a
release that changes nothing a live app sees rolls without notice. The notice counts from the
site's publish of the page, not from this file. The 0.2.0 roll gets the same seven days: the site
does not serve this page yet, and publishes it with the cut, at least seven days before the roll.
Dev's untagged rolls, which carried every break above, end with 0.2.0. The rule stands
(CALL-1228), and the release cut and the tagged roll refuse a release that breaks it. There is no
feed or mail for releases: read this page before each event you run.

1. `@bitskiff/client`: pass a client key as `key`, allowing each origin your client page is served
   from. Make a live app's key in the console (no expiry unless you set one), or under an
   organization key that expires as late as the app needs (at most a year). A key the CLI makes
   expires when the organization key the CLI holds expires (`expiresAt` in
   `bitskiff keys list --scope org`), or 90 days after its own making if that key has none.
   `--expires` only shortens it, and a CLI rotation caps the successor the same way. For
   development:
   `bitskiff keys create --type publishable --preset client-page --origin https://play.example.com`.
   Repeat `--origin` per origin; a native client names its scheme, such as
   `capacitor://localhost`. With no `--origin` a key allows only `http://localhost:*` and
   `http://127.0.0.1:*`, and any other origin is refused `origin_not_allowed` (reason `mismatch`).
   A client that sends no `Origin` (Node, or an app shell's `Origin: null`) passes `origin`, such
   as `new BitskiffClient({ key, origin: 'app://my-player' })`, and the key lists it, or it is
   refused `origin_not_allowed` (reason `absent`). An expired key's joins are refused
   `invalid_key`. A client key admits 300 joins a minute (`joinsPerMinuteKey`), which 0.1.0 did
   not cap, and refuses the rest `rate_limited`. From the roll, per-address limits count a whole
   IPv6 `/64` as one address, and `sessionOpensPerMinuteIp` (30) and `hostTicketsPerMinuteIp`
   (30) count only your project's traffic, as `joinsPerMinuteIp` (20 a minute) already does on
   dev. Before a large event
   raise them, joins per address to at most 200 and the others to 300, such as
   `bitskiff limits set joinsPerMinuteIp 200`. Today the joins and session-opens raises apply at
   once, but `hostTicketsPerMinuteIp` stays at most 30 until the roll brings its 300; a raise past
   a ceiling is refused `quota_above_ceiling`. From the roll, on the `unlimited` plan no raise
   waits; under `experimental` one past the default is held for approval. Every organization made
   before the roll, under 0.1.0 included, is on `unlimited`, so its raises never wait. Plans and
   their read (`whoami`, `GET /v1/orgs/{orgId}/plan`) arrive with the roll.
   Two windows count every project and cannot be raised: before a host ticket names its session,
   300 calls a minute per `/64`; and `badCodeAttempts`: past ten wrong codes from a `/64` in a
   ten-minute window, its joins, valid codes included, are refused `rate_limited` while a back-off
   runs (2 s, doubling to 60 s), and past 100 until the window ends. The constructor throws
   `validation` without a key (`details.option: 'key'`) and on a `token` option (`'token'`; pass
   it to `join`, as above). The host's publishable key is not a client key: its joins are refused
   `origin_not_allowed`, or `forbidden` (`details.reason: 'missing_permission'`,
   `details.permission: 'sessions:join'`) when its origins allow the page. A REST caller of join
   or inspect sends the key as `X-Bitskiff-Key` and `origin` in the body (Changed, below). An
   early 0.1.0 client refuses every 0.2.0 code itself as `invalid_code`.
   0.1.0's `start(code, { bundle: 'viewer' })` is dropped: every client joins with the host's
   `defaultGrants`, so a view-only client needs the host to set its grants.
2. Webhooks and REST: rename every header; the api ignores the old names, with no alias. A webhook
   receiver reads `X-Bitskiff-Signature` and `X-Bitskiff-Signature-Kid`, where it read
   `X-MRC-Signature` and `X-MRC-Signature-Kid`, or it rejects every delivery. A receiver answers
   2xx to a kind it does not handle: `open.refused` now reaches every project with a webhook, and a
   delivery answered with any other status is retried for 24 hours. Under Changed, below: a
   `limit.warning` or `limit.exceeded` receiver reads camelCase `data` and renamed `limit` and
   `scope` values; a `limits.raise_requested` receiver and a caller of
   `POST /v1/project/limits/raise` use the renamed limit ids; a REST caller sends
   `X-Bitskiff-Project` and reads `X-Bitskiff-Request-Id`; a usage reader queries the renamed
   meter ids, and sums old and new ids as Usage meters says.
3. Join codes: stop checking or reformatting them. A code is 15 case-sensitive characters under
   `e2ee: off`; under `e2ee: required` a client joins on the whole `session.code` string,
   `<code>.<fingerprint>`, so pass it as it is. Never upper-case what a person types. A
   16-character code, printed or stored, is `invalid_code`.
4. `@bitskiff/host`, `@bitskiff/client`: rename every duration option and pass a duration string,
   such as `'30s'` (Changed, below). Only `open()` refuses an old name (`validation`,
   `details.option` names it), and `openForever` throws a `TypeError` on an unknown top-level key.
   `ReconnectPolicy`, the `openForever` policy, `resume()` options and the client options ignore an
   old name and keep the default; only the TypeScript compiler catches those.
5. `@bitskiff/host`: declare channels with `host.channel(name, spec)` before `open()`, and give the
   client only the name, `client.channel(name)`. A lossy channel that needs more than 30 messages a
   second declares `maxHz`.
6. `@bitskiff/host`: replace `session.participants` and `on('participant')` with `session.clients`
   and the `clientJoined`, `clientChanged` and `clientLeft` events. `session.clients` is no longer
   a `Map`: it iterates `Client` objects, not `[id, participant]` pairs, and has no `forEach`
   (`get`, `has`, `keys` and `entries` remain). A client's fields are read from `client.state`,
   such as `client.state.deviceClass` where you read `p.deviceClass`; `seat` is `state.number`,
   and `bundle` is gone. On both SDKs a message handler
   takes one `Message`: `(m, meta)` is `(m)`, and the host reads the sender as `m.client`, a
   `Client`, where it read the id `meta.from`. `send(to, data)` takes a `Client`, a client id or an
   array of them, and `broadcast(data)` replaces `everyone`: a literal `'*'` target, which was
   `everyone`, is read as a client id and returns `{ dropped: 'noClient' }`. An `on()` or `off()`
   for an event the handle does not have now throws `validation` (Changed, below), so a leftover
   `session.on('participant', ...)` or `client.on('gone', ...)` throws at that line. A
   `ParticipantChange` handler is rewritten, not renamed (step 13).
7. `@bitskiff/host`, `@bitskiff/client`: `send()` returns `'sent'` or `{ dropped: reason }`. Code
   that compares the result with `'dropped'` or `'queued'` never matches now; test
   `result !== 'sent'` and read `result.dropped`.
8. `@bitskiff/host/unattended`: call `openForever(host, { session, policy })`, listen for
   `openRefused` and `stopped` where you listened for `openFailed` and `gaveUp`, and drop
   `maxStrikes`. Read the `loop.session` property where you called `current()`. Both events
   hand new shapes (Changed, below): `nextAt` is absent when the loop ends `disposed`, and a
   `stopped` `reason` is the loop's, not a session end reason. A leftover
   `loop.on('openFailed', ...)` or `loop.on('gaveUp', ...)` throws `validation`
   (`details.option: 'event'`) at that line from 0.2.0 on; the served `-0.1.0.tgz` build runs it
   as a `stopped` listener. A host you pass to `openForever` needs `open`,
   `platform` and `reconnect`, where it needed only `open`: pass the `BitskiffHost` itself, since a
   wrapper exposing only `open` throws a `TypeError` at the call. The defaults changed (Changed,
   below). Rename the options inside `session` too (step 4): a `session` that still carries
   `idleTimeoutMs` or `maxControllers` never opens, since every open is refused `validation` and
   the loop retries it every 300 to 600 s forever, so watch `openRefused` on first boot.
   `@bitskiff/host-node/operator`: `createDrain` now detaches on `SIGTERM` as well as `SIGHUP`
   when `detach` is wired, so a plain stop leaves the session running out its host grace; pass
   `restartOn: ['SIGHUP']` to close on `SIGTERM` as before. `processSignals(process)` hears only
   `SIGTERM` and `SIGINT`, so a process sent `SIGHUP` passes it in the list.
9. `@bitskiff/host`, `@bitskiff/host-node`: a `Persistence` of your own
   (`Platform.persistence`) no longer fits: its slot stores a host record, not the host token
   string (Changed, below). On Node, keep host state in your own store with
   `nodeStateStore(backend)` instead.
10. REST: rename the JSON fields, routes, error codes and event kinds your code reads (Changed,
    below). A REST caller reads and writes the proto3 JSON names, such as `maxClients`. A request
    with no body and no `Accept` is answered JSON, not protobuf, and an unwritable `Accept` is
    406, not 400, with code `validation` as before.
11. `@bitskiff/ui-kit`: import a component's constants and helpers from its own subpath, such as
    `DIRECTIONS` from `@bitskiff/ui-kit/dpad`.
12. Expect a fresh start on each machine and page from an early 0.1.0 build: 0.2.0 does not read
    state saved under the `mrc` names (Changed, below), nor a client record saved before early
    2026-09-27 (under `mrc:client`, or the bare `bitskiff:client` from builds of 2026-09-25 on),
    so the client joins afresh. Host state from builds of 2026-09-25 on, and the client's record
    under its key's name from builds of 2026-09-27 on, carry over. Nothing needs deleting. A CLI
    sign-in stored only under `mrc` must sign in again.
13. `@bitskiff/host`, `@bitskiff/client`: rename every value your code compares against and every
    method it calls that moved. A comparison with an old value is not refused; it never matches:
    - `ClientPhase`: `idle` is gone, and a reconnect is `joining`, where it was `reconnecting`.
    - End reason `seatsFull` is `clientsFull`; error code `seats_full` is `clients_full`.
    - `client.state.deviceClass`: `'phone'` is `'mobile'`.
    - `Channel.pressure` (`0 | 1`) is `Channel.congested` (a boolean).
    - Lifecycle reasons `hidden` and `visible` are `away` and `present` (a client reports them
      only once its page calls `setPresence`, step 15); transport path `turn` is `relay`.
    - `ParticipantChange`, an object with a `kind`, is `ClientChange`, a string, handed as
      `change` in `ClientChanged { client, change, previous, at }` on `clientChanged`. `joined`
      and the first `connected` are one `clientJoined` event, and `gone` is `clientLeft` (its
      `reason` on the event);
      `connected` after a drop is `'reconnected'`, `grantsChanged` is `'grants'` (the old grants
      on `previous.grants`, where they were `from`) and `e2eeChanged` is `'e2ee'`; `disconnected`,
      `away` and `present` keep their words; `'channels'` and `'network'` are new. A ported
      `c.kind === 'connected'` never matches: test `e.change === 'reconnected'`.
    - Deadline kind `seat` is reported as `reconnect`, and `Deadline.participantId` is
      `clientId`, on both SDKs.
    - Host end reason `left` is gone; one the SDK does not know is `unknown`, where it was
      `apiClosed`.
    - `BitskiffError.limit` and `details.limit`: `concurrentParticipants` is `concurrentClients`,
      and the rest as listed under Changed. The rename reaches a server on a secret or
      organization key, and every caller's `rate_limited` and `concurrency_limit` refusals. A
      `quota_exceeded` or `turn_quota_exceeded` refusal to a host or a client, on a publishable
      key or a token, names no limit at all in 0.2.0 (Changed, below), so a host branches on
      `code` and reads `resetAtUnixMs` (`details.resetAt` on REST), where it compared `limit`.
    - `session.setGrants(id, grants)`, `session.boot(id)` and `session.markActive(id)` are
      `setGrants(grants)`, `boot()` and `markActive()` on the `Client`; the old calls throw a
      `TypeError`, as the session no longer has them.
    - Session options `maxControllers` and `joinGrants` are `maxClients` and `defaultGrants`;
      `open()` refuses the old names `validation`. `maxViewers` is gone and `open()` refuses it
      too: set `maxClients` to the old `maxControllers` plus `maxViewers`, up to 500, or the
      former viewers are refused `clients_full`. A host that kept the default still holds 4
      clients.
14. Every package: run on Node 22 or later. Each one you install declares
    `engines: { node: ">=22" }`, which
    0.1.0 did not, so an install on Node 18 or 20 warns `EBADENGINE` and fails under
    `engine-strict`.
15. `@bitskiff/client`: set presence from your page. The client platform no longer reads page
    visibility, so a 0.1.0 page that upgrades never reports away, and its
    `backgroundBudget` never starts, until it wires its own signal:

    ```js
    document.addEventListener('visibilitychange', () =>
      client.setPresence(document.hidden ? 'away' : 'present'),
    );
    ```

    A custom `PresenceSource` that raises `hidden` or `visible` on a client is ignored, with no
    error.

16. CLI scripts: `whoami --format json` prints one JSON document per line on stdout: the
    resolution, the api's body, then the organization's plan when one is resolved, so a script
    reads the api body from the second line. Every `--format json` answer is the REST body, so the
    REST renames and the 200 `limit` cap (Changed, below) reach scripts that parse CLI output; the
    `keys` verbs but `create` and `rotate` leave out `masked` and `secret`, so a script names a key
    by `keyId`. `bitskiff keys rotate --overlap` and `bitskiff tokens mint --ttl` take a duration,
    such as `--overlap 1h` where a script passed `--overlap 3600`, and exit with a usage error on a
    bare number. The `--limit` names on `bitskiff keys` and `bitskiff tokens mint` are renamed
    (Changed, below), and an old name exits with a usage error. The CLI ignores
    `BITSKIFF_ADMIN_KEY`, so a script that sets it runs under the machine's sign-in, or fails for
    want of one: set `BITSKIFF_ORG_KEY`. `bitskiff deadlines parked` and `unpark` are gone
    (Removed, below; step 19).
17. Keys and project: clear `requireToken` on every key that sets it, and plan for the project's
    own. 0.1.0 stored it and never enforced it;
    from the roll a client is refused `forbidden` (`details.reason: 'token_required'`) without the
    token the stricter of the project's word and the opening key's asks for (`token`, or
    `token_bound_to_session`: one bound to the session). First, now, set again every project limit
    you raised under a 0.1.0 name, under its new name, such as
    `bitskiff limits set concurrentClients 50`, `clientMinutesPerDay`, `clientResumesPerMinute` or
    `tokensMaxTtl 1h`. The set applies at once, and at the roll the new name wins over the old.
    Until then such a raise reads at the default, so a key patch sent first that names a key limit
    above it is refused `quota_above_ceiling` whole, `requireToken` included, and the key's
    clients are refused `token_required` at the roll. Then, until the roll a key patch,
    `bitskiff keys update` included, replaces the key's whole `limits`, and `GET /v1/project/keys`
    leaves out a limit stored under a 0.1.0 name, so a patch copied from the GET erases it. Send
    `PATCH /v1/project/keys/{keyId}` with `limits: { requireToken: "off", ... }` holding every
    limit the GET shows, and every limit your own records say you set on the key before dev's roll
    early on 2026-09-25 (UTC), under its new name: `concurrentParticipants` as
    `concurrentClients`, `participantMinutesPerDay` as `clientMinutesPerDay`, and `tokensMaxTtlS`
    (seconds) as `tokensMaxTtl` (a duration, such as `'1h'`). If you cannot list them, patch
    nothing now: give that host's clients join tokens, and after the roll, whose patch keeps every
    limit it does not name, old names included, run
    `bitskiff keys update <keyId> --limit requireToken=off` from the 0.2.0 CLI
    (`/downloads/0.2.0/`); today's CLI exits `unknown limit`. Either way the key keeps every limit.
    The project's own `requireToken` can be neither read nor cleared before the roll: dev's limits
    read does not return it, and its raise refuses it `validation`. If the project may have set
    it, give its clients join tokens before the roll, and at the announced hour, once the roll is
    done, run `bitskiff limits set requireToken off` from the 0.2.0 CLI; until then every
    tokenless client is refused `token_required`. Each `token_required` refusal counts toward the
    shared `badCodeAttempts` window (step 1), so a venue's `/64` of tokenless clients soon has its
    valid joins refused too.
18. Keys: read `ancestry` before you revoke a key. Revoking a project key revokes every key made
    under it, recursively, keys made on 0.1.0 included, as dev has since 2026-09-21 (before, only
    the key). From the roll an organization key's revoke does the same, so revoking the one a CLI
    signed in with also revokes every key that CLI made (Changed, below). List the keys
    (`bitskiff keys list`, and `--scope org` for the organization's), find those whose `ancestry`
    names the key you revoke, and replace the ones that must keep working from a key you keep
    before you revoke it.
19. Keys, now: finish the rotations 0.1.0 left open; it never retired a rotated key.
    In `bitskiff keys list`, a key with `status` `rotating` names its new key in `successorKeyId`.
    One whose `retireAt` is still ahead at the roll retires then: move its hosts and clients to
    the new key first, or push it out with `bitskiff keys update <keyId> --retire-at <time>` (at
    most 30 days out, cut with no error). Nothing sweeps before the roll, so a past time set now is
    parked at the roll and the key keeps working: revoke a key that must stop now, after step 18.
    At the roll one up to ten minutes past its `retireAt` retires, and one further past is parked
    (Changed, below) and keeps working: move its hosts and clients, then re-arm it the same way, or
    unpark it with `POST /v1/project/deadlines/{kind}/{member}/unpark` (`kind` `rotation`,
    `member` the key id), called with an
    organization key holding `project:manage` and `X-Bitskiff-Project` naming the project, and it
    retires at the next sweep.
    `GET /v1/project/deadlines/parked` answers at most 200 entries (50 by default), with no cursor,
    so trust `keys list`.

The steps lead from any 0.1.0 build: the 0.1.0 files were rebuilt in place until the bump, so a
step whose old name your code does not use is already done.

Only the CLI names its build
(`bitskiff --version`, and `build` in `/downloads/manifest.json`, from 2026-09-27 on); an SDK
tarball carries none, and the manifest's `sha256` matches only the last 0.1.0 build, kept at
`/downloads/<scope>-<name>-0.1.0.tgz`. From 0.2.0 on, a version names one set of bytes.

0.2.0 was cut without the release checklist's previous-release conformance run or smoke join, so
what it says a 0.1.0 host or client meets is read from the 0.1.0 code, not
from a run, and the earlier the build, the more of it applies.

**From 0.2.0 on, nothing breaks in place**: a renamed or removed
name keeps working for one minor release. This release removes names with no such window, since
that rule starts at the first tagged release. The `Deprecation` and `Sunset` headers and the SDK
diagnostic for an old name are not built yet, and nothing on the wire is deprecated in 0.2.0.

### Added

- Downloads: `/downloads/0.2.0/<scope>-<name>.tgz` for every package, and
  `/downloads/latest/<scope>-<name>.tgz` for the newest release.
- `@bitskiff/host`: the direct path. `direct: true` in the host's session options offers each
  capable client a direct link that carries its lossy channels; the engine carries everything else
  and is the fallback. The relay is Bitskiff's own TURN server for the direct link, metered per
  credential. Off by default.
- `@bitskiff/client`: `direct: false` declines a direct link; `{ iceServers, iceTransportPolicy }`
  brings the page's own.
- `@bitskiff/host-node`: `direct.peerConnection`, the peer connection the direct path uses, such as
  `(config) => new RTCPeerConnection(config)` from werift. Without it, a client is reported
  `directNotOffered` with cause `noPeerConnection`.
- `@bitskiff/host`: each channel row carries `maxHz`, `maxBytes`, `latestOnly`, `ordered` and
  a lifetime (`lifetime` on the spec, read back as `lifetimeMs` on the row). The api clamps the
  row (`maxHz` to the class rate, `maxBytes` to the wire ceiling), and the channel object reports
  the row it runs. All reliable rows share one bucket of 20 a second
  per sender.
- `@bitskiff/host`: a session declares at most 64 channels; the 65th `host.channel()` throws
  `validation`.
- `@bitskiff/client`: `client.setPresence()`: presence is the app's to set, and the host reads it
  on the `Client`.
- `@bitskiff/host`: `session.capacity` and `on('capacity')`, so a live session learns its cap.
- `@bitskiff/host`: `open()` takes `openBudget` (a duration string, such as `'2m'`) and
  `idempotencyKey`, and `BitskiffOptions.key` may be a function.
- `@bitskiff/host`: the termination policy: `terminationRules` on the session, the
  `@bitskiff/host/expressions` subpath, and the `policy` deadline.
- `@bitskiff/host`, `@bitskiff/client`: `BitskiffError` carries `clears` and `source`.
- `@bitskiff/host`, `@bitskiff/client`, `@bitskiff/host/unattended`: every `on` takes
  `{ signal }`, and every handle has `off`; the unattended loop's `on` options are the new type
  `UnattendedListenerOptions`.
- `@bitskiff/host`, `@bitskiff/client`: `parseDuration` and `formatDuration`.
- `@bitskiff/client`: `clientRecordKey(key)`, the storage name of the record a client keeps.
- `@bitskiff/host-node`: `nodeStateStore(backend)`, for keeping host state in your own store.
- `@bitskiff/host/unattended`: the loop reports `health` and `error` events, and
  `UnattendedLoop.detach()` ends the loop and leaves the session running out its host grace.
- `@bitskiff/host`, `@bitskiff/host-node`: `HOST_RECORD_KEY`, the storage name of the host record;
  both packages export `HostKey`, the type of `BitskiffOptions.key`.
- `@bitskiff/client`: `resolveReconnectPolicy` and `JoinToken`, the type of `join(code, { token })`.
- `@bitskiff/host`, `@bitskiff/client`: `ResolvedReconnectPolicy`, what `resolveReconnectPolicy`
  returns; its fields are milliseconds and keep the `Ms` names (`baseMs`, `capMs` and so on).
- Error codes: `plan_expired` (the organization's plan passed its expiry), `overloaded` (the
  platform is at its admission ceiling; retry after `retryAfterMs`), `replaced` (a second join as
  the same token subject took the client's place) and `relay_not_configured` (the environment
  runs no relay).
- Error reasons: `forbidden` with `token_required` (the project requires a join token) and
  `token_not_for_session` (the join token is bound to another session); `invalid_key` with
  `details.reason` `absent`, `malformed`, `unknown`, `revoked` or `expired`.
- Webhooks: `open.refused`, delivered to every project with a webhook; the opt-in kinds
  `session.ended` and `client.gone`, one usage record each, chosen with `WebhookConfig.kinds`. A
  client's record carries `subjectUnknown`, true when its `subject` and `keyId` are empty because
  they could not be read, not because the client is anonymous.
- REST: `GET /v1/project/ledger` and `SessionView.usage`, the usage a developer bills on. A CSV page
  carries its next cursor in `X-Bitskiff-Next-Cursor`.
- REST: `GET /v1/project/events`, which reads back refusals such as `open.refused`. It pages by
  `cursor` and `limit`. From the roll a page carries `trimmed`, which dev's build does not:
  `true` when the `cursor` asked for is older than the oldest event the stream keeps, and the page
  starts at the oldest kept.
- REST: `GET /v1/sessions` filters by `label`, `subject` and `hostState`.
- REST: project session defaults, so an open that names no `idleTimeout` takes the project's.
- REST: device-code sign-in for a terminal: `POST /v1/auth/device/code`,
  `POST /v1/auth/device/token`, `POST /v1/auth/device/approve` and
  `GET /v1/auth/device/{userCode}`.
- REST: `GET /v1/orgs/{orgId}/plan`, the organization's plan and when it expires (the plan behind
  `plan_expired`). `PUT /v1/orgs/{orgId}/plan`, `GET` and `POST /v1/platform/invitations`, and
  `DELETE /v1/platform/invitations/{invitationId}` are platform staff only.
- REST: routes the SDKs call themselves, not for app code:
  `POST /v1/sessions/{sessionId}/relay-credentials`, and under
  `/v1/sessions/{sessionId}/clients/{clientId}` `POST .../unheard`, `GET .../budget` and
  `POST .../closing`; and `POST /v1/bench/tickets`, which the connection benchmark page uses.
- REST: a `concurrency_limit` refusal on `concurrentSessions` carries `details.sessionIds`, the
  sessions in the way, to a caller that may list them.
- Limits: `bootCooldown`, `10m` by default: a subject booted by policy, or by its own rate, is held
  out of the project that long.
- Limits: `joinsPerMinuteKey`, joins per minute per client key, 300 by default; and
  `joinsPerMinuteIp` may be raised to 200 per project and address.
- Keys: the subjects condition: a secret key that mints tokens carries `subjects`, the subject
  prefixes it may mint for, and a backend key mints join tokens without holding `sessions:join`.
- CLI: `bitskiff init` signs in, picks a project and writes a hello world. Its page loads the
  versioned host file the CLI was staged against, with its `integrity` hash, or else the dev build,
  `host@dev.js`, with no pin and a comment saying so; it never names a hosted file the site does
  not serve.
- CLI: `bitskiff ledger`, `bitskiff limits set <limit> <value>`, `bitskiff sessions list --label`,
  `--host` and `--subject`, `bitskiff sessions close --subject`, and `--host-key` and
  `--subject-prefix` on `bitskiff keys create` and `update`, and `--clear-host-keys` and
  `--clear-subjects` on `bitskiff keys update`.
- CLI: `bitskiff settings set` also writes the project's session defaults and `terminationRules`.
- Usage meters: `api_requests.{route_class}`, `egress_bytes_attributed` and
  `engine_bytes_unattributed`.

### Changed

- `@bitskiff/client`: every client is built with a client key, `new BitskiffClient({ key })`; the
  constructor throws `validation` without one. The api answers a join with no key `invalid_key`
  (reason `absent`), unless the session serves the platform's sample client page.
- `@bitskiff/client`: the `origin` option, and `origin` in the join and inspect body, is checked
  against the client key's origins, where it was matched against the session's join URL. The api
  reads it only when the request has no `Origin` header or `Origin: null`, and then requires it:
  a Node client, an app-shell page or a server passes it, or its join is refused
  `origin_not_allowed` (reason `absent`).
- `@bitskiff/host`, `@bitskiff/client`: the session's, the host's per-client `Client` objects'
  and the client's `on()` and `off()` throw `validation` (`details.option: 'event'`) for an event
  the handle does not have, where they accepted it and the handler never ran (a `Client`
  registered it on `'left'`). The unattended loop's `on` does too, where an unknown name ran its
  handler as a `stopped` listener. A channel's `on()`, the client's and both of the host's (the
  handle's channel object and a session's), throws the same for any name but `'message'`, where
  the host's returned a stop that registered nothing.
- Every published package declares `engines: { node: ">=22" }`, where 0.1.0 declared none.
- `@bitskiff/client`: `client.start(code, opts)` is `client.join(code, opts?)`, no alias, and it
  resolves to the client once it has joined. A join token is `join(code, { token })`, not a
  constructor option.
- `@bitskiff/client`: `ClientPhase` is `'joining' | 'connected' | 'suspended' | 'ended'`: `idle` is
  gone and a reconnect inside the budget is `joining`, not `reconnecting`. The `ClientChange` value
  `reconnecting` is gone too.
- `@bitskiff/host`, `@bitskiff/client`: `retry()` resolves to the session or the client, where it
  resolved to nothing (host) or the `ClientState` (client).
- Join code: 15 case-sensitive characters over `A-Z`, `a-z` and `0-9`, where it was 16 upper-case
  characters. A 16-character code, or one whose case was changed, is `invalid_code`.
- `@bitskiff/host`: `session.channel(name, spec)` is `host.channel(name, spec)`, called before
  `open()`, no alias; `open()` seals the table, a call after it throws `validation`, and
  `session.channel(name)` returns the declared channel.
- `@bitskiff/client`: `client.channel(name, spec)` is `client.channel(name)`; the host's row is the
  only spec.
- `@bitskiff/host`: a lossy channel with no `maxHz` is paced at 30 messages a second, where 0.1.0
  held it only by its class bucket, 120 a second. A send over the rate returns
  `{ dropped: 'rate' }`; declare `maxHz: 60` for a client sending at 60 Hz.
- `@bitskiff/host`, `@bitskiff/client`: `SendResult` is `'sent' | { dropped: reason }`, where it
  was `'sent' | 'queued' | 'dropped'`. The host's `DropReason` is `rate`, `size`, `pressure`,
  `noClient`, `notListening`, `noGrant` or `notOpen`; the client's `ClientDropReason` is `rate`,
  `size`, `pressure`, `noGrant`, `notOpen`, `hostAway` or `notDeclared`.
- `@bitskiff/host`, `@bitskiff/client`: a message handler takes one `Message`, where it took
  `(message, meta)`; `seq`, `receivedAt`, `sentAt` and `channel` are on the `Message`, and the
  host's sender is `m.client`, a `Client`, where it was the id `meta.from`. `meta.lagMs` has no
  counterpart on the `Message`; read `receivedAt - sentAt`.
- `@bitskiff/host`: `channel.send(to, data)` takes a `ClientTarget` (a `Client` or a client id) or
  an array of them, and `channel.broadcast(data)` sends to every client.
- `@bitskiff/host`, `@bitskiff/client`: `Channel.pressure` (`0 | 1`) is `Channel.congested`
  (a boolean), no alias.
- `@bitskiff/host`: `BitskiffOptions.e2ee` takes `'required'` or `'off'` and refuses anything else
  `validation`.
- `@bitskiff/host/unattended`: `openForever(host, session, policy)` is
  `openForever(host, { session, policy })`, and an old call with a third argument or a flat
  session config throws a `TypeError` (`openForever(host)` runs unchanged). It returns an
  `UnattendedLoop`, where it returned an `UnattendedHost`; the `openFailed` event is `openRefused`
  and `gaveUp` is `stopped`, no alias; the policy's `maxStrikes` is gone. `current()` is the
  `session` property; `OpenFailedInfo { strikes, retryInMs }` is `OpenRefusedInfo
{ attempt, nextAt }`; `GaveUpInfo { strikes, error, reason }` is `LoopStopped { at, reason }`,
  whose `reason` is `stop`, `reopenOn` or `disposed`; and `OpenableHost` requires `platform` and
  `reconnect` beside `open`. The defaults changed: `maxBackoff` is `5m`, where `maxBackoffMs` was
  30 s; the loop never gives up, where `maxStrikes` gave up after 20; and `reopenOn` accepts every
  end reason, where it refused `disposed`.
- `@bitskiff/host`, `@bitskiff/client`: every duration is a duration string, such as `'30s'`, and
  its option lost the unit suffix, no alias:
  - session options: `idleTimeoutMs` is `idleTimeout`, `maxSessionTimeMs` is `maxSessionTime`,
    `hostResumeWindowMs` is `hostResumeWindow`, `warningLeadMs` is `warningLead`,
    `reconnectBudgetMs` is `reconnectBudget`, `backgroundBudgetMs` is `backgroundBudget`,
    `joinCodeRotationS` is `joinCodeRotation`, `inactivityMs` is `inactivity` (also on `resume()`).
  - `ReconnectPolicy` and `DEFAULT_RECONNECT_POLICY`: `baseMs` is `base`, `capMs` is `cap`,
    `minSpacingMs` is `minSpacing`, `stableMs` is `stable`, `joiningBudgetMs` is `joiningBudget`,
    `openBudgetMs` is `openBudget`.
  - `@bitskiff/client`: `returnProbeMs` is `returnProbe`, `linkDeadAfterHiddenMs` is
    `linkDeadAfterHidden`.
  - `openForever` policy: `initialBackoffMs` is `initialBackoff`, `maxBackoffMs` is `maxBackoff`.
- `@bitskiff/host`: session option `joinGrants` is `defaultGrants`, no alias, and an empty value is
  refused `validation`.
- `@bitskiff/host`: `maxControllers` is `maxClients`, no alias; `maxViewers` is gone.
- `@bitskiff/host`: the roster is `Client` objects. `session.participants`, a
  `ReadonlyMap<string, Participant>`, is `session.clients`, a `ClientRoster` that iterates
  `Client` values and has no `forEach`. A client's fields (`deviceClass`, `grants`, `presence`,
  `connection`, `transport`, `networkClass`, `joinedAt`, `deadlines`, `e2ee`, `attested`) are on
  `client.state`, where they were on the `Participant`; `seat` is `state.number`, and `bundle` is
  gone. `on('participant')` is `clientJoined`, `clientChanged` and `clientLeft` on the session and
  `change` and `left` on the `Client`, and `session.setGrants`, `session.boot` and
  `session.markActive` are `setGrants`, `boot` and `markActive` on the `Client`. No alias.
- `@bitskiff/host`: a client's `deviceClass` is `'mobile'`, where it was `'phone'`.
- `@bitskiff/host`: a client that stays over its byte cap for ten consecutive seconds is booted,
  where it was ten dropped sends; its left reason is `rate`.
- `@bitskiff/host`: end reasons: `left` is gone and `unknown` is new, and an end reason the SDK
  does not recognise is `unknown`, where it was `apiClosed`.
- `@bitskiff/host`, `@bitskiff/client`: `Deadline.participantId` is `clientId`, and the SDK
  deadline kind `seat` is reported as `reconnect`.
- `@bitskiff/host`: `ParticipantChange`, an object union on `kind`, is `ClientChange`, a string
  union handed in `ClientChanged { client, change, previous, at }`: `connected` is `reconnected`,
  `grantsChanged` is `grants`, `e2eeChanged` is `e2ee`, `joined` and `gone` are the
  `clientJoined` and `clientLeft` events, and `channels` and `network` are new.
- `@bitskiff/host-node/operator`: `createDrain`'s `restartOn` defaults to
  `['SIGTERM', 'SIGHUP']`, where it was `['SIGHUP']`, so a `SIGTERM` detaches when `detach` is
  wired and the session runs out its host grace.
- `@bitskiff/client`: the client platform reads no page visibility; presence is the
  page's to set with `client.setPresence`, and a `PresenceSource` raising `hidden` or `visible` on
  a client is ignored.
- `@bitskiff/client`: the end reason `seatsFull` is `clientsFull`, no alias.
- `@bitskiff/host`, `@bitskiff/client`: the lifecycle reasons `hidden` and `visible` are `away` and
  `present`, and the transport path `turn` is `relay` (`TransportPath`, `TransportInfo.path`).
  `Diagnostic.topic` is gone.
- `@bitskiff/host`, `@bitskiff/client`: `BitskiffError.limit` names clients:
  `concurrentParticipants` is `concurrentClients`, `participantMinutesPerDay` is
  `clientMinutesPerDay`, `seatResumesPerMinute` is `clientResumesPerMinute`, `tokensMaxTtlS` is
  `tokensMaxTtl`, and `maxControllers` is `maxClients`. `maxViewers`, `monthlySpendCap`,
  `deviceCodesPerHourPerIp`, `invitationsPerHourPerOrg`, `labSessionReadsPerIp`,
  `latencyProbesPerIp`, `mailPerDayGlobal`, `signInLinksPerHourPerEmail`,
  `signInLinksPerHourPerIp` and `signInVerifiesPerHourPerIp` are gone; `bootCooldown`,
  `joinsPerMinuteKey`, `linkMessagesPerSecond`, `relayCredentialsPerMinute` and
  `sessionOpensPerMinuteIp` are new, and a per-address open refusal names
  `sessionOpensPerMinuteIp`, where it named `sessionOpensPerMinute`.
- `@bitskiff/host`, `@bitskiff/host-node`: a custom `Persistence` changed contract.
  `PersistSlot.read` and `write` take and return a host record (`hostToken`, `generation`,
  `sessionId` and the host's key pair), where they took and returned the host token string;
  `take` receives the sealing secrets as a third argument and may return a promise; and the
  optional `seals(medium)` is new. `nodeStateStore(backend)` is the supported way to use your own
  store.
- `@bitskiff/host-node`: `nodePersistence(dir, platform)` lost its third argument.
- `@bitskiff/client`: `client.channels` maps each name to its `ChannelRow`, where it mapped to a
  `Channel`; `ClientState.bundle` is gone and `ClientState.number` is new; `Inspection.verdict`
  gains `wrongClient`; `ClientEndReason` gains `rate`.
- `@bitskiff/client`: `CLIENT_RECORD_KEY` is `'bitskiff:client'`, where early 0.1.0 builds had
  `'mrc:client'`, and no client writes a record under it, though 0.1.0 builds of 2026-09-25 to
  early 2026-09-27 did (step 12); a page reads its record under
  `clientRecordKey(key)` (Deprecated, below).
- `@bitskiff/ui-kit`: the root export keeps the elements, the `define*` functions, their types and
  `THEME_TOKENS`. The tag constants
  (`BITSKIFF_*_TAG`), `DIRECTIONS`, `READOUT_COLORS`, `TRANSPORTS`, `BANNER_STATES`,
  `BACK_CLEAR_MS`, `LEAVE_ACTION` and the scanner's names (`SCANNER_STATES`, `SCANNER_FOUND_EVENT`,
  `SCAN_INTERVAL_MS`, `createCodeDetector`, `nativeCodeDetector`, `ponyfillCodeDetector`,
  `wasmLocator`, `joinUrl`, `QR_FORMAT`, `WASM_FILE` and the detector types) are imported from
  their component's subpath, such as `@bitskiff/ui-kit/scanner`. The status element's transport
  `turn` is `relay`.
- `@bitskiff/host-node`, `@bitskiff/host`, `@bitskiff/client`: saved state moved, and an early
  0.1.0 build's is not read (a later build's already used the new names, step 12). The Node
  host's state directory is `bitskiff/host` (under `%APPDATA%`, `~/Library/Application Support`
  or `$XDG_DATA_HOME`), where it was `mrc/host`; a browser host's keys are `bitskiff:host:...`,
  where they were `mrc:host:...`; a browser client's record is
  `bitskiff:client:<the last 16 characters of the client key>` (`clientRecordKey(key)`) and its
  key database `bitskiff-client-crypto-keys`, where they were `mrc:client` (then the bare
  `bitskiff:client`) and `mrc-client-crypto-keys`; its per-session keys are
  `bitskiff:client:key:<sessionId>` and `bitskiff:client:seq:<sessionId>`, where they were
  `mrc:client:key:` and `mrc:client:seq:`. The CLI's config stays under `bitskiff`; a config left
  under an older `mrc` directory is no longer read.
- Webhooks: `X-MRC-Signature` is `X-Bitskiff-Signature`, no alias.
- Webhooks: `X-MRC-Signature-Kid` is `X-Bitskiff-Signature-Kid`, no alias.
- HTTP: `X-MRC-Project` is `X-Bitskiff-Project`, no alias; the api ignores the old name.
- HTTP: `X-MRC-SDK` is `X-Bitskiff-SDK`, no alias.
- HTTP: `X-MRC-Request-Id` is `X-Bitskiff-Request-Id`, no alias.
- HTTP: `POST /v1/auth/bootstrap` reads `X-Bitskiff-Bootstrap`, where it read `X-MRC-Bootstrap`,
  no alias.
- HTTP: the join routes read the optional network hint as `X-Bitskiff-Network-Class` (or
  `Sec-CH-Network-Class`), where they read `Network-Class`, no alias.
- REST: a request with no body and no `Accept`, `*/*` or `application/*` is answered JSON, where it
  was answered protobuf; a request with a body is still answered in its body's format. An
  `Accept` the api cannot write is `validation` with HTTP status 406, where it was 400.
- REST: the session read fills each client's `transportPath` with `udp`, `tcp`, `turn` or
  `unknown`, where it always read `unknown`. The REST read spells the relayed path `turn`, where
  the SDKs say `relay`.
- REST: the per-client routes moved from `/v1/sessions/{sessionId}/participants/{participantId}` to
  `/v1/sessions/{sessionId}/clients/{clientId}`, no alias: `PATCH`, and `POST .../boot`,
  `.../leave` and `.../presence`.
- REST: `SessionConfig`: `maxControllers` is `maxClients` (`maxViewers` is gone), `joinGrants` is
  `defaultGrants`, and each duration field is a duration string without the unit: `idleTimeoutMs`
  is `idleTimeout`, `maxSessionTimeMs` is `maxSessionTime`, `joinCodeRotationS` is
  `joinCodeRotation`, `hostResumeWindowMs` is `hostResumeWindow`, `warningLeadMs` is
  `warningLead`, `reconnectBudgetMs` is `reconnectBudget` and `backgroundBudgetMs` is
  `backgroundBudget`. No alias.
- REST: `InspectResponse`: `maxControllers` is `maxClients` and `controllerSeatsFree` is
  `clientsFree` (`maxViewers` and `viewerSeatsFree` are gone), no alias.
- REST: `ListSessionsResponse.cursor` is `nextCursor`; `MintTokenRequest.ttlS` (seconds) is `ttl`,
  a duration string; `KeyLimits.tokensMaxTtlS` (seconds) is `tokensMaxTtl`, a duration string. No
  alias.
- REST: limit fields on `LimitsResponse`, `LimitsPatch`, `KeyLimits` and `TokenLimits`:
  `concurrentParticipants` is `concurrentClients`, `maxConcurrentParticipants` is
  `maxConcurrentClients`, `participantMinutesPerDay` is `clientMinutesPerDay`,
  `seatResumesPerMinute` is `clientResumesPerMinute`, and `LimitsResponse`'s
  `joinsPerMinutePerSession` is `joinsPerMinuteSession`, the name `LimitsPatch` already used; no
  alias. A duration limit reads back as a duration string.
- Limits: a limit stored by 0.1.0 under a renamed name holds. A project's or key's limits, a
  token's limits, a project's pending raise and a seed file stored under `concurrentParticipants`,
  `participantMinutesPerDay`, `seatResumesPerMinute` or `tokensMaxTtlS` are read under
  `concurrentClients`, `clientMinutesPerDay`, `clientResumesPerMinute` and `tokensMaxTtl` (seconds
  converted), rewritten under the new names at the object's next write, and logged once per object.
  A developer re-sets nothing, unless a patch before the roll left them out (step 17).
- REST: every route that takes `limit` defaults to 50 and refuses more than 200 `validation`, and
  a stale or foreign `cursor` is `validation` (`details.field: 'cursor'`), where it was an empty
  page.
- REST: a key reading a session it cannot reach gets `not_found` with `details.named`, where it got
  `session_not_found`; an organization key naming another organization gets `not_found`, where it
  got `forbidden`. A resume keeps `session_not_found`.
- Sessions: `reconnectBudget` defaults to `10s`, where it was `15s`, and `backgroundBudget` to
  `30s`, where it was `5m` (CALL-1230); the ceilings are unchanged. A session opened before the
  roll keeps the budgets it opened with; to keep the old hold, pass them on open or set them as
  the project's session defaults.
- Join: a second join as a token subject already in the session ends its earlier client
  `replaced`: the host is told, as of any client that leaves, and the engine drops that
  connection, where the earlier client was released `left` and the host was not told.
- Join: a join token bound to another session of the project is refused `forbidden`
  (`details.reason: 'token_not_for_session'`, naming neither session), where it was
  `session_not_found`.
- REST: `SessionView.projectId` is filled only for a secret key, an organization key or the
  console, and left out for every other caller.
- REST: the session read keeps its older field names in 0.2.0 (`participants`, `participantId`,
  `seatClass`, `controllersHeld`, `viewersHeld`); a later release renames them with a deprecation
  window.
- Error codes: `seats_full` is `clients_full` (the session is at `maxClients`), no alias.
- Error codes: `wrong_client` changed meaning. It was the origin mismatch; it is now a client key
  that does not fit the session, with `details.reason` `other_project` or `other_host`. An origin
  the client key does not allow is `origin_not_allowed` (reason `mismatch`).
- Error details: a limit refusal's `details.scope` value `participant` is `client`, and its
  `details.limit` values are renamed and removed exactly as `BitskiffError.limit` (above), on the
  REST api and every other surface that carries them.
- Event kinds, in the meter events: `participant.joined`, `.connected`, `.reconnecting`, `.away`,
  `.present`, `.grants_changed`, `.snapshot` and `.gone` are `client.joined` through
  `client.gone`, no alias. `GET /v1/project/events` and the webhook kind `client.gone` are new in
  0.2.0 and carry the `client.*` names from the start; 0.1.0's webhooks carried only
  `limit.warning`, `limit.exceeded` and `limits.raise_requested`.
- Usage meters: four ids name clients, no alias: `limit_hits.concurrent_participants` is
  `limit_hits.concurrent_clients`, `limit_hits.daily_participant_minutes` is
  `limit_hits.daily_client_minutes`, `limit_hits.seat_resumes_per_minute` is
  `limit_hits.client_resumes_per_minute` and `joins.refused.seats_full` is
  `joins.refused.clients_full`. The ids split at dev's first roll to a build from 2026-09-24 on,
  on 2026-09-25. The daily usage job recomputes the days two to
  four back, so the older two days of its first run after that roll carry both the old and the new
  `limit_hits.*` id, and later days the new id only: for a month, sum the old id before those two
  days and the new id from them on. `joins.refused.*` splits at the roll instant: the roll day
  carries both ids, and a month sums both. A per-address open
  refusal counts on `limit_hits.session_opens_per_minute_ip`, where 0.1.0 counted it on
  `limit_hits.opens_per_minute` with the project's own open refusals.
- Webhooks: `limit.warning` and `limit.exceeded` deliver `data` in camelCase, `orgId`, `keyId`,
  `limit`, `scope`, `scopeId`, `value`, `ceiling` and `requestId`, where it was the snake_case meter
  body. `value` is the observed count, where it was a copy of the cap; `used` and `period_end` are
  gone, and so is `project_id`: read the envelope's `projectId`. The day-minutes quota's `limit`
  value is `client_minutes_per_day`, where it was `participant_minutes_per_day`. `scope` may be
  the new `org`, for an organization cap, whose notice goes to every project of the organization,
  and a per-client `scope` is `client`, where it was `participant`.
  `keyId` is left out, not empty, when no key is involved. `requestId` is the event's own `id`,
  since a threshold notice answers no request, so it never matches a refused call's
  `X-Bitskiff-Request-Id`.
- Webhooks: every delivery's envelope carries `orgId` beside `projectId`.
- Webhooks: a failed delivery is retried for 24 h (at 1 minute, 5 minutes, 30 minutes, 2 hours and
  6 hours after the first attempt, then every 6 hours to 24 h, each up to 10% later for jitter),
  where it was three attempts over about a second and a half; each retry is
  signed afresh.
  Delivery is at least once, so a receiver dedupes on the event `id`.
- Wire: the changes below are made inside protocol major 1 before the first tagged release, not
  as recorded exceptions: a field that changes its type or name on its number under the
  same-number carve-out (CALL-1103), and the two channel fields on new numbers before the freeze
  CALL-1226 draws at that release. From 0.2.0 on the wire is frozen.
- Wire: the proto packages are `bitskiff.protocol.v1` and `bitskiff.metering.v1`, where they were
  `mrc.protocol.v1` and `mrc.metering.v1`. A renamed field keeps its number.
- Wire: `Payload.channel` (9, string) is `Payload.channel_id` (10, uint32), and
  `InputEnvelope.channel` (12, string) is `InputEnvelope.channel_id` (13, uint32): new numbers and
  a new type, the channel's index in the session's table.
- Wire: `MintTokenRequest.ttl`, `KeyLimits.tokens_max_ttl` and the `SessionConfig` durations
  changed type from integer to string as well as name.
- Wire: in the meter event, the `participant_*` body members are `client_*` (numbers 6 to 13),
  `DEVICE_CLASS_PHONE` is `DEVICE_CLASS_MOBILE`, `TRAFFIC_SOURCE_PHONE` is
  `TRAFFIC_SOURCE_CLIENT`, and the `LIMIT_NAME_*PARTICIPANT*` and
  `LIMIT_NAME_SEAT_RESUMES_PER_MINUTE` values name clients. The meter event's `SessionConfig`
  field `max_controllers` (1) is `max_clients`, and `max_viewers` (2) is reserved.
- Wire: the data-plane topics are `bitskiff.ctl`, `bitskiff.in` and `bitskiff.out`, where they
  were `mrc.ctl`, `mrc.in` and `mrc.out`; a `transport` of your own (`TransportFactory`)
  publishes on the new names.
- Limits: `requireToken` (`off`, `token`, `token_bound_to_session`) is enforced at the join, where
  0.1.0 stored it and never enforced it (step 17); inspect and the limits read return it.
- Keys: a key patch (`PATCH /v1/project/keys/{keyId}`, `bitskiff keys update --limit`) merges
  `limits` per field, where it replaced the whole object and erased every limit it did not name:
  a named field is set, a field sent as `null` is cleared back to the value above it (the patching
  key's, when a key writes the patch and holds one, else the project's), and one left out is
  kept. Only a JSON body clears a field. `permissions` and `origins` still replace the whole list.
- Limits: the limit ids `POST /v1/project/limits/raise` takes and every webhook `data.limit`
  carries, `limits.raise_requested` included, are renamed, no alias: `concurrent_participants` is
  `concurrent_clients`, `participant_minutes_per_day` is `client_minutes_per_day` and
  `seat_resumes_per_minute` is `client_resumes_per_minute`. A raise naming an old id is refused
  `validation`.
- Limits: every per-address limit counts a whole IPv6 `/64` as one address, where 0.1.0 counted
  each address; step 1 has the rows, their raises and the two windows every project shares. One
  project's raise never spends another's, and a host-ticket lookup that finds no session spends
  its own window, held at 30. `hostTicketsPerMinuteIp`'s raise ceiling is 300, where it was 30.
- Limits: an open reserves `2 x maxClients x (min(lossy maxHz sum, messagesPerSecond) +
min(reliable maxHz sum, 20)) + 25 x maxClients + 25` deliveries a second against the engine
  throughput ceiling, the last 25 for the host, plus one more for each 112 bytes a second its
  rows declare past what those deliveries carry, where the last 0.1.0 builds reserved
  `2 x maxHz x maxClients` per row, their sum capped at `maxClients x messagesPerSecond`, against 2850. The rows declare
  `2 x maxClients x min(sum of maxHz x maxBytes, bytesPerSecond)` bytes a second. The ceiling is
  7000 a second per engine node (the 2026-09-28 capacity run), one budget shared by every project
  on the environment, and dev has one engine node. A typical session (one lossy row at 30 and one
  reliable row at 20, `maxClients` 4, the 8 KiB byte row) reserves 586, 400 for the rows, 100 for
  the clients, 25 for the host and 61 for the bytes it declares, so eleven fit on dev in all, and
  a project's five reserve 2930 of the 7000; a home game of 8 clients on the same rows reserves
  1171, and a session with no rows reserves 125.
  `maxClients` stays 4, `concurrentClients` defaults to 40, where it was 25,
  `concurrentSessions` stays 5, and `joinsPerMinuteSession` still never prices a session below 4
  joins a minute. A session opened before the roll counts at what it reserved when it opened,
  until it ends. An open that would pass the ceiling is refused `overloaded`, as before,
  and that may be another project's load: retry after `retryAfterMs`.
- Limits: `bytesPerSecond` defaults to 8 KiB a second for each sender in each direction, where it
  was 16 KiB, so a client of a 0.1.0 app that sends more is booted by its host after 10 s over
  the row (the roll, above). A
  project can now raise it from the default up to its plan's ceiling (16 KiB on `experimental`,
  none on `unlimited`) with `PUT /v1/project/limits` or `bitskiff limits set bytesPerSecond`; a
  client key can still set a lower value, and a raised value reserves more per session (1171 at
  16 KiB against 586 at the default, above).
- Limits: a project limit you never set now follows the catalogue default, on projects made under
  0.1.0 too: a project that never raised `concurrentClients` reads 40, not the 25 it was created
  with; a value you set stays. A limit a 0.1.0 project set to its default value reads as never
  set; today that changes only a `concurrentClients` set to 25, so set it again.
- Licence: every package is licensed Apache-2.0, where 0.1.0 was MIT.
- Limits: a usage-limit refusal answered to a page, a token or a host names no limit, and rounds
  `retryAfterMs` and `resetAt` up to the hour; a secret key, an organization key and the console
  still read the full refusal.
- Keys: a rotated key's predecessor retires at its deadline on the running deployment, where
  0.1.0 never retired it. Rotating a key no longer revokes the keys it made: when the overlap ends
  only the old key is retired, and every key it made moves to the new key, audited as one
  `key.updated` per key. Rotations that 0.1.0 left overdue are parked at the 0.2.0 roll, each
  audited `key.rotation_parked` in your project's audit log (step 19).
  The parked-deadline routes see and touch only the caller's project, where 0.1.0's touched every
  project's: unpark answers another project's member as missing, `session_not_found`, or
  `validation` (`no such key`) for `rotation`.
- Keys: revoking an organization key revokes every key made under it, recursively, as a project
  key's revoke has on dev since 2026-09-21 (step 18).
- REST: a boot from outside the session reaches the host and the client as a boot, and a close or
  a boot from outside names who did it.
- CLI: the `--limit` names on `bitskiff keys` and `bitskiff tokens mint` are `concurrentClients`
  and `clientMinutesPerDay`, where they were `concurrentParticipants` and
  `participantMinutesPerDay`; on `bitskiff keys`, `tokensMaxTtlS` (seconds) is `tokensMaxTtl` (a
  duration). Neither old spelling, camelCase or snake_case, is accepted.
- CLI: `bitskiff tokens mint --ttl` takes a duration, such as `10m`, and refuses a bare number,
  where it took seconds.
- CLI: `bitskiff keys rotate --overlap` takes a duration, such as `--overlap 1h` where it was
  `--overlap 3600`, and exits with a usage error on a bare number.
- CLI: `--org` is a global flag on every verb.
- CLI: `whoami --format json` prints one JSON document per line on stdout (the resolution, the
  api's body, then the plan), where stdout was the api's body alone and the resolution went to
  stderr; and every list answer carries its list field, empty or not.
- CLI: `--format json` prints the REST body unchanged, so the REST field renames and the 200
  `limit` cap above apply to scripts that parse CLI output. The one exception: `keys list`,
  `update` and `revoke` leave out `masked` and `secret`, where early 0.1.0 builds printed them;
  a key is named by `keyId`.

### Deprecated

- `@bitskiff/client`: `CLIENT_RECORD_KEY`, which no client writes under any more; removed in
  0.3.0. Read a page's record under `clientRecordKey(key)`. The export carries no deprecation
  mark of any kind in 0.2.0: no `@deprecated` tag, so an editor shows nothing, and no notice on
  its use, as the SDK diagnostic is not built yet.

The session read's older field names (Changed, above) are not deprecated yet: they are deprecated
in the release that adds their new names. No route carries the `Deprecation` or `Sunset` header in
0.2.0 (Upgrading from 0.1.0).

### Removed

Only the first two lines below were deprecated first, in 0.1.0; every other name was removed
before the first tagged release with no deprecation.

- `@bitskiff/host`, `@bitskiff/client`: `EndedInfo.retryable` and `ClientEnded.retryable`
  (deprecated in 0.1.0, where they always read `false`). An end is always an end; a stopped
  attempt is `phase: 'suspended'`, and `errorCode` and `BitskiffError.clears` say what to do next.
- CLI: `BITSKIFF_ADMIN_KEY` (deprecated in 0.1.0) is no longer read, and setting it is ignored
  with no notice; set `BITSKIFF_ORG_KEY` (step 16).

- `@bitskiff/host`, `@bitskiff/host-node`: `Participant`, `ParticipantChange`,
  `ParticipantConnection` and `ParticipantPresence`; use `Client`, `ClientChange`,
  `ClientConnection` and `ClientPresence`.
- `@bitskiff/host`, `@bitskiff/host-node`, `@bitskiff/client`: `everyone`; use
  `channel.broadcast()`.
- `@bitskiff/host`, `@bitskiff/host-node`: `HostMessage` and `MessageMeta`; a handler is handed one
  `Message`.
- `@bitskiff/host`, `@bitskiff/host-node`: `TOPIC_CTL`, `TOPIC_INPUT`, `TOPIC_OUT`, `seatClassOf`,
  `qrCapacityBytes` and `isTrustedPeer`, with no replacement.
- `@bitskiff/host`: `BitskiffOptions.joinTrust`, with no replacement.
- `@bitskiff/host`, `@bitskiff/host-node`: the internal helpers `apiErrorToBitskiffError`,
  `httpFailureToBitskiffError`, `errorCodeFromWire`, `bundleFromProto`, `bundleToProto`,
  `grantsFromProto`, `grantsToProto`, `ReconnectScheduler`, `ReconnectSchedulerOptions`,
  `ReconnectRange`, `ScheduleOutcome` and `ScheduleRequest`.
- `@bitskiff/host/unattended`, `@bitskiff/host-node/unattended`: `UnattendedHost`, `GaveUpInfo`
  and `OpenFailedInfo`; use `UnattendedLoop`, `LoopStopped` and `OpenRefusedInfo`.
- `@bitskiff/client`: `StartOptions` (use `JoinOptions`), `ChannelSpec` (the host's row is
  `ChannelRow`), `SessionConfigValues` (use `SessionConfigRead`), `JoinBundle`, `BundleName` and
  `MessageMeta`.
- `@bitskiff/ui-kit`: `privateName` from the root, and `fakeCodeDetector`, `FakeCodeDetector` and
  `Detection` from the root and from `@bitskiff/ui-kit/scanner`.
- `@bitskiff/host`: `maxViewers`; every client is a client.
- REST: `JoinRequest.bundle`, `InspectResponse.maxViewers`, `InspectResponse.viewerSeatsFree` and
  `SessionConfig.maxViewers`.
- CLI: `bitskiff dev` (`up`, `down`, `seed`), which was the repository's own tool.
- CLI: `bitskiff deadlines parked` and `bitskiff deadlines unpark`, public 0.1.0 verbs that called
  `GET /v1/project/deadlines/parked` and `POST /v1/project/deadlines/{kind}/{member}/unpark`, with
  no alias: a script that runs them exits with a usage error. The routes remain, and
  `bitskiff keys update <keyId> --retire-at <time>` re-arms a parked rotation (step 16, step 19).
- 0.1.0's saved-state names are no longer read (Changed, above).

### Fixed

- `@bitskiff/client`: the transport event fires when the path moves, not on every round-trip
  sample.
- `@bitskiff/client`: a reload after the join code rotated resumes on the stored record.
- `@bitskiff/host-node`: a restart after a crash takes the lease as soon as the old one expires.
- `@bitskiff/host`, `@bitskiff/client`: an api outage is charged to no host grace and no client
  budget, and deadlines moved during it are announced again.
- `@bitskiff/host`: a host that runs around the clock resumes after days offline.
- `@bitskiff/host`, `@bitskiff/client`: a replayed ticket no longer ends the client or the host,
  and a resume whose answer was lost still resumes.
- Webhooks: a retry is signed afresh with its own `t`.
- Limits: a token's `clientMinutesPerDay` holds on live usage.
- Limits: an organization-scope refusal is labelled the organization in the console and the CLI.
- CLI: the "raise it with" hint names `bitskiff limits set`, which now exists.
