Custom capacity scaling

Build a provider-specific scaler against the Capacity deployment protocol at /capacity/v1. Helmr supplies desired-capacity and Worker lifecycle status. Your scaler owns provider policy and mutations, including creating and terminating hosts.

This is a deployment infrastructure protocol for a trusted capacity owner, including an operator performing full-stop maintenance. It is not the tenant Developer REST API. Its credential grants every Capacity operation and Worker group in the deployment.

Configure authentication

Generate one canonical 32-byte base64url token:

CAPACITY_TOKEN="$(openssl rand -base64 32 | tr -d '\n=' | tr '/+' '_-')"

Store it in your secret manager. Provide the same value as CAPACITY_TOKEN to Control Plane and as a secret to the scaler. Do not place it in source, command-line arguments, logs, or ordinary configuration files. When CAPACITY_TOKEN is unset, Control Plane rejects every Capacity request.

Send the token in every request:

Authorization: Bearer <capacity-token>
Accept: application/json

Use HTTPS outside localhost and 127.0.0.1.

Operations

The Control Plane base URL is shown as $CONTROL_PLANE_URL below.

Method and path Purpose
GET /capacity/v1/updates Receive capacity invalidation hints as server-sent events.
GET /capacity/v1/deployment Read the live deployment release, worker API and database schema.
GET /capacity/v1/worker-groups/resolve?region_id=...&name=... Resolve a Worker group by canonical Region ID and name.
GET /capacity/v1/worker-groups/{group_id}/pools/resolve?name=... Resolve a pool in a Worker group.
PUT /capacity/v1/worker-groups/{group_id}/primary-pool Select the primary pool with a group claim-version fence and a positive minimum of ready hosts.
POST /capacity/v1/worker-groups/{group_id}/plan Compute provider-neutral additional Worker recommendations.
GET /capacity/v1/worker-hosts List Worker instances for inventory and lifecycle reconciliation.
GET /capacity/v1/worker-hosts/{instance_id} Read one Worker instance.
POST /capacity/v1/worker-hosts/{instance_id}/drain Start claim- and epoch-fenced drain.
POST /capacity/v1/worker-hosts/{instance_id}/lost Confirm that the provider host is absent.

Worker group, pool, and instance IDs are canonical UUIDv7 values. region_id, names, and provider resource_id values are opaque canonical strings.

Resolve and plan

Resolve the group and pools, then observe provider and Worker inventory in the same reconciliation cycle. Record observed_at before starting those reads. The group response includes an opaque supply_fingerprint; return it as expected_supply_fingerprint in the plan request. Re-resolve after changing the primary pool, pool lifecycle or Worker inventory.

Ask Helmr how many additional Workers each provider pool can use:

printf 'Authorization: Bearer %s\n' "$CAPACITY_TOKEN" |
  curl --fail-with-body --get \
  --header @- \
  --header 'Accept: application/json' \
  --data-urlencode "region_id=$REGION_ID" \
  --data-urlencode "name=$GROUP_NAME" \
  "$CONTROL_PLANE_URL/capacity/v1/worker-groups/resolve"

printf 'Authorization: Bearer %s\n' "$CAPACITY_TOKEN" |
  curl --fail-with-body \
  --request POST \
  --header @- \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data @capacity-plan.json \
  "$CONTROL_PLANE_URL/capacity/v1/worker-groups/$GROUP_ID/plan"

The request file has this shape (use the current observation time and fingerprint):

{
  "expected_supply_fingerprint": "<fingerprint from group resolution>",
  "observed_at": "2026-10-22T00:00:00Z",
  "pools": [{
    "pool_id": "019...",
    "max_additional_workers": 10,
    "active_workers": 1,
    "registering_workers": 0
  }]
}

Include every active pool, including pools with zero provider headroom. max_additional_workers is the number of additional VM-capable hosts the provider can supply beyond the active Workers already represented in this observation. Account for desired, in-flight, unregistered and unresolved provider instances; do not derive it by subtracting registered Workers from the provider maximum. Report the active and registering counts from the same Worker inventory.

An observation must be no more than 30 seconds old and must still match the current supply fingerprint. Old, future-dated or mismatched observations return execution_capacity_unavailable; reobserve instead of retrying the stale body. Requests cover at most 64 pools and at most 1,000 additional Workers per pool. A partial pool set or mismatched inventory counts can still produce an advisory plan, but cannot refresh capacity evidence for new execution admission.

