Extend reservation TTL (lease refresh / heartbeat)
Extends the expiry of an ACTIVE reservation to support long-running agent workflows.
SEMANTICS (NORMATIVE): - Extension updates expires_at_ms only; it MUST NOT change reserved amount, unit, subject, action, scope_path, or affected_scopes. - Extensions MUST be applied in a concurrency-safe way. - Server MUST accept extend only when status is ACTIVE and the reservation has not yet expired
(i.e., server time ≤ expires_at_ms). If the reservation is expired, server MUST return 410 with error=RESERVATION_EXPIRED.
IDEMPOTENCY (NORMATIVE): - On replay with the same idempotency_key, the server MUST return the original successful response outcome
and payload — all fields VERBATIM — except that remaining_ttl_ms is a volatile observation and, when
emitted, MUST be recomputed from the original expires_at_ms and current authoritative server time while
constructing the replay response. It MUST NOT be copied from the originally stored response and MUST be
0 if the reservation is no longer ACTIVE. (Rationale: a heartbeat retrying a lost response with the same
idempotency key schedules from the replayed body; a cached value is stale by the retry delay.)
TENANCY (NORMATIVE): - If the reservation exists but is owned by a different effective tenant, the server MUST return 403 FORBIDDEN.
ERROR SEMANTICS (NORMATIVE): - If the owning tenant's status is CLOSED (deployments with a governance plane),
server MUST return 409 with error=TENANT_CLOSED, taking precedence over the
reservation-state errors below for non-replay requests — see the closed-tenant
binding in this document's top-level ERROR SEMANTICS.
- If the reservation is COMMITTED or RELEASED, server MUST return 409 with error=RESERVATION_FINALIZED. - If the reservation is expired (server time > expires_at_ms), server MUST return 410 with error=RESERVATION_EXPIRED. - If the reservation never existed, server MUST return 404 with error=NOT_FOUND.
HEARTBEAT GUIDANCE: - extend_by_ms is RELATIVE to the reservation's current expires_at_ms, not to request time. A keep-alive
client that extends by ttl_ms on every sub-TTL beat (e.g. every ttl/2) therefore drifts expiry outward
by ttl/2 per beat — an effectively unbounded zombie-reservation window (bounded only by a server's
extension limits) that a crashed client can leave holding budget, working against the same
zombie-reservation rationale as the grace_period_ms ceiling — and it consumes any server-side
extension budget (MAX_EXTENSIONS_EXCEEDED) twice as fast as necessary. - Servers MAY constrain extensions in two DISTINCT ways with different client-visible
signatures. (1) Per-extend GRANT clamping: the granted TTL is capped below the requested
ttl_ms (e.g. a max_reservation_ttl_ms ceiling in deployments with a governance plane: a 24h
request may be silently capped to 1h — or to 1s). (2) MAXIMUM-LEAD clamping: the effective
new expiry is clamped to at most a policy lead L beyond server time, so the server holds
expiry ≈ now + L no matter what is requested. The returned expires_at_ms is AUTHORITATIVE in
both cases. This revision adds the OPTIONAL remaining_ttl_ms field to both
ReservationCreateResponse and ReservationExtendResponse precisely because, when it is
absent, a client can compute neither its true initial lead nor which clamp regime it faces
(see the FALLBACK limits below). - PRIMARY ALGORITHM — NORMATIVE whenever the response carries remaining_ttl_ms (servers
SHOULD emit it; clients MUST treat it as optional for backward compatibility). On EVERY
successful create or extend response, recompute the schedule from that response alone —
never accumulate expiry differences:
Clients MUST record monotonic attempt_sent immediately before each HTTP attempt and
response_received after its complete, schema-valid response body is received.
rtt = monotonic(response_received) − monotonic(attempt_sent);
lead_floor = max(0, remaining_ttl_ms − rtt);
request_timeout_budget = the client's enforced finite upper bound for one complete extend attempt
(connect, write, read, and failure detection);
attempt_budget = max(request_timeout_budget, 1000 ms, 2 × maximum observed rtt);
safety_margin = max(1000 ms, 2 × maximum observed rtt);
retry_reserve = 2 × attempt_budget + safety_margin;
next_delay = max(0, lead_floor − retry_reserve);
All duration arithmetic MUST use milliseconds and MUST be overflow-safe; positive overflow
is treated as positive infinity, never wraparound. When clock or timer resolution requires
rounding, clients MUST round attempt budgets and safety margins up, and lead lower bounds
and scheduling/retry delays down, so rounding cannot consume the reserved margin.
schedule the next extension next_delay after response receipt. retry_reserve covers one
failed attempt, one same-key retry, and scheduling/network margin; when the lease is too
short to hold that reserve, next_delay is 0 rather than an unsafe positive delay. A zero
delay starts a best-effort extension immediately; it does not by itself prove that a
recovery retry will still fit if that attempt consumes its full budget.
A positive next_delay is possible only when lead_floor > retry_reserve. Clients SHOULD
therefore configure both timeout and expected-network-latency bounds so
attempt_budget < (minimum expected lead_floor − safety_margin) / 2; in particular, the
enforced per-extend request_timeout_budget SHOULD satisfy that inequality whenever it
dominates attempt_budget.
For example, with a returned lead near the default requested ttl_ms of 60000 ms, a
10000 ms request timeout and 1500 ms maximum observed rtt give attempt_budget = 10000 ms,
safety_margin = 3000 ms, retry_reserve = 23000 ms, and next_delay ≈ 37000 ms. A 30000 ms
timeout instead makes retry_reserve at least 61000 ms, so a 60000 ms lead produces
next_delay = 0. An additive-delta server MAY establish positive lead after the immediate
extension, but a server clamped to a 60000 ms maximum lead cannot; client timeout defaults
SHOULD be chosen accordingly.
If attempt timing is unavailable or unreliable, the client MUST set lead_floor and
next_delay to 0. If the request timeout is unknown or unbounded, treat attempt_budget as
positive infinity, which likewise makes next_delay 0. Unknown timing MUST NOT be treated as
zero elapsed time. A client without reliable monotonic attempt timing therefore cannot
establish a safe primary-path delay: the two-consecutive-zero-delay guard below permits at
most one immediate fresh extension after a successful response and then requires the client
to stop. It MUST NOT switch to the fieldless fallback merely because its local timing is
unavailable. The first beat derives from the CREATE response's
remaining_ttl_ms in exactly the same way, so no immediate priming is needed when a positive
safe delay can be established. Extend by the requested ttl_ms; the returned expires_at_ms
and remaining_ttl_ms are authoritative.
Replay awareness: same-key CREATE and EXTEND retries are safe to schedule from because the
server recomputes remaining_ttl_ms at replay-response construction time (see IDEMPOTENCY
above). The client still measures rtt for the individual HTTP attempt that produced the
response; it MUST NOT substitute timing from an earlier attempt.
Only a schema-valid HTTP 200 ReservationCreateResponse (for the initial create) or
ReservationExtendResponse (for an extension) counts as an observed success. A different or
malformed 2xx response is ambiguous and MUST NOT be used to schedule from stale state.
This response-success predicate applies on BOTH the primary and fallback paths; the fallback
changes only how the next delay is chosen after a valid success, not what proves success.
After a CREATE timeout, connection error, 5xx, or ambiguous 2xx, where no valid lead_floor
exists, the client SHOULD make one immediate retry with the SAME idempotency_key and then
stop and surface the ambiguity if that retry also fails to produce a schema-valid result.
On an EXTEND transient failure (timeout, connection error, 5xx, 429 LIMIT_EXCEEDED, or an
ambiguous 2xx), recompute current_lead_estimate = max(0, last lead_floor − monotonic elapsed
since the schema-valid response that established last lead_floor was received). Define
retry_window = current_lead_estimate − attempt_budget − safety_margin, WITHOUT clamping.
If retry_window < 0, no complete retry plus margin is provably safe: the client MUST NOT
retry and MUST stop and surface the failure. Otherwise, for timeout, connection error, 5xx,
and ambiguous 2xx, retry with the SAME idempotency_key after
retry_delay = min(30000 ms, current_lead_estimate/4, retry_window). For 429, first convert
the non-negative Retry-After seconds to retry_after_ms = 1000 × Retry-After using
overflow-safe arithmetic; retry_delay equals retry_after_ms only when retry_after_ms ≤
retry_window. If Retry-After is missing, invalid, overflows, or exceeds retry_window after
conversion, the client MUST NOT invent an earlier retry that violates throttling and SHOULD
stop and surface that the lease cannot be safely renewed. The retry_window cap leaves one
complete retry plus margin; retry_window = 0 means retry immediately, while a negative value
means stop. After EVERY failed or ambiguous recovery attempt, the client MUST recompute
current_lead_estimate and retry_window from the same last schema-valid response and the new
monotonic elapsed time before deciding again. It MAY continue recovery with the SAME
idempotency_key while the freshly recomputed retry_window is positive; elapsed time from all
prior attempts has already been deducted, and each decision reserves one complete
attempt_budget plus safety_margin for the prospective retry. If retry_window = 0, the client
MAY make one immediate recovery retry; if that retry also fails or is ambiguous before an
intervening success, it MUST stop even if coarse clock resolution still reports zero. The
client MUST also stop if neither monotonic elapsed nor retry_window decreases between
consecutive failures, preventing a zero-time recovery loop.
On any other 4xx response, the client SHOULD stop and surface the request or authorization
failure; it MUST NOT automatically rotate the idempotency key and retry an unchanged request.
A client MUST NOT enter an unbounded zero-delay loop. When a schema-valid success produces
next_delay = 0, it MAY make one immediate fresh extension attempt with a new idempotency_key.
If that success also produces next_delay = 0, the client MUST stop and surface that the lease
is shorter than its retry-safety budget; this still permits an additive-delta server's first
immediate extension to establish a larger positive lead without burning a maximum-lead
server's extension budget in a tight loop. - FALLBACK (NON-NORMATIVE) — only for servers that do not emit remaining_ttl_ms. A
measured-grant scheme, with its limits stated honestly below; no refinement of it removes
them.
Maintain a conservative LOWER BOUND on lead from same-frame differences (no cross-clock
arithmetic anywhere): lead_min = (sum of measured grants) −
monotonic_elapsed_since_reserve_response, starting at 0 at the reserve response, where each
measured grant is the difference between successive returned expires_at_ms values (the SAME
server frame, so differencing them is legitimate); lead_min never overstates the true lead.
The FIRST extension MAY fire immediately — as a TRADEOFF, not a free action. No
create-response field bounds the effective initial lease, so immediate priming minimizes
the lapse risk of an unknown small initial lease (a 24h request silently capped to 1s is
long expired when a 30s first beat arrives). But under maximum-lead clamping the value of
an extension is SCHEDULE-DEPENDENT: with a 60s maximum lead, an immediate extend measures a
grant ≈ 0 where the same extend fired 30s later would have measured ≈ 30s — so total
protected runtime and the sum of grants are NOT schedule-invariant, and immediate priming
can spend one extension for zero added lifetime. (An earlier revision of this guidance
claimed "nothing is lost but the count"; that claim is false under maximum-lead clamping.)
Cadence heuristic: compare each measured grant to the monotonic elapsed time since the
previous successful extension. Treat grant ≥ ~0.9×requested ttl_ms as the normal regime and
beat at ~grant/2 (bounded sensibly, e.g. no shorter than 1s) — against a per-extend-DELTA
clamp a smaller grant is a genuinely smaller lease, so tightening is correct. Treat
grant ≤ 0, or a grant inside a band around elapsed (e.g. 0.75×elapsed ≤ grant ≤
1.25×elapsed), as maximum-lead clamping: hold a fixed bounded cadence (e.g. min(requested
ttl_ms/2, 30s)), do not tighten toward a floor, surface that the extension budget (default
10) will deplete, and rely on MAX_EXTENSIONS_EXCEEDED as the terminal signal. Skip a beat
when lead_min ≥ 1.5×last_grant. - LIMITS of the fallback, stated plainly:
(a) It is only sound against servers that clamp the per-extend DELTA; under maximum-lead
clamping successive expires_at_ms differences measure the elapsed time BETWEEN calls, not
the lease size, so every grant-derived signal merely echoes the client's own call spacing.
(b) Regime detection from the observables (grant, elapsed) is UNDECIDABLE in general — the
band heuristic above is best-effort, not sound. Counterexample: requested ttl 24s,
per-extend grant cap 10s. After a lead_min skip the grant is measured across a doubled gap
(ratio ≈ 1, inside the band), the client falls back to the held cadence min(24s/2, 30s) =
12s, and at that cadence the ratio is 10/12 ≈ 0.833 — inside [0.75, 1.25] FOREVER while
the lease erodes 2s per cycle to a lapse. Any true per-extend grant in
[0.75×min(requested ttl_ms/2, 30s), 0.9×requested ttl_ms) stays misclassified permanently
once the held cadence is adopted; and grant ≥ 0.9×requested ttl_ms does not prove the
normal regime either — a maximum-lead clamp echoes ANY call spacing, including one that
approaches the requested ttl.
(c) Consequently NO portable, safe, extension-efficient heartbeat exists when servers may
both cap the initial TTL and clamp extensions to a maximum lead without returning the
remaining lifetime. That is WHY remaining_ttl_ms exists. Fallback clients SHOULD prefer
over-beating (bounded cadence, budget burn, MAX_EXTENSIONS_EXCEEDED as the stop) over
risking lease lapse.
The primary and fallback prescriptions are intentionally asymmetric. A field-bearing client
with reliable timing has a conservative lead lower bound and stops when it can prove that no
complete attempt plus margin fits. A fieldless client cannot make that proof in either
direction, so bounded over-beating is a best-effort choice that favors avoiding an
unobservable lapse at the acknowledged cost of extension-budget burn. - A blind alternate-beat cadence (extend on every SECOND beat WITHOUT lead tracking) leaves zero
margin after any single failed beat — at steady state the retry lands at the expiry instant — and
cannot keep up when the beat interval exceeds ttl/2. - On both paths, whenever a failed or ambiguous attempt is retried, the client MUST use the
SAME idempotency_key so a lost-response extend is not applied twice. Before every additional
primary-path recovery, the client MUST apply the freshly recomputed retry_window and progress
guards above. - On RESERVATION_EXPIRED, RESERVATION_FINALIZED, MAX_EXTENSIONS_EXCEEDED, TENANT_CLOSED (tenant
closure is irreversible), or NOT_FOUND the heartbeat SHOULD stop — these conditions are permanent
for the reservation.
Authorizations
Parameters
Header Parameters
Optional idempotency key header. If both header and body idempotency_key are provided, they MUST match. Server MUST enforce idempotency per endpoint by (effective tenant, endpoint, idempotency_key). On replay of an idempotent request that previously succeeded, server MUST return the original successful response outcome and payload (including any server-generated identifiers such as reservation_id), except that remaining_ttl_ms on a replayed createReservation or extendReservation success is a volatile response observation and is not part of the attested CyclesEvidence payload. When emitted, it MUST be recomputed from the original expires_at_ms and the current authoritative server time, never copied from the stored response; it MUST be 0 if the reservation is no longer ACTIVE. All other fields replay verbatim.
1256Path Parameters
1128Request Body
Responses
Reservation expiry extended