# Reservations (/docs/guides/capacity/reservations)

> Reserve GPUs from a pool with an idempotent request that Better IAM decides on, follow it from draft to released, and extend, renew or release it.



A reservation is a tenant's claim on devices of one pool for a time
window: four NVLink-connected H100s for eight hours from now, or two nodes for three days starting
Monday. It is created as a request-as-resource: the request is first written as a
draft, Better IAM decides on that draft with everything it asks for as
attributes, and only an allowed draft is placed. So a policy such as "at most 8 H100s for at most 72
hours in these regions" is one decision on the reservation itself, not a check you write in code.

## Reserve GPUs [#reserve-gpus]

  **Server:**

    ```ts title="reserve.ts"
    import { compute } from './compute';

    const reservation = await compute.api.reservations.create(
      { headers: request.headers }, // the caller's credential
      {
        tenantId: 'acme',
        poolId: 'pool_01k…',
        devices: [{ class: 'gpu', count: 4, topology: 'nvlink-all' }],
        duration: '8h',
        idempotencyKey: 'train-2026-09-24',
      },
    );
    // reservation.state: 'placed' (or 'pending' with an Unschedulable condition)
    ```
  
  **Browser client:**

    ```ts title="reserve.ts"
    import { createComputeClient } from 'better-compute/client';
    import type { compute } from './compute';

    const client = createComputeClient<typeof compute>({
      baseURL: 'https://compute.example.com',
      token: () => session.getToken(),
    });
    await client.reservations.create({
      tenantId: 'acme',
      poolId: 'pool_01k…',
      devices: [{ class: 'gpu', count: 4, topology: 'nvlink-all' }],
      duration: '8h',
      idempotencyKey: 'train-2026-09-24',
    });
    ```
  
  **HTTP:**

    ```bash
    curl -X POST https://compute.example.com/api/compute/reservations/create \
      -H "Authorization: Bearer $BETTER_COMPUTE_TOKEN" \
      -H "Content-Type: application/json" \
      -H "X-Better-Compute: 1" \
      -d '{"tenantId":"acme","poolId":"pool_01k…","devices":[{"class":"gpu","count":4,"topology":"nvlink-all"}],"duration":"8h","idempotencyKey":"train-2026-09-24"}'
    # → { "data": { "id": "rsv_…", "state": "placed", … } }
    ```
  
  **CLI:**

    ```bash
    better-compute reserve --tenant acme --pool pool_01k… --gpus 4 --for 8h --nvlink --key train-2026-09-24
    better-compute reserve --tenant acme --pool pool_01k… --gpus 2 --for 8h --cpus 32 --memory 256
    better-compute capacity --tenant acme --preview --pool pool_01k… --gpus 2 --for 8h --cpus 32 --memory 256
    better-compute reservations get rsv_… --tenant acme --query binding
    ```

    `reserve` sends a new idempotency key on every run unless you pass `--key`, and prints the key so you
    can retry. `--cpus` and `--memory` (GiB) book [host resources](#host-resources) with the GPUs, and `--kubernetes` asks
    for `binding: 'kubernetes'` (ea36a94); `capacity --preview` takes the same three. See the [CLI reference](/docs/reference/cli).
  
<TypeTable
  type="{
  tenantId: { type: 'string', description: 'The tenant reserving. The caller must belong to it (or be root).', required: true },
  poolId: { type: 'string', description: 'A pool the tenant owns or is offered, not archived.', required: true },
  devices: {
    type: 'DeviceRequest[]',
    description: '1 to 8 entries of { class, count (1 to 64), selector?, topology?, sharing? }; at most 64 devices in all.',
    required: true,
  },
  duration: { type: 'string | number', description: 'Like 30m, 8h, 7d or 1h30m, or milliseconds; from 1 minute to 365 days.', required: true },
  idempotencyKey: { type: 'string', description: '1 to 128 letters, digits, dots, underscores, colons or hyphens. Scoped to the tenant and the caller.', required: true },
  start: { type: 'number', description: 'Epoch milliseconds; a later start makes a calendar reservation.', default: 'now' },
  topology: { type: &#x22;'any' | 'nvlink-all'&#x22;, description: 'nvlink-all requires every GPU of a device request to be NVLink-connected.', default: &#x22;'any'&#x22; },
  binding: { type: &#x22;'hold' | 'node' | 'kubernetes'&#x22;, description: &#x22;hold hands the devices to software on the node; node leases whole machines (v0.5); kubernetes hands them to the node's kubelet, in a pool with policy.kubernetes (v1.1).&#x22;, default: &#x22;'hold'&#x22; },
  node: { type: '{ profileId: string; profileVersion?: number }', description: &#x22;binding 'node' only: the metal profile to deploy.&#x22; },
  resources: { type: '{ vcpu?: number; memoryGiB?: number; hugepages1Gi?: number }', description: 'Host CPUs (0 to 4096), memory in GiB (0 to 65536) and 1 GiB pages (0 to 4096) per node, booked with the devices (v1.1). Left out: the pool\'s per-GPU share. Refused with binding node. See host resources below.' },
  leaseSeconds: { type: 'number', description: '30 to 86400. Makes the reservation renewable: it is released unless renewed within this period.' },
  projectId: { type: 'string', description: 'A project (prj_) to account the reservation to.' },
  spot: { type: 'boolean', description: 'Preemptible, billed at the spot meter (v0.5).', default: 'false' },
  gang: { type: '{ size: number; topologyKey?: string }', description: '2 to 64 nodes, each taking devices, all or nothing (v0.5).' },
  wait: { type: '{ timeoutMs: number }', description: '0 to 30000. Holds the answer while the reservation is queued for the pool-lease placer; see waiting for placement below.' },
}"
/>

Unknown fields are `INVALID_INPUT`. `binding: 'instance'` is `UNSUPPORTED`: VPS instances are created
with `instances.create` and book through the capacity port (see [instances](/docs/guides/vps/instances)).

## The reservation object [#the-reservation-object]

<TypeTable
  type="{
  id: { type: 'string', description: 'rsv_ plus 52 characters, derived from the tenant, the caller and the idempotency key.' },
  tenantId: { type: 'string', description: 'The owning tenant.' },
  poolId: { type: 'string', description: 'The pool it is booked in.' },
  projectId: { type: 'string', description: 'The project, when the request named one.' },
  state: { type: 'ReservationState', description: 'See states and conditions below.' },
  request: { type: 'ReservationSpec', description: 'What was asked for, with duration stored in milliseconds (for example 28800000ms).' },
  during: { type: '{ start: number; end: number }', description: 'The booked window, once placed. Absent while pending.' },
  leaseExpiresAt: { type: 'number', description: 'Renewable reservations: when the lease lapses unless renewed.' },
  ownerIdentityId: { type: 'string', description: 'The identity that created it: the person, or the agent for agent keys.' },
  agentId: { type: 'string', description: 'The agent, for delegated sessions.' },
  delegationId: { type: 'string', description: 'The delegation, for delegated sessions.' },
  idempotencyKey: { type: 'string', description: 'The key it was created with.' },
  conditions: { type: 'Condition[]', description: '{ type, status, reason?, message?, since } entries.' },
  binding: { type: '{ nodeId; cdiDevices; envFile }', description: 'hold bindings, once a node reported the devices bound.' },
  operation: { type: '{ id; state; error? }', description: &#x22;The async operation behind the reservation: running, pending, succeeded or failed. See waiting for placement.&#x22; },
  version: { type: 'number', description: 'Raised by every change.' },
}"
/>

```json title="An active reservation"
{
  "id": "rsv_…",
  "tenantId": "acme",
  "poolId": "pool_01k…",
  "state": "active",
  "request": {
    "poolId": "pool_01k…",
    "devices": [{ "class": "gpu", "count": 4, "topology": "nvlink-all" }],
    "duration": "28800000ms",
    "binding": "hold"
  },
  "during": { "start": 1790000000000, "end": 1790028800000 },
  "ownerIdentityId": "…",
  "idempotencyKey": "train-2026-09-24",
  "conditions": [],
  "binding": {
    "nodeId": "node_01k…",
    "cdiDevices": ["bcd.dev/gpu=GPU-…", "bcd.dev/gpu=GPU-…", "bcd.dev/gpu=GPU-…", "bcd.dev/gpu=GPU-…"],
    "envFile": "/run/bcd/allocations/alloc_….env"
  },
  "operation": { "id": "rsv_…", "state": "succeeded" },
  "version": 4
}
```

## Drafts and idempotency [#drafts-and-idempotency]

The draft id is deterministic: `rsv_` followed by the base32 SHA-256 of the tenant, the caller's
identity and the idempotency key. Retrying with the same key reaches the same row, and the same key
from another identity gets a row of its own, so nobody can squat on or replay someone else's request.

The stored request hash covers the validated input without `wait` and `idempotencyKey`. Durations
are normalized to milliseconds and `binding` defaults to `hold`, so `'2h'` and `7200000` are the same
request, with or without `binding: 'hold'`.
A second call with the same key:

* with the same request, on a reservation past the draft stage, returns the stored reservation with
  no second IAM decision;
* with the same request, on a draft (for example after `QUOTA_EXCEEDED`), decides the same draft again;
* with a different request, is `CONFLICT` with `details.reservationId`;
* on a refused key, stays `ACCESS_DENIED`.

Drafts are internal: `reservations.get` answers `NOT_FOUND` for them and `reservations.list` skips
them.

## The admission sequence [#the-admission-sequence]

Every `create` follows the normative sequence of PLAN.md section 3.4, and `extend` follows it against
the existing reservation. IAM is never called inside a compute transaction.

<Mermaid
  chart="sequenceDiagram
  autonumber
  participant C as Caller
  participant A as compute api
  participant D as Postgres
  participant I as Better IAM
  C->>A: reservations.create(credential, input)
  A->>I: authenticate (cached 30 s)
  A->>A: tenant pre-check
  A->>D: tx 1: idempotency lookup, draft, authz row
  A->>I: require compute:reservations:create on the draft
  alt refused
    A->>D: draft becomes denied (or awaiting-confirmation)
    A-->>C: ACCESS_DENIED or CONFIRMATION_REQUIRED
  else allowed
    A->>D: tx 2: charge quota, place, audit row, outbox
    A-->>C: placed, pending or queued
  end"
/>

      ### Authenticate and pre-check [#authenticate-and-pre-check]

    The credential is authenticated (cached for 30 seconds). If the caller's tenant is neither the input
    tenant nor the root tenant, the answer is `ACCESS_DENIED` before compute's database is read, the same
    answer whether or not anything exists.
  
      ### Transaction 1: the draft [#transaction-1-the-draft]

    The idempotency row is looked up and locked. The pool must be owned by or offered to the tenant and
    not archived, or the answer is `NOT_FOUND`. Device classes must fit the pool's sharing mode, and
    `binding: 'node'` needs a `tenantRoot` pool, `binding: 'kubernetes'` a pool with `policy.kubernetes` that the
    tenant owns (3825ec7; a tenant the pool is only offered to is `INVALID_INPUT`). A request
    without `resources` gets the pool's per-GPU share of host CPU and memory here, stored in the request (see
    [host resources](#host-resources)). Then the draft is inserted with its row in the IAM
    authz projection, parented to the pool and owned by the caller:

    | Attribute                                   | Value                                                  |
    | ------------------------------------------- | ------------------------------------------------------ |
    | `gpuModel`                                  | The pool's GPU model, or `other`                       |
    | `gpuCount`                                  | Whole GPUs requested, times the gang size              |
    | `gpuSlices`                                 | MIG instances and slots requested, times the gang size |
    | `gangSize`                                  | 1, or the gang size                                    |
    | `durationHours`                             | The duration in hours, to three decimals               |
    | `region`                                    | The region of the pool's site                          |
    | `binding`, `spot`, `renewable`, `projectId` | As requested; `renewable` is true with `leaseSeconds`  |
  
      ### Decide exactly once [#decide-exactly-once]

    `iam.require` decides `compute:reservations:create` on `compute-reservation/<draft id>`. The resolver
    hands IAM the attributes above and the pool's relations, so pool access and request limits are one
    decision; see [policies](/docs/guides/iam/policies). A refusal marks the draft `denied`, unless the
    caller is an agent in a delegated session and IAM opened a confirmation request (below).

    Still outside any transaction, the budget check (`budgetAdmissionCheck`) runs when the pool's policy
    sets `admission.budgetCheck: 'meter'` or the tenant's offer has `onBudgetExceeded: 'stop-new'` or
    `'drain'`: it calls Better IAM's `billing.check` for the caller, and a spent budget answers
    [`SPEND_LIMIT_REACHED`](/docs/reference/errors#spend_limit_reached). The draft stays, so the same key is
    decided again once the budget's window resets. See [budgets](/docs/guides/iam/billing-and-usage#budgets).
  
      ### Transaction 2: quota and placement [#transaction-2-quota-and-placement]

    Quota is charged all-or-nothing first. `QUOTA_EXCEEDED` ends the request and leaves the draft, so a
    retry with the same key is decided again. Then the [placer](/docs/guides/capacity/placement) runs. A
    regular request that does not fit may [preempt spot work](/docs/guides/capacity/spot-and-preemption).
    The outcome is `placed`, `pending` with an `Unschedulable` condition (retried every 30 seconds), or,
    with the pool-lease placer, `pending` with a `Queued` condition. The same transaction schedules the
    lifecycle, writes the event to the outbox and records the audit row with the admission context.
  
Two details differ from the PLAN.md text. Quota is charged by the reservation service before
placement, never by the placer, because a pending reservation keeps its charge (decision D9).

## Waiting for placement [#waiting-for-placement]

`create` is async-with-wait (PLAN.md section 9.1): every reservation carries an `operation`, and a
request with `wait: { timeoutMs }` holds its answer until that operation stops running. The same client
code therefore works with both [placers](/docs/guides/capacity/placement#two-placers).

| `operation.state` | Reservation                                                                                                             |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `running`         | A draft, or `pending` with a `Queued` condition: waiting for the pool-lease batch. The only state a `wait` waits on     |
| `pending`         | `pending` without `Queued` (`Unschedulable`, retried every 30 seconds), or `awaiting-confirmation`                      |
| `succeeded`       | `placed`, `binding`, `active`, `releasing` or `released`                                                                |
| `failed`          | `failed` (`CAPACITY_UNAVAILABLE`), `denied` (`ACCESS_DENIED`), `cancelled` or `expired` (`INVALID_STATE`), with `error` |

After transaction 2 commits (or a repeated key returns the stored reservation), the server re-reads the
reservation in read-only transactions, backing off from 50 ms to 1 s, until the operation is no longer
`running`, `timeoutMs` passes (capped at 30 seconds), or the request is aborted. The answer is the last
read either way: a timeout is not an error, it returns the reservation still `Queued`. With the inline
placer a create is placed or `Unschedulable` at once, so `wait` returns immediately. `wait` is excluded
from the request hash, and `extend` does not wait.

```ts
const reservation = await client.reservations.create({ ...input, wait: { timeoutMs: 20_000 } });
if (reservation.operation?.state === 'running') {
  // Still queued after 20 s: read it again later with reservations.get.
}
```

The `better-compute reserve` command sends `wait` (its `--wait`, 20 seconds by default) and, if the
answer is still `Queued`, keeps polling `reservations.get` until the same deadline. The MCP
`reserve_gpus` tool sends `wait` from its `waitSeconds`.

## States and conditions [#states-and-conditions]

<Mermaid
  chart="stateDiagram-v2
  state &#x22;awaiting-confirmation&#x22; as awaiting
  [*] --> draft
  draft --> placed: allowed and placed
  draft --> pending: allowed, nothing fits
  draft --> denied: refused
  draft --> awaiting: delegated refusal
  awaiting --> draft: same key retried
  awaiting --> expired: deadline passed
  pending --> placed: a retry fits
  pending --> failed: maxPending or pool gone
  pending --> cancelled: cancel or release
  placed --> binding: start reached
  placed --> pending: repair failed or spot requeue
  placed --> cancelled: cancel
  binding --> active: every allocation bound
  binding --> pending: bind timeout
  binding --> failed: bind retries exhausted
  placed --> releasing: release
  binding --> releasing: release
  active --> releasing: end, release or lease lapse
  releasing --> released"
/>

`denied`, `released`, `failed`, `cancelled` and `expired` are terminal. The `capacity.reservations`
reconciler drives each reservation, woken by the work queue at the moments
that matter: the start, 15 minutes before it, the bind deadline, the end and the lease deadline.
While a reservation is active, the reconciler only sets and clears conditions from its nodes and atoms, and
never releases it (PLAN.md section 10.5): node connectivity and health changes wake it, and it rechecks every
15 minutes without them.

| Condition              | Reason                                  | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ---------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Unschedulable`        | `NoFit`                                 | Nothing fits; the message is the placer's summary                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `Unschedulable`        | `MaxPendingExceeded`                    | Pending longer than the pool's `maxPending` (default 24 h); the reservation failed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `Unschedulable`        | `PoolGone`                              | The pool is no longer offered to the tenant, or archived; the reservation failed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `Unschedulable`        | `BookingRepairFailed`                   | The booking went bad before the start and nothing else fits                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `Unschedulable`        | `SpotPreempted`                         | A spot reservation that had not started was preempted and re-queued                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `Queued`               | `PoolPlacer`                            | Waiting for the pool-lease placer's next batch                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `BindTimeout`          | `NodeDidNotBind`, `NodeRejectedBinding` | The node did not bind in time or refused; the message counts attempts                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `Degraded`             | `BindingLost`                           | A node reported it lost devices it had bound                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `Degraded`             | `ForeignGpuExposure`                    | A node of an active reservation reports the node condition `ForeignGpuExposure`: something besides bcd can hand its GPUs to pods, so they are not the tenant's alone (4570dfe). The message lists the nodes. `BindingLost` wins over it, and it wins over `AtomUnhealthy`; nothing is released. See [GPU isolation](/docs/guides/capacity/kubernetes#gpu-isolation)                                                                                                                                                                                                            |
| `Degraded`             | `AtomUnhealthy`                         | A booked atom of an active reservation is unhealthy; the message lists them. `BindingLost` wins when both apply                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `NodeUnreachable`      | `Unreachable`                           | A node of an active reservation is not connected; the message lists the nodes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `Preempted`            | `SpotPreempted`                         | A running spot reservation was cut back to its notice deadline                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `ConfirmationRequired` | the confirmation id                     | Waiting for a person; the message is the deadline in epoch milliseconds                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `UnhealthyAtoms`       | `NotBilled`                             | A node lease holds unhealthy GPUs that it is not billed for                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `DevicesHeld`          | `PodsHoldDevices`                       | A released `binding: 'kubernetes'` reservation whose allocation is still releasing: pods on the named nodes still hold its GPUs, which stay booked until the node reports them free (0127ed7), billed never past the allocation's own end; a release stuck past that end is carried in 5-minute steps, unbilled, for at most 24 hours (e7a56d7). `since` is when the release began. Computed by `reservations.get` and `list`, not stored; it disappears once `AllocationReleased` or the hosts pass ends the release. See [release](/docs/guides/capacity/kubernetes#release) |

Lifecycle changes emit events with subject `reservation/<id>` and `state` and `poolId` in their data:
`reservation.placed` (with `nodeIds`, `start`, `end`), `reservation.pending`, `reservation.queued`,
`reservation.binding`, `reservation.active`, `reservation.extended`, `reservation.released` (with
`reason`: `release`, `expired`, `lease-lapsed` or `preempted`), `reservation.cancelled`,
`reservation.failed`, `reservation.awaiting-confirmation`, `reservation.expired`,
`reservation.repaired`, `reservation.preempting`, `reservation.preempted`, `reservation.reassigned` (an
offboarded owner's reservation handed to the successor, with `reservationId`, `from`, `to` and `reason`; it
has its own row in the event catalog and `webhooks.eventTypes`; see
[IAM events](/docs/guides/iam#iam-events-compute-reacts-to)) and
`reservation.binding-rejected` (with `allocationId`, `nodeId` and `reason`; see
[attested bindings](#attested-bindings)). Subscribe with
[webhooks](/docs/guides/events/webhooks) or [watch](/docs/guides/events/watch).

A node that becomes unreachable only raises conditions on the node; the reservation keeps its
booking (principle P6).

## Bindings [#bindings]

The binding says how the devices reach a workload.

**`hold`** (v0.1, the default) hands the devices to software already running on the node, such as
Slurm, Kubernetes or containerd with CDI. At the start the reservation moves to `binding` and the
node's desired state gains a `devices` assignment per allocation (`allocationId`, `deviceIds`, and
`until`, the booking's end). The node writes a CDI spec and an env file and
reports `AllocationBound` with the CDI device names; once every allocation is bound the reservation is
`active`, and `reservations.get` returns `binding`. Failure handling:

* no report within 5 minutes, or an `AllocationFailed` report: the bookings are released and the
  reservation is placed again, at most twice; the third failure fails it;
* an `AllocationReleased` report for a bound allocation: `Degraded` with `BindingLost` until the node
  reports it bound again.

The gateway accepts a report only about an allocation placed on the reporting node's own session; a report naming
another node's allocation is ignored like an unknown one. A report that arrives while the reservation is still
`placed` is judged as soon as the start is reached, not after the bind timeout.

**`kubernetes`** (v1.1, a21c1c1) is a hold whose devices the node hands to its kubelet: the assignment carries
`BINDING_KUBERNETES`, bcd binds it like a hold and marks its CDI spec for its device plugin, which advertises exactly
those devices as `bcd.dev/gpu`, `bcd.dev/gpu.mig` or `bcd.dev/gpu.slot` until the reservation ends. It needs a pool
with `policy.kubernetes` (`INVALID_INPUT` otherwise), and only the pool's owner may book it (3825ec7): the kubelet
gives bound GPUs to any pod of the cluster, so a tenant the pool is merely offered to gets `INVALID_INPUT` and books
holds instead. It places only on nodes whose bcd advertises the `binding.kubernetes` capability (system label
`bc.dev/cap.binding.kubernetes`, 409f783): an older bcd drops `BINDING_KUBERNETES`, and its GPUs would never reach the
kubelet. See [Kubernetes workers](/docs/guides/capacity/kubernetes#older-daemons).

### Host resources [#host-resources]

A reservation can book a host's CPUs, memory and 1 GiB huge pages with its GPUs (PLAN.md §3.8, a21c1c1):

* **What is bookable.** The `capacity.hosts` reconciler (every 30 seconds, `hostSyncEvery`) keeps each host's
  `vcpu`, `memoryGiB`, `hugepages2Mi` and `hugepages1Gi` rows in step with its applied inventory (8a7f8eb), and since
  b7e8bf5 inventory wakes it (and vps's `vps.hosts`) for a host as soon as it applies that host's snapshot, so a new
  or re-inventoried host is bookable at once rather than at the next pass; see
  [pools](/docs/guides/capacity/pools#pool-policy). A host without rows books none.
* **`resources`.** `{ vcpu, memoryGiB, hugepages1Gi }` per node (per gang member for a gang), booked on the devices'
  node in the same guarded statement as the devices, so a node that cannot give them is not a fit.
* **Without `resources`**, a request books the pool's per-GPU share of CPU and memory, times the GPUs' worth it asks
  for: `policy.perGpu.vcpu` and `policy.perGpu.memoryGiB` where set, otherwise the smallest share any GPU host of the
  pool can give (`allocatable × ratio ÷ physical GPUs`, counting every physical GPU whether bookable or not), so the
  share fits on every host; the product is rounded down. Since 004579e fractional GPUs count too (`gpuEquivalent`): a
  MIG instance is 1/7 of a GPU, and a time-slice or MPS slot 1/`policy.sharing.replicas` (default 4, or 10 for `mps`),
  so MIG and slot work also carries, and is limited to, its share of the host. It is resolved once, when the draft is
  written, and stored in the request. Pools whose hosts have no rows book none, so they behave as before.
* **The share is also the floor** (41826a2). `resources` below the pool's per-GPU share, or `resources: {}`, are
  raised to it, so booked GPUs never run a workload without CPU and memory limits while the same CPU and memory are
  sold again.
* **Quota.** The share rides on the GPUs and needs no quota beyond theirs; the reservation records it as `hostShare`
  when it is created, and since 004579e always, as `{}` when the share is empty (a pool without one), so anything
  booked above an empty share is charged too. Anything booked above it is charged to the tenant's `vcpu` and `memoryGiB` quota (1 GiB huge
  pages count as memory), and a missing quota row grants 0, so the excess is `QUOTA_EXCEEDED` unless the offer names
  these resources (41826a2). One GPU's quota can then not take a whole host's CPUs and memory. Reservations made before
  `hostShare` existed charge their devices only. IAM sees `vcpu` and `memoryGiB` (summed over a gang) as attributes of
  the reservation.
* **Preview.** `capacity.preview` books the same host resources as `reservations.create` would, the request's or the
  pool's per-GPU share, so a preview that fits is a create that fits (17f600b); a `node` binding books none.
* **Only on a bcd that enforces them** (409f783). An older bcd drops `DeviceAssignment.limits` as an unknown field and
  would run the workload unbounded, so a reservation that books any host resource (a non-zero `vcpu`, `memoryGiB` or
  `hugepages1Gi`, including the per-GPU share) places only on nodes with the system label
  `bc.dev/cap.devices.limits`, which the gateway writes from the `devices.limits` capability a GPU host's bcd
  advertises in `Hello` (see [capabilities](/docs/daemon/sessions#hello)). An operator label of the same name does not
  count: capacity reads `bc.dev/` labels only when their source is `system`. The requirement applies to create, to
  re-placing a pending reservation, to repairing a booking and to `capacity.preview` (a node without the label is
  rejected with filter `labels`, `needs label bc.dev/cap.devices.limits=*`); the placer also re-reads the labels under
  lock when it commits, so a daemon downgraded between plan and commit is caught. When no advertising node fits, the
  reservation waits `pending` like any other. A plain GPU hold that books no host resources (a pool whose hosts have no
  resource rows) needs no capability, so it still places on older daemons within the N-2 skew window, as does a
  `node` binding.
* **From the CLI and the console.** `better-compute reserve` and `better-compute capacity --preview` take `--cpus`
  (0 to 4096) and `--memory` (GiB, 0 to 65,536), sent as `resources`, and `--kubernetes` for `binding: 'kubernetes'`
  (ea36a94). `@better-compute/console-kit`'s reservation form takes the same three as `vcpu`, `memoryGiB` and
  `kubernetes`, and previews with them (e833433). The console app's reservation page shows them since 4533265:
  optional **Host CPUs** (0 to 4096) and **Host memory, GiB** (0 to 65,536), left empty for the pool's per-GPU share,
  and the Kubernetes checkbox only for a pool the tenant owns that has `policy.kubernetes`.
* **On the node** (c5a419c, 260e33c), the booked resources become the assignment's limits: `cpu_millis` from
  `vcpu`, `memory_bytes` from `memoryGiB`, `hugepages_1gi`, and a cpuset on the NUMA nodes of the hold's devices when
  those nodes have enough CPUs (`cpus`) or memory (`mems`). Since b6b804e a cpuset never includes the host's reserved
  CPUs, the lowest non-isolated, `policy.hostReserve.vcpu` (default 2), and the reserve counts against the fit. The node's cpulists (its NUMA nodes'
  CPUs and isolated CPUs) come from its inventory, so capacity reads them within bounds (004579e): CPU ids up to 8191,
  at most 32 KiB and 4096 comma-separated parts, a longer list read as empty, so an oversized list cannot stall the
  control plane. bcd
  admits the hold against the host's memory beside its VMs and other holds, and one that does not fit fails with
  `resources-exhausted`, which re-places it (31555bf). It then creates the cgroup slice
  `bcd-alloc-<allocation>.slice` with those limits, names it in the CDI spec (`bcd.dev/cgroup-parent`) and the env
  (`BCD_CGROUP_PARENT`), and stops it once it is empty after the release, retrying every pass (e01d7ff).
* **Where the workload runs.** The workload manager on the node (containerd, Slurm, a wrapper) places the workload in
  that slice; see [device assignments](/docs/daemon/delivery#device-assignments). Once the reservation is active,
  [`reservations.get`](/docs/reference/api/reservations#get) shows the slice as `binding.cgroupParent`
  (`HoldBinding.cgroupParent`, 6aa412b), which bcd reports in `AllocationBound.cgroup_parent` (c81f230, 19dbf60); the
  control plane keeps it only when it is exactly `bcd-alloc-<allocation>.slice` for that same allocation (004579e), so
  a node cannot point a reservation at another allocation's slice. It is empty for a hold without limits and for a
  `kubernetes` binding: that binding gets no slice, since its pods live in the kubelet's cgroups, where the kubelet
  enforces their requests and limits; its booked limits still count in bcd's host admission (see
  [Kubernetes workers](/docs/guides/capacity/kubernetes#host-resources-and-pods)).

### Attested bindings [#attested-bindings]

A gateway relays `AllocationBound`, so on its own a compromised gateway could claim a binding the node never made.
Attested bindings (v0.2, principle P14) close that gap: the node signs the report with its own key, the gateway stores
the signed bytes as received (`allocations.bound_report`, `bound_sig_kid`, `bound_sig`, `core/0017`), and the
reservation worker verifies them before the binding counts.

With `pki.commandSigning` configured, `betterCompute()` turns this on by default: capacity's `attestedBindings` is
`pkiAllocationBindingCheck()` from `@better-compute/server`. You can also pass your own
`capacity: { attestedBindings }` check. The pki check accepts a report only when:

* it is signed by a certificate of that node (current, or recently expired within the offline grace, never
  revoked);
* the certificate's role runs workloads (`node` or `node-tenant` by default; `roles` changes it);
* the signed body reports exactly this allocation bound.

A good report replaces the binding with the signed CDI devices and env file, marked `attested`, whatever the gateway
said. With the option on, only attested allocations count as bound. A report that fails the check never counts, and
each rejection publishes `reservation.binding-rejected` with `allocationId`, `nodeId` and `reason` (`unsigned
report`, a signature failure, `the signed report is not this allocation bound`, …):

| State when rejected | What happens                                                                                                |
| ------------------- | ----------------------------------------------------------------------------------------------------------- |
| `binding`           | `bound_at` is cleared, so nothing is billed; the reservation waits for a genuine report or its bind timeout |
| `active`            | The binding is marked lost: `Degraded` with `BindingLost`, and billing keeps the real binding               |

Node leases (`binding: 'node'`) are exempt: metal's worker binds them after its deploy workflow verified the machine.
The end-to-end test `tests/e2e/attested-bindings.test.ts` runs this through a real gateway on the dev stack: an
unsigned report, one signed by a foreign key, and the node's genuine signature over another allocation never
activate the reservation, and a forged re-report of an active binding marks it lost. Both daemons sign these
reports with their node key: the FakeDaemon, and the Go daemon `bcd`, whose devices converger sends every bind and
release as a node-signed `AttestedReport` (purpose `attested-report`).

Both implement the node side the same way (CDI spec `/etc/cdi/bcd-<alloc>.json`, env file
`/run/bcd/allocations/<alloc>.env`, device names `bcd.dev/gpu=<id>`); the CDI spec is the hold itself, and neither
drops a hold on its own. The FakeDaemon drives the simulator and `better-compute dev --sim N`; `bcd` is built and
tested end to end against the gateway but not released. See
[desired state and commands](/docs/guides/concepts/desired-state-and-commands) and [the daemon](/docs/daemon).

**`node`** (v0.5) leases whole machines in a pool whose policy sets `tenantRoot: true`. It needs
`node: { profileId, profileVersion? }` and cannot be spot. It books every bookable atom of `ready`,
platform-trusted nodes; metal deploys the profile from the start and marks the allocation bound, with
2 hours to do so and no second placement. See [dedicated root](/docs/guides/bare-metal/dedicated-root).

## Start, lease, extend and release [#start-lease-extend-and-release]

**Start.** Without `start` the window begins at placement. A `start` in the future books the window
now; see [calendar reservations](/docs/guides/capacity/gang-and-calendar). A pending reservation keeps
its requested start while it is still ahead; once it has passed, the window begins when a retry
places it, with the full duration.

**Renewable leases.** With `leaseSeconds`, the lease runs out `leaseSeconds` after placement.
[`reservations.renew`](/docs/reference/api/reservations#renew) (`compute:reservations:extend`) moves it
to now plus `leaseSeconds` without touching `during`, in states `placed`, `binding` and `active`. A
lapsed lease releases the reservation with reason `lease-lapsed`. Renewing a reservation created
without `leaseSeconds` is `INVALID_STATE`. Leases suit agents that may die without cleaning up.

**Extend.** [`reservations.extend`](/docs/reference/api/reservations#extend) takes exactly one of
`until` (epoch milliseconds) or `by` (a duration), plus an `idempotencyKey`. It is admitted like a
create: transaction 1 writes the pending change into the authz row, with `durationHours` set to the
total after the change, so a cap such as `BoundedExtend` also bounds extensions. On allow, every
allocation's end moves later, all or nothing, and the `EXCLUDE` constraint decides:

```ts
const result = await compute.api.reservations.extend(credential, {
  tenantId: 'acme',
  reservationId,
  by: '4h',
  idempotencyKey: 'train-2026-09-24-ext-1',
});
// { reservation, extended: true }
// or { reservation, extended: false, availableUntil: 1790040000000 } when another booking starts first
```

An `until` that the reservation already reaches answers `extended: false` without a decision, so
prefer `until` when you may retry: a repeated `by` extends again from the new end. A refusal clears
the pending change; so does a handler that throws after an allow (the reservation changed between the
transactions), through the admission's `abandoned` hook in a transaction of its own, so a refused
lifetime is never merged into later decisions on the reservation (a2288ec). A retry with the same key
decides again.

One extend at a time stages the pending change (f36248d, review 7), since the authorization projection keeps one
pending value per resource: two concurrent extends could otherwise interleave, one decided on the other's shorter
duration and then applying its own longer one past `BoundedExtend`, and one's refusal could clear the other's
change. `capacity_extend_holds` (migration `capacity/0005_extend_holds`: the reservation, the hold and when it was
taken; deleted with the reservation) gives the slot to one request. The hold is a nonce the server mints for each
extend (`newId('hold')`, kept on the parsed input, which callers cannot set), not the caller's `x-request-id`, which
two concurrent extends could share (d6f7ddc); its age is measured with `compute_now()`, since replicas' clocks may
disagree. While another extend holds the slot, an extend is `CONFLICT` with `details.reason: 'extend-in-flight'`
and `retryAfterSeconds: 60`; a hold older than 60 seconds is taken over. Transaction 2 applies the change only while
its hold still owns the slot, and a refusal, an abandon or a budget refusal clears the pending change only when the
hold is its own. The completing transaction
takes the pool lock first (bb6da07), so an extend of a gang and a batch commit cannot deadlock on each other's
bookings. Sandboxes do the same with their own hold (see [sandboxes](/docs/guides/vps/sandboxes)).

**Release and cancel.** [`reservations.release`](/docs/reference/api/reservations#release) ends a
reservation at `compute_now()`: the current booking is truncated, future ones are deleted, quota is
refunded, and billing stops. Releasing a pending reservation cancels it, and releasing a terminal one
changes nothing. [`reservations.cancel`](/docs/reference/api/reservations#cancel) withdraws one that has
not started (`pending` or `placed`); anything else is `INVALID_STATE`. Both are
`compute:reservations:release`. Freed capacity wakes the pool's pending reservations at once instead of
at their next 30-second retry.

```bash
better-compute extend rsv_… --tenant acme --until +12h
better-compute release rsv_… --tenant acme
```

## Delegated confirmation [#delegated-confirmation]

When an agent acting for a person in a delegated session is refused, and the person's delegation holds
the action for confirmation, Better IAM opens a confirmation request and the call fails with
`CONFIRMATION_REQUIRED` (`details.confirmationId`, and `expiresAt` when IAM gives one). The draft moves
to `awaiting-confirmation` with a `ConfirmationRequired` condition and no quota charged. The deadline is
IAM's, clamped to at most 30 minutes.

Once the person approves, the agent retries **with the same idempotency key**. The retry reaches the
same draft, IAM decides it again, finds the approval, and the reservation is placed. After the
deadline, the reservation becomes `expired`, either by the reconciler or on a late retry. See
[delegated confirmation](/docs/guides/iam/delegated-confirmation) and the [MCP server](/docs/guides/clients/mcp),
which runs this loop for agents.

## The reservations API [#the-reservations-api]

| Method                                                             | Action                         | Decided on  | Phase |
| ------------------------------------------------------------------ | ------------------------------ | ----------- | ----- |
| [`reservations.create`](/docs/reference/api/reservations#create)   | `compute:reservations:create`  | the draft   | v0.1  |
| [`reservations.get`](/docs/reference/api/reservations#get)         | `compute:reservations:read`    | reservation | v0.1  |
| [`reservations.list`](/docs/reference/api/reservations#list)       | `compute:reservations:read`    | tenant      | v0.1  |
| [`reservations.release`](/docs/reference/api/reservations#release) | `compute:reservations:release` | reservation | v0.1  |
| [`reservations.cancel`](/docs/reference/api/reservations#cancel)   | `compute:reservations:release` | reservation | v0.2  |
| [`reservations.extend`](/docs/reference/api/reservations#extend)   | `compute:reservations:extend`  | reservation | v0.1  |
| [`reservations.renew`](/docs/reference/api/reservations#renew)     | `compute:reservations:extend`  | reservation | v0.1  |

All seven are served today. `reservations.list` returns the caller's own reservations by default
(`scope: 'mine'`); `scope: 'all'` lists the tenant's. It filters by `state` and `poolId`, returns newest
first, and pages with `limit` (1 to 200, default 50) and `cursor`, the last reservation's id. Errors are
listed in the [error reference](/docs/reference/errors).