Helmr reserves bounded execution claims against current capacity and this fresh provider evidence. When evidence expires or topology changes, new work can use verified existing capacity but cannot rely on the old provider headroom. Keep observations current even when the provider is at its maximum. A rejected request has no accepted Turn waiting for capacity; the caller must submit new work after resolving the reported condition.

The plan response has this shape:

{
  "worker_group_id": "019...",
  "worker_group_name": "default",
  "region_id": "aws-us-east-1",
  "group_status": "active",
  "pools": [
    {
      "pool_id": "019...",
      "pool_name": "default",
      "recommended_additional_workers": 2,
      "compatible_queued_items": 2,
      "registering_workers": 0,
      "active_workers": 1,
      "complete": true,
      "saturated": false,
      "scale_in_blocked": false
    }
  ],
  "unmatched_demand": [],
  "complete": true,
  "computed_at": "2026-08-29T00:00:00Z"
}

Treat the recommendation as input to your provider policy, not as an imperative desired count. The scaler decides mutation rate, limits, and provider calls.

Scale out can use positive recommended_additional_workers subject to provider limits. Scale in needs a complete, internally consistent observation. Do not start a drain unless all of these are true:

  • the group plan has complete: true and unmatched_demand is empty;
  • the selected pool has complete: true, saturated: false, scale_in_blocked: false, and compatible_queued_items: 0;
  • the plan’s active/registering counts match Worker inventory observed in the same reconciliation cycle;
  • inventory did not reach the requested limit, no Worker is registering or already draining, and no provider mutation is in flight; and
  • provider inventory is complete and stable under its scale-in protection.

The drain fence and the advisory queued-demand check for idle_scale_in are final safety checks. They do not make a partial plan or incomplete inventory safe for scale-in.

Bound optional work per Worker

Read or replace a pool’s policy at GET /capacity/v1/worker-groups/{group}/pools/{pool}/resource-policy and PUT /capacity/v1/worker-groups/{group}/pools/{pool}/resource-policy. The replacement requires every field and the revision returned by the read:

{
  "expected_revision": 1,
  "optional": {
    "cpu_millis": 0,
    "memory_bytes": 0,
    "disk_bytes": 0,
    "vm_slots": 0,
    "max_tasks": 0,
    "io_bytes_per_second": 0
  },
  "local_headroom_slots": 0
}

All limits default to zero. Optional seed generation, prepared Computers and cache share these per-Worker ceilings. A paused source retained after a durable Computer checkpoint uses these optional ceilings too; it reserves its full CPU, memory and lifecycle disk until physical teardown completes. Retention is an optimization, not a promise that the source will remain available. local_headroom_slots keeps full VM shapes available to real work by restricting optional admission. It does not request additional hosts or guarantee fleet-wide ready capacity. Set a provider minimum size when physical standing capacity is required. Replacing policy requires the current revision; a stale revision returns HTTP 409. A successful update records desired policy, not proof that every Worker has finished reclaiming resources.

Workers charge seed generation, prepared Computers and cache to the same physical reservation ledger as real allocations. Cache limits include filesystem overhead. Policy reduction cancels optional fills and stops seed attempts through Control Plane; open readers, VM processes and files remain charged until their owners release them. reclaiming_workers can therefore remain nonzero after a policy update. An optional transfer rate of zero disables optional uploads and downloads. The rate is shared across concurrent requests, multipart transfers and retries.

A prepared Computer is a private, paused VM for an exact qualified image and runtime shape. Preparation does not run authored initialization or receive Session credentials or a Computer write key. Its source access expires and must be renewed; policy changes, stale observations and source invalidation remove unused preparation. Every leaf reserves a full lifecycle slot, including local RAM ciphertext retained through restoration and subsequent checkpoint capture.

A fresh Computer allocation can atomically take ownership of a matching prepared VM and its reservation. Fresh execution authority and bootstrap identity are installed before execution. Personal checkpoint restoration keeps its exact saved state and does not use a prepared image as a substitute. A miss follows the ordinary startup path. Pending real work suppresses refill and reclaims conflicting optional resources; failed cleanup retains the charge and prevents reuse until physical release is verified. Prepared counts are observations, not execution authority or a guarantee of startup latency.

