HTTP backend protocol
This document is the source of truth for the wire contract Kilolock exposes to Terraform and OpenTofu in v0. It mirrors the documented behavior of Terraform's [http backend][tf-http] and lists every intentional deviation explicitly.
This document covers only the Terraform/OpenTofu HTTP backend lane. The experimental KL-native lane is documented separately in [docs/state-engine-protocol-v1.md](./state-engine-protocol-v1.md).
[tf-http]: https://developer.hashicorp.com/terraform/language/backend/http
Address
A state is addressed by name at the URL path:
<scheme>://<host>:<port>/v1/states/<state_name>
The <state_name> segment is URL-decoded and must be non-empty. State names that contain / are not supported in v0.
A sample Terraform backend block:
terraform {
backend "http" {
address = "http://localhost:8080/v1/states/example"
lock_address = "http://localhost:8080/v1/states/example"
unlock_address = "http://localhost:8080/v1/states/example"
lock_method = "LOCK"
unlock_method = "UNLOCK"
}
}
Recommended deployment patterns:
- Local OSS / Docker Compose: use the sample above directly.
- Hosted / cloud behind a managed edge: prefer `unlock_address =
".../v1/state-unlock/..."
withunlock_method = "POST"`. The separatePOST /v1/state-unlock/...route exists specifically because some providersreject
UNLOCKwith a request body before it reaches Kilolock.
If you want sliced state fetch, narrower reservations, or native kl state operations, keep this backend block as-is for Terraform/OpenTofu and configure the KL-native state-engine lane separately. The two lanes are designed to share one logical state.
Operations
GET /v1/states/{name}
Returns the current (most recent) state for the named state.
- 200 with
Content-Type: application/jsonand the state body when astate exists.
- 404 with no body when the state has never been written.
POST /v1/states/{name}?ID=<lock_id>
Atomically writes a new state version. The request body is the full .tfstate JSON; Kilolock stores it byte-for-byte in state_versions.raw_state and projects it into the normalized tables (resources, resource_dependencies, outputs) in the same transaction. The raw bytes remain the source of truth for export; the normalized rows are derived data used by SQL queries and the future graph-scoped engine.
Lock semantics:
| Lock currently held? | ?ID= supplied? | Behavior |
|---|---|---|
| no | no | accepted; written under no lock |
| no | yes | 409 Conflict — caller's lock view is stale |
| yes | no | 423 Locked — supply the lock id |
| yes | matching | accepted |
| yes | mismatching | 409 Conflict |
- 200 on success, empty body.
- 400 when the request body is not valid JSON or not a Terraform v4 state.
- 409 Conflict also returned if the supplied state's
serialmatches a serial already stored for this state. Bump the serial and retry, or useklCLI tooling to inspect history. - 423 Locked per the lock matrix above.
- 500 on internal errors.
DELETE /v1/states/{name}?ID=<lock_id>
Removes a state and (via ON DELETE CASCADE) all of its versions, resources, dependencies, outputs, locks, and audit events. Same lock semantics as POST.
- 200 on success, empty body.
- 404 if the state does not exist.
- 409 / 423 per the lock matrix.
LOCK /v1/states/{name}
Acquires a lock on the named state, creating the state row on first use. The request body is the JSON LockInfo Terraform sends:
{
"ID": "lock-uuid",
"Operation": "OperationTypeApply",
"Info": "",
"Who": "alice@laptop",
"Version": "1.13.4",
"Created": "2026-05-12T11:30:00.000Z",
"Path": "http://localhost:8080/v1/states/example"
}
- 200 when the lock is acquired, empty body.
- 400 when the body is not valid JSON or
IDis missing. - 423 Locked when the state is already locked; the response body is
the existing
LockInfoJSON so Terraform can show the holder.
UNLOCK /v1/states/{name}
Releases a held lock. Two shapes are accepted:
- Owner release. Body is the same
LockInfoJSON used byLOCK,with a non-empty
ID. The lock is released only whenIDmatchesthe one currently held.
- Force release. Body is empty, or JSON with an empty
ID. Thelock is released unconditionally and the operation is logged as
lock_force_releasein the audit trail. This branch exists becauseterraform force-unlockagainst anhttpbackend transmits anempty body and never sends the user-supplied lock ID over the wire
(see [Terraform's
httpClient.Unlock][tf-unlock]: it sendsc.jsonLockInfo, which is nil outside the process that acquiredthe lock).
[tf-unlock]: https://github.com/hashicorp/terraform/blob/v1.13.4/internal/backend/remote-state/http/client.go
- 200 when released, or on a force release against a state with no
lock currently held (idempotent —
terraform force-unlockis safe tore-run).
- 400 when the body is non-empty and not valid JSON.
- 409 Conflict when an owner-release
IDdoes not match the heldlock.
POST /v1/state-unlock/{name}
Cloud-compatible alias for lock release. It performs the same operation as UNLOCK /v1/states/{name} and accepts the same payload shapes:
LockInfoJSON with matchingID- empty body for force-unlock
- plain lock id string / JSON string /
?ID=...
Use this route when your ingress or load balancer does not reliably forward UNLOCK requests with a body.
Authentication
Multi-customer (hosted) — KL_AUTH_MODE=database
Each customer is a row in tenants (unique slug). Each API token belongs to one tenant and is stored as a SHA-256 hash.
Create customers and tokens with the control API:
curl -sS -X POST "http://localhost:8090/v1/api/tenants" \
-H "Authorization: Bearer $KL_CONTROL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug":"acme","name":"Acme Corp"}'
curl -sS -X POST "http://localhost:8090/v1/api/tenants/acme/tokens" \
-H "Authorization: Bearer $KL_CONTROL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"terraform-ci","environment":"default"}'
# token_secret is returned once — save it
**Terraform backend "http"** (recommended):
terraform {
backend "http" {
address = "https://api.example.com/v1/states/ws_ab12cd34ef56/env_12ab34cd56ef/prod"
lock_address = "https://api.example.com/v1/states/ws_ab12cd34ef56/env_12ab34cd56ef/prod"
unlock_address = "https://api.example.com/v1/state-unlock/ws_ab12cd34ef56/env_12ab34cd56ef/prod"
lock_method = "LOCK"
unlock_method = "POST"
username = "ws_ab12cd34ef56" # workspace_id
password = "kl_…" # environment token secret
}
}
The server validates:
hash(password)matches an active environment tokenusernamematches that token'sworkspace_id- the state path starts with that token's
{workspace_id}/{env_public_id}/...
Wrong pairing or wrong path → auth/path failure.
Bearer (no tenant in header): Authorization: Bearer kl_… — the token alone identifies the tenant.
/healthz is always unauthenticated.
Single-tenant (legacy) — KL_AUTH_MODE=static
One shared KL_AUTH_TOKEN for the built-in self-hosted singleton tenant.
Open (development only) — KL_AUTH_MODE=open
No HTTP authentication.
Isolation
Every store query filters by tenant_id from the authenticated Principal. Two customers can each have a state named prod; they never see each other's rows. In the workspace/environment-aware runtime path, the backend address is:
/v1/states/{workspace_id}/{env_public_id}/{state_name}
Audit trail
Every operation that mutates state writes a row to events:
events.kind | Triggered by |
|---|---|
state_write | POST /v1/states/{name} |
state_delete | DELETE /v1/states/{name} |
lock_acquire | LOCK /v1/states/{name} |
lock_release | UNLOCK /v1/states/{name} with matching ID |
lock_force_release | UNLOCK /v1/states/{name} with empty body / empty ID (terraform force-unlock) |
Reads do not produce events in v0.
Limits
| Knob | Default | Why |
|---|---|---|
| State body size | 512 MiB | Real-world large states have been observed in the 300 MiB range; the cap is set above that with comfortable headroom. |
| Lock body size | 64 KiB | Lock info is small; anything larger is suspicious. |
ReadHeaderTimeout | 15 s | Guards against slow-loris clients. |
| Migrate-on-startup timeout | 30 s | Bounds initial schema work; relax if needed. |
Deviations from the Terraform http backend
- **Lock acquisition is fixed at
LOCK /v1/states/{name}.** Terraform'shttpbackend lets you configure other methods, but v0 Kilolock does not currently
provide a
POSTalias for lock acquisition. - Lock release supports two wire shapes: canonical
UNLOCK /v1/states/{name}and the cloud-friendly aliasPOST /v1/state-unlock/{name}. The latter exists to work around managed edgesthat reject
UNLOCKwith a body. - **No
update_methoddistinction.** Terraform supports configuring analternate method (e.g.
PUT) for state writes; v0 implementsPOSTonly, which is the default.
- **Response on
POSTis always 200 (or an error).** Some backends use201 on first write; v0 does not distinguish.
- **No retry-related headers (
Retry-After).** Clients are expected tohonor 423/409 by re-fetching the lock or retrying after backoff.