---
title: "Control costs"
description: "The cost card before every run, why unknown is never zero, the hard stop, and the plan limits that refuse a run before it starts."
---
Three separate mechanisms bound what a run can cost you, and they do different jobs. The **estimate** tells you what a run is likely to cost. The **hard stop** is the exact ceiling at which control cancels. The **entitlement gate** refuses a run before anything chargeable is allocated.
## The cost card
```bash
curl -fsS -X POST "$WS/run-estimates" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"worker_id": "wkr_2665449a0f8444f99719ab9f2639a787"}'
```
The card names the environment classes the run will use, how long it is likely to take, the maximum model budget, the maximum environment cost, the maximum total, and the hard stop. It reads only: it allocates nothing and starts nothing.
You can also price a template before a Worker exists, or price a demo:
```bash
curl -fsS -X POST "$WS/run-estimates" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"template_id": "website-qa-sweep", "demo": true}'
```
## Unknown is a shape, not a number
An amount is a tagged union. A determinate amount carries an integer:
```json
{ "known": true, "usd_micros": 2000000 }
```
An indeterminate one carries a reason and **no numeric field at all**:
```json
{ "known": false, "reason": "environment_rate_unknown", "note": "Environment rates are not configured in this deployment." }
```
Those are different shapes, not the same shape with a null in it, so a serializer cannot default a missing estimate to `0` and a renderer cannot print `$0.00` for "we do not know". The reasons are a closed set: `no_historical_basis`, `model_price_unknown`, `environment_rate_unknown`, `variable_workload`, `component_unknown`, `environment_class_not_runnable`.
The SDK carries the same rule into the type system:
```ts
estimate.max_total.usd_micros; // does not compile
estimate.max_total.usd_micros ?? 0; // does not compile either
if (estimate.max_total.known) {
estimate.max_total.usd_micros; // number
} else {
estimate.max_total.reason; // "component_unknown", …
}
```
`basis` says where the figures came from: `historical`, `worker_limits`, `template_default` or `no_basis`. `amounts_are_illustrative` is `true` wherever a deployment has configured rates that have not been measured.
Obol publishes no price on this card. A configured environment rate is deployment configuration; an unconfigured one is reported as unknown rather than guessed. The plan ladder carries entitlements and has no money field on it at all.
## The hard stop is always exact
```json
{
"max_total_micros_usd": 5000000,
"deadline_seconds": 600,
"on_reach": "cancel_run",
"max_environment_seconds": 600
}
```
A run cannot be cancelled at an unknown ceiling, so the hard stop commits to exact numbers even when every estimate on the card is unknown. It is the only promise on the card; everything else is an estimate over recorded metering events.
## Cost is shown before every run, by enforcement
`POST /worker-runs` requires `acknowledged_hard_stop`, and it must equal the `acknowledgement` from a *fresh* estimate. A client that never rendered the card cannot produce the figures. A client whose card went stale gets the current one back:
```json 409
{
"detail": {
"code": "estimate_stale",
"what": "This run's cost estimate changed before it started.",
"why": "The hard stop you confirmed is not the one that applies now.",
"suggested_fix": "Review the updated estimate and start the run again.",
"estimate": { "...": "the current card" },
"acknowledgement": { "max_total_micros_usd": 5000000, "deadline_seconds": 600 }
}
}
```
Handle it as a recoverable error, not a failure:
```ts
import { EstimateStaleError } from "@obol/sdk";
try {
await obol.workers.startRun({ worker_id, acknowledged_hard_stop: stale });
} catch (error) {
if (error instanceof EstimateStaleError) {
// error.estimate is the current card; error.acknowledgement is what to echo.
await obol.workers.startRun({
worker_id,
acknowledged_hard_stop: error.acknowledgement,
});
}
}
```
`obol.workers.run()` does this for you, and re-runs your `confirm` callback against the new card first.
## Plan limits refuse before anything starts
```bash
curl -fsS "$WS/workers-entitlements" -H "Authorization: Bearer $OBOL_SESSION_TOKEN"
```
The response carries your entitlement, the whole ladder as structure, what you have used this period, and per-class availability. Limits are `{"kind": "limited", "value": n}` or `{"kind": "unlimited"}` — an unlimited limit has no `value` field, for the same reason an unknown amount has no number.
Three refusal reasons can arrive before a run starts. Each is typed, each names the rule, and none of them allocates anything:
| Reason | Status | Means |
|---|---|---|
| `environment_class_unavailable` | 409 | the class is Beta, waitlisted, unentitled, or has no backend here |
| `concurrency_limit_reached` | 409 | as many runs or environments are in flight as the plan permits |
| `entitlement_exceeded` | 402 | the included runs for this period are used up, or a plan cap below applies (`detail.dimension` names which) |
Two plan caps are structural rather than per-period counts (ADR-0135). Each refuses the start. Neither ever cuts a running run short:
| Plan | Longest run (`run_timeout_seconds`) | Included environment time |
|---|---|---|
| Free | 30 minutes | 5 hours a month |
| Builder | 4 hours | — |
| Pro | 12 hours | — |
| Team | 24 hours | — |
| Enterprise | uncapped | — |
A Worker whose run timeout is longer than the plan allows is refused with `dimension: "run_timeout_seconds"`. A Free workspace whose environment time this month is used up is refused with `dimension: "environment_seconds"` before any environment is allocated. Schedules and webhooks also need a paid plan: `schedules_allowed` and `webhooks_allowed` are false on Free.
`entitlement_exceeded` is a *pre-allocation* refusal and has no counterpart in the run-failure taxonomy: there is no failure reason for "plan run allowance exhausted", because a run that was refused never started and therefore never failed. A run that exhausts a *budget* mid-flight fails with `budget_exhausted` instead, and that failure carries `most_expensive_step`.
## What a demo costs
Nothing, and by construction rather than by policy. `demo` is set by control at the point of creation, the run's estimate is a known zero, the budget row is written `billable: false`, and the metering path refuses to make a demo interval chargeable. Three independent places, because this is the one mistake that takes money from someone who was promised none.
## Reading what a run actually cost
The run list carries `measured_total_micros_usd`, which is `null` when nothing was measured. Render that as unknown, never as zero. The run page carries `most_expensive_step` on every run with measured steps, not only on a budget failure — "what did this cost me" gets asked more often than "why did it stop".
Provisioning time is not billed; a wait that releases compute accrues no active time, and one that deliberately holds a live environment does. Whether a paused run still holds compute is `environment.holds_compute` on the run view, not something to infer from run state.