Cluster tfstate & locking
The control plane is the durable home of each cluster's OpenTofu state:
adj cluster provision/deprovision/state push sync it here, encrypted at
rest, and a per-cluster advisory lock keeps two admins from running tofu
against the same cluster at once. See
Where provisioning state lives
for the CLI-side flow this API supports, and
Security model for
exactly what data lives in a stored state blob.
Auth: every route below is admin, org-scoped (cross-org access is a
404, same as other cluster routes).
Feature flag: if the control plane has no ADJ_STATE_ENC_KEY
configured, every route on this page returns:
{"error": "state sync not configured"}
with status 503. This is the sole degradation signal — the CLI treats it
as "operate local-only," not an error to retry.
GET /v1/clusters/{id}/tfstate — fetch the stored state
Returns the decrypted raw tfstate JSON exactly as last pushed. This is a read — it does not require holding the lock.
$ curl -s https://api.stagdb.com/v1/clusters/1c9c14da-8b0e-4c6e-9f1a-2d7c3a5b6e42/tfstate \
-H "Authorization: Bearer $TOKEN" -D -
Response 200 OK — body is the raw tfstate JSON (Content-Type: application/json), plus these headers:
| Header | Meaning |
|---|---|
X-Adj-State-Serial |
The tfstate serial field, lifted out for quick comparison without parsing the body. |
X-Adj-State-Lineage |
The tfstate lineage field. |
X-Adj-State-Pushed-By |
Email of the admin who last pushed this state. |
X-Adj-State-Pushed-At |
RFC3339 UTC timestamp of the last push. |
| Status | When |
|---|---|
| 400 | {id} not a valid UUID ("invalid id"). |
| 403 | Caller is not an admin. |
| 404 | No state stored yet for this cluster (or no such cluster in this org). |
| 503 | State sync not configured (ADJ_STATE_ENC_KEY unset). |
PUT /v1/clusters/{id}/tfstate — push state
Body is the raw tfstate JSON, sent as-is (not wrapped). The caller must
already hold the operation lock — acquire it first via POST .../lock.
$ curl -s https://api.stagdb.com/v1/clusters/1c9c14da-8b0e-4c6e-9f1a-2d7c3a5b6e42/tfstate \
-X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
--data-binary @terraform.tfstate
Response 200 OK
{"serial": 8}
Validation, in order:
- Admin/org check, then the
ADJ_STATE_ENC_KEYcheck (503 if unset). - Lock check — the caller must hold this cluster's lock, or the request is rejected outright.
- Size cap — plaintext over 10 MiB is rejected.
- The server parses
serial/lineageout of the body — malformed input (not a tfstate JSON) is a 400. - Conflict rules, evaluated against the currently-stored serial/lineage:
- Stored lineage differs from the pushed lineage → conflict.
- Stored serial is higher than the pushed serial → conflict.
- Equal serial, identical content → no-op,
200(idempotent retry). - Equal serial, different content → conflict.
- Otherwise (pushed serial higher, same lineage) → accepted, stored, and
audited as
cluster.state_pushed(payload: serial + size only — the state content itself is never audited).
| Status | When |
|---|---|
| 400 | Invalid {id}; or body isn't valid tfstate JSON (missing serial/lineage). |
| 403 | Caller is not an admin. |
| 404 | No such cluster in this org. |
| 409 | {"error":"lock_required"} — caller doesn't hold the lock; or {"error":"state_conflict"} — the serial/lineage rules above rejected the write. |
| 413 | {"error":"state exceeds 10 MiB"} — plaintext size over the cap. |
| 503 | State sync not configured. |
POST /v1/clusters/{id}/lock — acquire the operation lock
Body: {"operation": "provision"|"deprovision"|"state-push", "host": "<hostname>"}
(host is CLI-supplied, display-only — not validated against anything).
$ curl -s https://api.stagdb.com/v1/clusters/1c9c14da-8b0e-4c6e-9f1a-2d7c3a5b6e42/lock \
-X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"operation":"provision","host":"dana-laptop"}'
Response 200 OK
{"acquired_at": "2026-08-01T14:02:03Z"}
If the same caller already holds the lock, re-acquiring just refreshes the
timestamps (still 200). If another caller's lock is fresh (heartbeated
within the last 5 minutes), the request is refused:
Response 409 Conflict
{
"holder_email": "[email protected]",
"operation": "provision",
"host": "dana-laptop",
"acquired_at": "2026-08-01T13:58:00Z",
"heartbeat_age_seconds": 42
}
If the existing lock's heartbeat is older than 5 minutes, it's
considered stale: the acquire call steals it instead of 409ing —
audited as cluster.state_lock_stolen (payload includes the previous
holder).
| Status | When |
|---|---|
| 400 | Invalid {id}. |
| 403 | Caller is not an admin. |
| 404 | No such cluster in this org. |
| 409 | Lock held by another caller and still fresh (body above). |
| 503 | State sync not configured. |
POST /v1/clusters/{id}/lock/heartbeat — extend the lock
Called by the CLI every 60 seconds for the life of an operation. Holder only.
$ curl -s https://api.stagdb.com/v1/clusters/1c9c14da-8b0e-4c6e-9f1a-2d7c3a5b6e42/lock/heartbeat \
-X POST -H "Authorization: Bearer $TOKEN"
Response: 200 OK, empty body.
| Status | When |
|---|---|
| 400 | Invalid {id}. |
| 403 | Caller is not an admin. |
| 404 | No such cluster in this org. |
| 409 | {"error":"lock lost"} — caller no longer holds the lock (released, expired, or stolen by someone else). |
| 503 | State sync not configured. |
A heartbeat failure is not fatal on the CLI side — the running operation keeps going and pushes its state at the end regardless; a lost lock just means the server-side row no longer protects that run (see the sharp edge in Operation locking).
DELETE /v1/clusters/{id}/lock — release the lock
Holder-only release; idempotent.
$ curl -s https://api.stagdb.com/v1/clusters/1c9c14da-8b0e-4c6e-9f1a-2d7c3a5b6e42/lock \
-X DELETE -H "Authorization: Bearer $TOKEN"
Response: 200 OK, empty body.
DELETE /v1/clusters/{id}/lock?force=1 — force-unlock
Any org admin may force-release another holder's lock, regardless of
staleness. This is what adj cluster force-unlock calls, after its typed
cluster-name confirmation. Audited as cluster.state_lock_broken (payload:
previous holder).
$ curl -s "https://api.stagdb.com/v1/clusters/1c9c14da-8b0e-4c6e-9f1a-2d7c3a5b6e42/lock?force=1" \
-X DELETE -H "Authorization: Bearer $TOKEN"
Response: 200 OK, empty body.
| Status | When |
|---|---|
| 400 | Invalid {id}. |
| 403 | Caller is not an admin. |
| 404 | No such cluster in this org. |
| 503 | State sync not configured. |
Next
- Clusters — registration, health, join tokens, the provisioning bootstrap bundle.
- Cluster operations guide — the CLI-side lock/pull/heartbeat/push flow and its degradation behavior.