State Engine Protocol v1 Draft
This document is the draft wire contract for the state engine protocol used by kl to talk to Kilolock without being constrained to Terraform's plain HTTP backend snapshot semantics.
It complements, rather than replaces, the Terraform-compatible [docs/protocol.md](./protocol.md) contract.
Status
- Status: Draft
- Last updated: 2026-06-25
- Relates to: ADR 0028, ADR 0029, Terraform/OpenTofu compatibility policy
Goals
The state engine protocol exists to support workflows that are awkward or expensive under plain Terraform/OpenTofu HTTP backend semantics, especially on very large states.
The practical ambition is not small-project convenience. It is giving a large company a credible path to keep a very large shared state when that is the natural shape of the infrastructure, while still allowing narrower concurrent work on different dependency branches.
Primary goals:
- fetch only a relevant slice of realized state instead of always pulling the
full trunk snapshot
- lock only the relevant resource / module scope for state engine concurrency
- make native state operations possible (
state rm,state mv, repair,rollback, patch)
- keep the same logical state compatible with plain Terraform/OpenTofu HTTP
backend usage
- make mixed-mode operation safe by presenting the state as locked to plain
Terraform/OpenTofu while a state engine write is in progress
In other words, the protocol exists so "one enormous monolith" does not have to mean "one engineer at a time forever".
Non-goals
- replacing the Terraform/OpenTofu HTTP backend contract
- requiring users to migrate state to a different storage model
- guaranteeing a perfect minimal slice in every edge case
- exposing CLI-specific concepts like
--filedirectly as protocol primitives
Design principles
- One logical state, two lanes
- plain Terraform/OpenTofu uses HTTP backend semantics
klmay use state engine semantics
- Protocol is product surface, not CLI trick
klis a reference client- future clients should be able to implement the protocol directly
- State identity is shared
- both lanes address the same workspace/environment/state
- lineage, serial family, history, and audit trail remain unified
- Fail closed
- if slice completeness or dependency closure cannot be proven safely, the
client should widen scope or fall back instead of guessing
- if slice completeness or dependency closure cannot be proven safely, the
- Mixed-mode safety first
- state engine write activity must appear locked to plain Terraform/OpenTofu
- Trusted native lane, explicit fallback
- the protocol must distinguish "backend proved a safe native slice" from
"client must fall back"
- fallback is not just metadata; it changes the runtime lane
- the protocol must distinguish "backend proved a safe native slice" from
Transport
v1 uses:
- HTTPS
- JSON request/response bodies
- bearer token or equivalent auth already accepted by Kilolock APIs
Base path:
/v1/state-engine
This path is separate from:
/v1/states/...for Terraform/OpenTofu HTTP backend traffic/v1/admin/...for current operator/admin APIs
State identity
Protocol requests must identify a state using one of these equivalent forms:
Canonical logical identity
{
"workspace_id": "ws_ab12cd34ef56",
"env_public_id": "env_12ab34cd56ef",
"state_name": "prod"
}
Canonical combined name
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod"
}
State URL
{
"state_url": "https://api.example.com/v1/states/ws_ab12cd34ef56/env_12ab34cd56ef/prod"
}
Clients should prefer sending the combined state form once the identity is resolved.
Capability negotiation
GET /v1/state-engine/capabilities
Returns protocol version and supported features.
Example response:
{
"protocol": "state-engine",
"version": "v1",
"capabilities": {
"slice_fetch": true,
"backend_closure_expansion": true,
"resource_reservations": true,
"terraform_visible_native_lock": true,
"delta_commit": true,
"native_state_rm": true,
"native_state_mv": true,
"native_resource_rollback": true
}
}
Notes
delta_commit=truemeans native exact-address mutations can commit byupdating only the touched resource rows while still writing a canonical new
state_versions.raw_statesnapshot.- It does not imply that every future workflow already avoids snapshot-style
commit paths; in v1 it specifically covers native exact-address mutations.
- Capability negotiation allows the backend and client to evolve without
pretending every deployment supports every optimization.
Client configuration
The protocol is selected by KL-owned configuration, not by introducing a new Terraform backend type.
Terraform/OpenTofu backend block
Users may keep a normal HTTP backend:
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"
}
}
.kl.toml
Example:
state_url = "https://api.example.com/v1/states/ws_ab12cd34ef56/env_12ab34cd56ef/prod"
protocol = "state-engine"
[auth]
token_env = "KL_TOKEN"
Environment variables
KL_STATE_URLKL_TOKENKL_PROTOCOL
Precedence
Recommended precedence:
- explicit CLI flags
- KL environment variables
.kl.toml- discovered Terraform/OpenTofu backend config
Protocol flow
The expected high-level flow for a scoped state engine write is:
- resolve state identity
- fetch capabilities
- analyze local config and derive candidate scope
- ask backend to expand that scope over realized state
- fetch a slice
- acquire reservations
- begin an apply-run lifecycle record
- mark the state as Terraform-visible locked
- run local planning / execution / validation
- commit mutation
- finish or abort the apply-run
- release reservations and Terraform-visible lock
Trusted native apply versus fallback
For scoped apply workflows, the backend-authored scope contract is also a trust contract.
Proven-safe native slice
When the backend can prove a safe native slice, the plan metadata should identify the scope as a native slice (for example native-slice or native-slice-with-discovery-fallback with safe confidence).
At runtime this means the client may use the trusted state-engine lane:
- acquire a Terraform-visible coarse lock
- use state-engine reservation endpoints
- commit through state-engine delta semantics
- this applies both to
kl apply --plan-spec ...and to ordinary safe scopedflows such as
kl apply -f ...once the backend-authored plan metadata provesthe slice is trusted
- surface native intent to the operator
Fallback-required scope
When the backend cannot prove a safe native slice, or when the native scoped transport is unavailable, the plan metadata should identify fallback explicitly (for example full-trunk-fallback).
At runtime this means:
- the client must not pretend the trusted native lane is still active
- no state-engine coarse lock is taken
- no trusted state-engine delta commit is used
- execution stays on the broader snapshot/full-trunk behavior
This distinction is part of the protocol contract, not merely a CLI implementation detail. The product promise is "narrow when proven, broad when not", never "quietly keep going on the narrow lane anyway".
Scope and closure primitives
The protocol intentionally accepts generic scope primitives rather than CLI-specific flags such as --file.
Resource address selector
{
"kind": "resource_address",
"value": "aws_instance.web[0]"
}
Module prefix selector
{
"kind": "module_prefix",
"value": "module.database"
}
File provenance hint
{
"kind": "file_hint",
"value": "database.tf"
}
This may be used for diagnostics or server-side heuristics later, but it is not the fundamental scope unit.
Metadata lookup
POST /v1/state-engine/state/resolve
Resolves state metadata and canonical identity.
Request:
{
"state_url": "https://api.example.com/v1/states/ws_ab12cd34ef56/env_12ab34cd56ef/prod"
}
Response:
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"state_id": "9b25b0d1-9ea9-4cd1-8c23-4ce5a6a341f1",
"workspace_id": "ws_ab12cd34ef56",
"env_public_id": "env_12ab34cd56ef",
"state_name": "prod",
"lineage": "5734f91e-ac46-8262-b0aa-fca2549fb533",
"serial": 47,
"creator": "http_backend"
}
Backend-assisted closure expansion
POST /v1/state-engine/scope/expand
Expands client intent over the enriched realized graph.
Request:
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"selectors": [
{ "kind": "resource_address", "value": "module.database.aws_db_instance.primary" },
{ "kind": "module_prefix", "value": "module.database" }
],
"client_context": {
"explicit_write_candidates": [
"module.database.aws_db_instance.primary"
],
"explicit_read_candidates": [
"aws_kms_key.main"
],
"undeployed_config_candidates": [
"module.database.aws_db_parameter_group.primary"
],
"removed_config_candidates": [
"module.database.aws_db_option_group.legacy"
],
"config_nodes": [
{
"address": "module.database.aws_db_parameter_group.primary",
"dependencies": [
"aws_kms_key.main"
]
}
]
}
}
Response:
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"contract_source": "backend_authoritative",
"scope_contract": {
"fetch_addresses": [
"module.database.aws_db_instance.primary",
"aws_kms_key.main",
"aws_security_group.db"
],
"write_addresses": [
"module.database.aws_db_instance.primary"
],
"read_addresses": [
"aws_kms_key.main",
"aws_security_group.db"
],
"config_required_nodes": [
"module.database.aws_db_parameter_group.primary"
],
"removed_config_nodes": [
"module.database.aws_db_option_group.legacy"
],
"missing_from_state": [
"module.database.aws_db_parameter_group.primary"
],
"undeployed_candidates": [
"module.database.aws_db_parameter_group.primary"
],
"unknown_missing_from_state": [],
"reservation_candidates": [
{ "address_glob": "module.database.aws_db_instance.primary", "mode": "write" },
{ "address_glob": "aws_kms_key.main", "mode": "read" },
{ "address_glob": "aws_security_group.db", "mode": "read" }
],
"confidence": "safe",
"notes": []
},
"fetch_addresses": [
"module.database.aws_db_instance.primary",
"aws_kms_key.main",
"aws_security_group.db"
],
"realized_write_closure": [
"module.database.aws_db_instance.primary"
],
"realized_read_closure": [
"aws_kms_key.main",
"aws_security_group.db"
],
"missing_from_state": [
"module.database.aws_db_parameter_group.primary"
],
"undeployed_candidates": [
"module.database.aws_db_parameter_group.primary"
],
"unknown_missing_from_state": [],
"reservation_candidates": [
{ "address_glob": "module.database.aws_db_instance.primary", "mode": "write" },
{ "address_glob": "aws_kms_key.main", "mode": "read" },
{ "address_glob": "aws_security_group.db", "mode": "read" }
],
"confidence": "safe",
"notes": []
}
Backend-authored scope contract
scope_contract is the backend-authoritative execution contract for the next state-engine slice.
fetch_addressesis the exact realized slice the client should requestwrite_addressesandread_addressesare the backend-approved closuresconfig_required_nodesare undeployed config-only nodes still needed forlocal planning, even though they have no realized payload in state
removed_config_nodesare addresses the client marked as intentionallyremoved from config scope; if already absent from realized state, the backend
treats them as safe no-op deletions rather than unsafe unknowns
reservation_candidatesis the backend-approved reservation surface- the older top-level arrays remain in v1 for compatibility while clients move
over to the nested contract
Newer kl clients should prefer scope_contract when present instead of reconstructing fetch scope client-side.
Confidence values
safe— backend can satisfy the realized closure cleanlywidened— backend widened closure conservativelyunsafe— backend cannot prove enough closure; client should fail closed orfall back
Classification notes
missing_from_statemeans the requested/candidate address is not currentlyrealized in state
undeployed_candidatesis the subset the backend could explain asconfig-known but state-absent, based on client-provided undeployed hints
unknown_missing_from_stateis the subset the backend could not classifysafely; clients should treat this as fail-closed
Slice fetch
POST /v1/state-engine/state/slice
Fetches a reduced realized state slice.
Request:
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"addresses": [
"module.database.aws_db_instance.primary",
"aws_kms_key.main",
"aws_security_group.db"
],
"base_serial": 47
}
Response:
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"state_id": "9b25b0d1-9ea9-4cd1-8c23-4ce5a6a341f1",
"lineage": "5734f91e-ac46-8262-b0aa-fca2549fb533",
"serial": 47,
"slice": {
"resources": [
{
"address": "module.database.aws_db_instance.primary",
"provider": "registry.terraform.io/hashicorp/aws",
"schema_version": 1,
"attributes_json": { "identifier": "prod-db" },
"attributes_hash": "sha256:abc",
"resource_version": 313
}
],
"outputs": [],
"metadata": {
"terraform_version": "1.13.4"
}
}
}
Notes
- v1 does not require that the wire slice be a complete Terraform state JSON
document. It may be a backend-native shape that
klmaterializes locallyinto whatever execution format it needs.
- A future client other than
klmay choose a different local materialization. - when
addressesis empty, the backend returns an empty realized slice ratherthan the full trunk; this is important for config scopes that currently have
only undeployed resources
Reservations
POST /v1/state-engine/reservations/acquire
Request:
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"apply_id": "c106e3b2-eb7a-44f7-a2c7-c220b8d61297",
"holder": "alice@example.com@kl",
"lease_seconds": 900,
"want": [
{ "address_glob": "module.database.aws_db_instance.primary", "mode": "write" },
{ "address_glob": "aws_kms_key.main", "mode": "read" }
]
}
Success response:
{
"ok": true
}
Conflict response:
{
"error": "reservation_conflict",
"conflicts": [
{
"address_glob": "module.database.aws_db_instance.primary",
"mode": "write",
"holder": "bob@example.com@kl",
"apply_id": "1f46f5f4",
"expires_at": "2026-06-24T12:00:00Z"
}
]
}
POST /v1/state-engine/reservations/renew
Renews active reservations by apply_id.
POST /v1/state-engine/reservations/release
Releases active reservations by apply_id.
Apply-run lifecycle
Native state-engine apply uses the same auditable lifecycle model as the older admin/orchestrated path, but under the protocol namespace.
POST /v1/state-engine/apply-runs/begin
Starts an auditable apply-run record before reservations and Terraform-visible lock acquisition.
Request:
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"actor": "alice@example.com@kl",
"source_serial": 47,
"info": {
"mode": "state-engine"
}
}
Response:
{
"id": "2481e0ed-f880-4655-860d-3d0be183c6c4",
"state_id": "9b25b0d1-9ea9-4cd1-8c23-4ce5a6a341f1",
"from_version_id": "2ef2a5f8-c3d2-4c59-84ef-510e49f0f995",
"actor": "alice@example.com@kl",
"status": "running",
"source_serial": 47
}
Notes:
- clients may provide
state_iddirectly if already known - otherwise the backend resolves
statefirst and binds the run to that state
GET /v1/state-engine/apply-runs/{id}/status
Returns the current apply-run status.
Example response:
{
"status": "running"
}
POST /v1/state-engine/apply-runs/{id}/finish
Marks the apply-run as finished.
Request:
{
"status": "committed",
"committed_serial": 48,
"resources_planned": 1,
"resources_applied": 1
}
POST /v1/state-engine/apply-runs/{id}/abort
Marks the apply-run as aborted.
Request:
{
"reason": "aborted by operator"
}
Terraform-visible coarse lock
Any state engine write must make the state appear locked to plain Terraform/OpenTofu.
POST /v1/state-engine/terraform-lock/acquire
Creates or activates a Terraform-visible coarse lock for a native writer.
Request:
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"apply_id": "c106e3b2-eb7a-44f7-a2c7-c220b8d61297",
"holder": "alice@example.com@kl",
"scope_summary": [
"module.database.aws_db_instance.primary"
]
}
Response:
{
"ok": true,
"lock_id": "state-engine-c106e3b2-eb7a-44f7-a2c7-c220b8d61297"
}
POST /v1/state-engine/terraform-lock/release
Releases the Terraform-visible coarse lock by apply_id or lock_id.
Required effect
While this coarse lock is active:
LOCK /v1/states/{name}from plain Terraform/OpenTofu must fail as locked- the returned lock metadata should identify the holder as a state engine
operation
Native commit
POST /v1/state-engine/state/commit
Commits a state engine mutation.
v1 supports two commit modes:
mode = "delta"for native narrower mutation pathsmode = "snapshot"for transitional implementations that still produce afuller post-mutation document locally
Delta request shape
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"apply_id": "c106e3b2-eb7a-44f7-a2c7-c220b8d61297",
"base_serial": 47,
"mode": "delta",
"delta": {
"terraform_version": "1.13.4",
"lineage": "5734f91e-ac46-8262-b0aa-fca2549fb533",
"output_writes": {},
"output_delete_set": [],
"check_results": null,
"write_set": [
"module.database.aws_db_instance.primary"
],
"resources": [
{
"mode": "managed",
"type": "aws_db_instance",
"name": "primary",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"module": "module.database",
"instances": [
{
"schema_version": 1,
"attributes": { "identifier": "prod-db-v2" },
"sensitive_attributes": []
}
]
}
]
}
}
Snapshot request shape
{
"state": "ws_ab12cd34ef56/env_12ab34cd56ef/prod",
"apply_id": "c106e3b2-eb7a-44f7-a2c7-c220b8d61297",
"base_serial": 47,
"mode": "snapshot",
"raw_state": "{...full state json...}",
"write_set": [
"module.database.aws_db_instance.primary"
]
}
Current implementation note:
- the v1 OSS implementation now supports both
mode = "snapshot"andmode = "delta"on/v1/state-engine/state/commit mode = "delta"now accepts a narrowdeltapayload containing:selected resource instances/groups, optional
delete_setintent foraddresses removed by the apply, output delta via
output_writesandoutput_delete_set, and top-level metadata needed to build the nextcanonical state version
- the backend still rebuilds the committed
resourcessection fromauthoritative open resource rows, so untouched resources are never trusted
from the client payload
mode = "snapshot"remains supported for compatibility; whenwrite_setissupplied it may still take the selected-row reprojection path
- for compatibility during rollout,
mode = "delta"still also accepts theolder
raw_state + write_setrequest shape - the reference
kl applypath now usesmode = "delta"in state-engine mode,together with the state-engine apply-run, reservation, and coarse-lock
endpoints
Success response:
{
"ok": true,
"committed_serial": 48,
"new_version_id": "beae2e08-477a-4c95-b241-3e39c27c1247",
"commit_mode": "delta"
}
Conflict response:
{
"error": "state_serial_conflict",
"current_serial": 48
}
Native state operations
These are capability-gated and may be added incrementally.
POST /v1/state-engine/resource-remove/preview
Previews exact-address removal from current state.
POST /v1/state-engine/resource-remove/apply
Applies exact-address removal as a new state version.
POST /v1/state-engine/resource-move/preview
Previews exact-address move within current state.
POST /v1/state-engine/resource-move/apply
Applies exact-address move as a new state version.
POST /v1/state-engine/resource-rollback/preview
Previews replay of one exact current address from a historical version reference such as @17, current, or a version UUID.
POST /v1/state-engine/resource-rollback/apply
Applies exact-address rollback/restore as a new state version.
Notes
- v1 starts with exact-address operations only.
- advanced module-wide or dynamically expanded semantics may be deferred.
- the reference CLI surface is:
kl state rm [state] --address ...kl state mv [state] --from ... --to ...kl rollback resource [state] --address ... --to ...
Error model
Protocol errors should use stable machine-readable codes.
Recommended codes:
unsupported_capabilityinvalid_requestunauthenticatedforbiddenstate_not_foundreservation_conflictterraform_lockedstate_serial_conflictunsafe_scope_closureslice_incompletenative_commit_not_supported
Example:
{
"error": "unsafe_scope_closure",
"message": "selected scope depends on undeployed resources outside the safe closure",
"details": {
"missing": [
"module.database.aws_db_parameter_group.primary"
]
}
}
Lock matrix
Native vs native
| Existing activity | New state engine read | New state engine write |
|---|---|---|
| no activity | allow | allow |
| native read on disjoint scope | allow | allow |
| native write on disjoint scope | allow | allow |
| native write on overlapping scope | block / wait | block / wait |
Current client behavior for overlapping writes is intentionally conservative:
- reservation acquisition is whole-scope and all-or-nothing
- if a requested scope is mostly disjoint but overlaps on one write-conflicting
address, the waiting apply does not begin partial execution on its
disjoint subset
- the apply waits until it can acquire its full reservation set, and only then
starts Terraform/native execution
Native vs plain Terraform/OpenTofu
| Existing activity | Plain Terraform/OpenTofu lock request |
|---|---|
| no native write | normal HTTP backend lock behavior |
| native read only | normal HTTP backend lock behavior |
| native write active | fail as locked |
Read-only native operations do not need to present a Terraform-visible coarse lock unless they are participating in a mutation workflow.
Backward compatibility
- Existing Terraform/OpenTofu HTTP backend users remain fully supported.
- Existing
klcommands may begin by calling the state engine protocol only when:- explicitly configured, or
- protocol negotiation and client policy allow it
- Falling back from state engine mode to the HTTP-compatible lane is expected in
early implementations.
Implementation order
Recommended order:
- capabilities
- state resolve
- scope expand
- slice fetch
- reservations
- Terraform-visible coarse lock
- snapshot-mode native commit
- exact-address
state rm - exact-address
state mv - exact-address resource rollback
- state-engine apply-run lifecycle
- delta-mode commit
Open questions
- Should the protocol allow the backend to return an already-materialized
Terraform state JSON slice in addition to the backend-native slice shape?
- Should the client send local-config closure hints as plain addresses only, or
as richer typed graph nodes?
- Should Terraform-visible coarse locks be explicit protocol objects, or
derived automatically from native write reservations?
- At what point should native commit become resource-row-authoritative by
default rather than snapshot-mode transitional?
- The current graph snapshot cache is per
kldprocess and keyed by(state_id, serial). When should hosted/cloud deployments introduce ashared multi-replica cache so hot states stay warm across API instances?
Redis or a similar shared cache may be a candidate, but the design is still
open.