A Worker crash can leave an unclaimed prepared VM’s attachment arena behind. Startup quarantines retained attachment state and requires operator reconciliation, including when no customer Computer was running. Before relocating the exact arena, stop the Worker and verify that its processes and VM cgroups are gone and its backing devices are exclusively available and idle. Preserve the arena for inspection, then restart the Worker. Do not delete attachment markers or treat a stale host observation as proof of physical release. Enabling prepared VMs therefore requires an operator recovery procedure; automatic recovery of these arenas is not provided.

Checkpoint capture uses a separate host queue before sealing dispatch. Waiting for that queue leaves normal save processing available. Publication yields the capture permit so other Computers can capture while durable upload proceeds; retries preserve the same cut. A zero optional policy does not disable required checkpoint publication.

All optional limits default to zero, which disables new artifact-cache fills as well as prepared VMs. Repeated starts can therefore fetch artifacts again. To fund only the local artifact cache, set nonzero disk_bytes, max_tasks and io_bytes_per_second in the optional policy while leaving optional CPU, memory and VM slots at zero. The network rate covers optional fetches and uploads; copying an already verified local file uses the cache’s disk and task allowance. Cache fills occur during image preparation and seed construction; ordinary Computer starts do not populate the cache. A seed construction task and its cache fill each consume a task allowance, so caching alongside seed construction needs max_tasks of at least 2. Setting task or transfer limits to zero, or retiring an in-flight seed after a policy change, prevents new cache work without itself evicting resident data that still fits the disk budget and headroom. Real-work admission and disk-limit reductions can still reclaim that data.

Each pool plan reports policy_revision, placements.immediate, placements.after_optional_reclaim, optional_occupancy, reclaiming_workers, local_headroom_slots_in_use and credited_hosts (host IDs and Worker epochs). Placements count complete compatible VM shapes before queued work is packed. Compatible pools can credit the same Worker, so their counts must not be added. Missing or stale resource observations make immediate placement and optional occupancy null and the plan incomplete. Post-reclaim counts remain advisory; physical cleanup must finish before a granted allocation starts. Optional backlog and occupancy do not create additional-Worker recommendations.

Retry scale-in safely

Submit POST /capacity/v1/worker-groups/{group}/pools/{pool}/scale-in with {"operation_key":"<UUIDv7>"}. Helmr selects an eligible host and commits its drain together with a durable receipt. A retry with the same key returns that selection; reusing it for another pool is a conflict. Only one unsettled automatic scale-in operation is allowed per group. Hosts occupied only by optional work may be selected, but cleanup must still finish before provider termination.

Read GET /capacity/v1/worker-groups/{group}/scale-in-operations/pending before starting a new operation, including after controller restart. It returns {"operation":null} or the outstanding receipt. Read a particular key at GET /capacity/v1/worker-groups/{group}/scale-in-operations/{operation_key}. Receipts retain the selected host, provider resource and drain claim. Their phase is draining, termination_ready, blocked or completed. Terminate only the exact resource with a current termination_ready receipt and matching inventory; a blocked receipt requires reconciliation. Completion follows confirmed physical provider absence. Response loss is not permission to select another host.

React to capacity changes

Connect to GET /capacity/v1/updates with the capacity token and Accept: text/event-stream. The stream emits event: change on connection and on committed capacity-relevant changes, with heartbeat comments during idle periods. Treat each event as an invalidation hint and re-read durable state. Events are coalesced, have no replay cursor, and do not contain a capacity plan. Reconnect after disconnects and reconcile periodically to cover lost hints and provider-only changes. Save and output progress alone do not trigger capacity notifications.

Select a primary pool

Use the claim_version returned by Worker-group resolution:

{
  "expected_group_claim_version": 4,
  "minimum_ready_hosts": 1,
  "pool_id": "019..."
}

PUT .../primary-pool returns worker_group and applied. Re-read the group and retry from current state when the claim fence is stale. Selection checks fresh, active, unpaused hosts at the current epoch under the same Group, Pool and Host fences used by drain. Selecting the current primary is an exact replay; it does not establish current fleet health.

Group and Pool resolution includes retained_profiles: up to 100 distinct profiles with live Instance, creating checkpoint, parked checkpoint and eligible Pool counts. Eligible Pool counts include the inspected Pool when it is active; they are not a count of alternatives after its retirement. complete: false means more profiles exist; a truncated result is not proof that retirement is safe. Valid parked checkpoints retain their profile when waits resolve or Commands queue. A Pool also reports its claim version and seal time. Retirement checks the complete database state atomically.

GET /capacity/v1/deployment reports this Control Plane replica’s release, source commit, worker protocol revision, applied database schema version and migration dirty flag. It reads database state directly and does not report provider fleet convergence.

Reconcile Worker inventory

GET /worker-hosts accepts these optional query parameters:

Parameter Meaning
worker_group_id Limit results to one group.
repeated resource_id Match provider resource IDs. Each value is at most 512 bytes.
repeated status Match registering, active, draining, termination_ready, or lost.
has_unreclaimed_instance=true Return worker hosts that still own unreclaimed Computer Instance state.
limit Result limit; default 200, maximum 500.

The response contains worker_hosts. Each item includes id, resource_id, worker_group_id, worker_pool_id, status, claim_version, created_at, and updated_at. drain_blockers counts unreclaimed Instances and unreconciled Session processes. Zero counts still require the worker’s local cleanup acknowledgment. current_epoch, draining_at, drain_reason, termination_ready_at, and lost_at appear when applicable.

Group statuses are active, paused, draining, or disabled. Pool statuses are pending, active, draining, or disabled.

Drain before termination

Never terminate a provider host until its Worker instance reaches termination_ready. Start drain with the current epoch and claim version:

printf 'Authorization: Bearer %s\n' "$CAPACITY_TOKEN" |
  curl --fail-with-body \
  --request POST \
  --header @- \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data "{\"expected_epoch\":$CURRENT_EPOCH,\"expected_claim_version\":$CLAIM_VERSION,\"reason\":\"idle_scale_in\"}" \
  "$CONTROL_PLANE_URL/capacity/v1/worker-hosts/$INSTANCE_ID/drain"

The epoch and claim version fence the exact Worker ownership observed by the scaler. On conflict, discard the attempted mutation, read the instance again, and reconcile from current state. For reason idle_scale_in, Helmr returns 409 with code queued_demand_present instead of beginning scale-in while that group has queued work. This demand snapshot is advisory; new work may enqueue afterward. Use replacement or capacity_reduction for planned maintenance that must preserve queued work while draining. The first committed drain reason and epoch remain unchanged on replay.

After the provider confirms that a host is absent, call POST .../{id}/lost. This is a confirmation of observed provider state, not a request for Helmr to delete the host.

Errors and retries

Non-2xx responses use the standard error envelope:

{
  "error": {
    "code": "queued_demand_present",
    "message": "queued demand is present"
  }
}

details is optional and appears only when an error supplies structured details.

Retry reads and reconciliation loops from freshly observed state. Do not retry a provider mutation blindly. 404 with not_found means the referenced object is absent. A stale drain or claim fence returns 409; re-read before deciding whether any provider action is still needed. Mutating request bodies are limited to 16 KiB.

Compatibility

/capacity/v1 is the integration boundary. Deploy matching Control Plane and capacity-controller versions during pre-release. Clients must ignore unknown response fields; the request fields documented above are required for current capacity observations.

The historical Go module release capacity/v0.1.0 remains downloadable for reproducible builds, but it is frozen and receives no successor releases. New scalers should implement this HTTP protocol directly.

Rotate the token

Control Plane accepts one token and reads it at startup, so rotation has no overlap window:

  1. Pause the scaler.
  2. Generate and store a new token.
  3. Update CAPACITY_TOKEN, replace every Control Plane replica, and wait until all old replicas have stopped and all replacements are ready.
  4. Update the scaler secret and restart the scaler.
  5. Confirm the scaler can resolve a Worker group and read a plan, then resume.

The old token stops working only after the last old Control Plane replica has stopped.

The capacity authority can create a pending pool with POST /capacity/v1/worker-groups/{workerGroupID}/pools using name and expected_group_claim_version, and disable an eligible pool with POST /capacity/v1/worker-groups/{workerGroupID}/pools/{workerPoolID}/disable using expected_pool_claim_version. These use the same group/pool locks, claim checks, primary protection and custody checks as administration. Both return the capacity WorkerPool projection. A disabled name cannot enroll again; use a new generation name. Controllers must reobserve after a conflict or lost response and must not infer a terminal fence from provider absence alone.