ADR 0007: v2 scope — Parallel apply on shared state
- Status: Proposed
- Date: 2026-05-14
- Decider(s): @davesade (David Kubec)
- Supersedes / Relates to: ADR 0001, ADR 0002, ADR 0004, ADR 0005
- Reference design: StateGraph Velocity (commercial, closed-source; <https://stategraph.com/docs/velocity>)
Context
v0 made Terraform state queryable. v1 closed the freshness gap with out-of-band provider-aware refresh. Neither addressed the operational constraint operators feel daily on any team larger than ~3 engineers: **only one person can terraform apply against a state at a time, even when their changes touch completely different resources.** Two engineers updating module.web.* and module.db.* in the same state must serialize because the HTTP backend protocol Terraform speaks has whole-state LOCK / UNLOCK and a whole-state blob write. S3 + DynamoDB, Terraform Cloud, GCS, Consul — every backend on the market has the same property.
The industry workaround is to split states aggressively: one state per team, per service, per module. It works, but it pushes the modularity problem out to the operator. Big orgs end up with hundreds of small states glued together with terraform_remote_state data sources, fragile cross-references, and bespoke deployment ordering scripts. Splitting the state because of how the backend locks is the wrong reason to split state.
The lifecycle model from ADR 0004 was always built to make this fixable. Content-addressable resources with explicit lifecycle ranges, written through a single delta-aware code path, mean that two writes that touch disjoint addresses are commutative at the row level. Whole-state blob writes are an artifact of the HTTP backend protocol, not of the data model underneath. The substrate has been ready since v1.
What's been missing is the layer that turns the substrate into a product:
- A reservation model that locks subgraphs, not whole states.
- A CLI entry point that drives Terraform per-subgraph instead of
letting Terraform drive the whole state.
- A commit path that merges row-level changes into the shared
trunk instead of clobbering it.
A commercial product exists that does exactly this: StateGraph Velocity. Their public documentation describes resource-level locking, parallel subgraph execution, multi-state transactions, and a stategraph CLI that replaces terraform as the operator's entry point — all over a Postgres backend. We are not inventing the design space. We are committing to ship the open-source implementation, Apache 2.0, self-hostable.
This ADR commits to that as the v2 scope.
Decision
v2 of Kilolock is parallel apply on shared state. Two engineers running kl apply against the same state with disjoint resource sets proceed in parallel and commit at the row level, without splitting the state and without blocking each other.
Concretely, v2 ships:
- A reservations model that locks addresses (and address
prefixes), not whole states.
- A new
kl applyCLI that consumes a Terraform planfile, computes the read- and write-sets, acquires reservations,
drives Terraform against a per-reservation state slice with
-lock=false, and commits row-level changes back through theADR 0004 lifecycle write path.
- A documented coexistence story with vanilla
terraform apply:plain Terraform still works against v2 states using a
whole-state reservation that conflicts with any in-flight
per-resource reservation.
Non-goals for v2 (deferred to v2.5 / v3):
- Multi-state transactions. StateGraph ships this; we treat
it as a separate scope. One state at a time in v2.
- Cross-state output slicing. Same — deferred. v2 reads the
full referenced state for now.
- Resource-level RBAC. Authorization on which addresses an
identity may reserve. Deferred to v3.
- Automatic retry on conflict. v2 fails fast and the operator
retries. Queueing / waiting is v2.5.
- Replacing Terraform's plan engine. v2 still uses
terraform plan -out=plan.tfplanto produce the plan. v2 ownsthe apply orchestration, not the planning.
Architecture
The data flow:
operator kl apply database
│ │ │
│ terraform plan -out=p │ │
│ kl apply -p p ─► │
│ │ parse plan JSON │
│ │ → write_set, read_set │
│ │ │
│ │ acquire reservations ───►│ resource_reservations
│ │ ◄── ok / conflict │
│ │ │
│ │ build state slice ───────│ resources @ snapshot
│ │ │
│ │ terraform apply │
│ │ -state=slice.tfstate │
│ │ -lock=false │
│ │ ◄── new slice │
│ │ │
│ │ row-level commit ───────►│ resources (lifecycle write)
│ │ release reservations ───►│ resource_reservations
│ │ │
│ ◄── apply complete │ │
The reservation model
A new table resource_reservations replaces state_locks on the v2 path:
CREATE TABLE resource_reservations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
state_id uuid NOT NULL REFERENCES states(id) ON DELETE CASCADE,
address_glob text NOT NULL, -- e.g. "module.web.*" or "aws_vpc.main"
mode text NOT NULL -- 'read' or 'write'
CHECK (mode IN ('read', 'write')),
holder text NOT NULL, -- actor identifier
apply_id uuid NOT NULL, -- correlates with apply_runs (below)
info jsonb NOT NULL DEFAULT '{}'::jsonb,
acquired_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz, -- lease; renewed by long-running applies
CONSTRAINT res_no_self_overlap UNIQUE (state_id, address_glob, holder, apply_id)
);
A companion apply_runs table mirrors refresh_runs: one row per kl apply invocation with status, started_at, finished_at, counters, error_summary.
Conflict matrix. A new reservation request conflicts with an existing reservation iff their address globs intersect AND at least one is write mode:
| existing: read | existing: write | |
|---|---|---|
| new: read | OK | conflict |
| new: write | conflict | conflict |
"Globs intersect" means: any address matched by glob A is also matched by glob B, or vice versa. For literal addresses, this is string equality; for prefixed globs (module.web.*), it's prefix containment in either direction. Implementation uses Postgres's LIKE patterns plus a small Go-side check for the intersection edge cases.
Leases. Reservations have an expires_at set by the orchestrator on acquire (default: 15 min) and renewed by a heartbeat goroutine inside kl apply every minute. Expired reservations are reclaimed lazily by the next acquire that conflicts with them — this matches state_locks' current behavior and means an kl apply killed with SIGKILL doesn't permanently wedge a subgraph.
Plan introspection
Terraform's plan file (plan.tfplan) is binary, but terraform show -json plan.tfplan produces a documented JSON representation. From that we extract:
- Write set: every address in
resource_changes[]whosechange.actionsis anything other than["no-op"]—create,update,delete,replace. These are theaddresses we will commit changes to.
- Read set: every address referenced by an expression in
the planned changes' configuration, traversed via the
graph dependencies recorded in
resource_dependencies.Pragmatically: the transitive closure of
write_setwalking dependency edges in both directions.
The read set matters for read-after-write consistency. If A's plan reads a value from R and B writes R between A's plan and A's commit, A is computing against stale truth. The reservation acquired on R in read mode tells B to wait.
Choice we make explicit: Computing the read set requires a dependency graph that is already current. In v2, that means a recent kl refresh is a prerequisite for an kl apply. The CLI either runs refresh implicitly or errors out if the cached graph is older than a configurable threshold. We pick the latter (explicit) to keep apply runs fast and deterministic; auto-refresh-before-apply is a v2.5 ergonomics tweak.
State slicing
Terraform's apply needs a state file that looks complete from its perspective: every resource it references (config, plan, dependencies) must be present, and the serial / lineage must match what Terraform expects.
For an apply scoped to write set W and read set R:
slice = {
serial: current_serial + 1,
lineage: same as trunk,
terraform_version: from current state_version,
resources: { r ∈ trunk : r.address ∈ W ∪ R },
outputs: all outputs (Terraform validates references)
}
Resources in R \ W (read-only references) are included so Terraform's plan validation passes, but the row-level commit ignores them — we trust whatever the original trunk had.
The slice is materialized in memory and handed to Terraform via -state= and -state-out= so Terraform never touches the backend during the sliced apply. The orchestrator captures the post-apply state file (-state-out=) and feeds it into the row-level commit.
Row-level commit
After Terraform's sliced apply finishes, the orchestrator:
- Parses the post-apply state.
- Filters resources down to
W(write set). Anything Terraformwrote that we didn't reserve is a bug — fail loud.
- Hands the filtered set to a variant of the existing
applyResourceDelta(pkg/store/normalize.go):for each address in
W, close the open lifecycle on thetrunk if the hash changed, open a new lifecycle at the new
serial. Untouched addresses on the trunk are not visited.
- Writes a new
state_versionsrow withsource='apply'and araw_state computed by re-projecting the trunk view at the new
serial. The raw_state stays consistent so vanilla
terraformGETs continue to work.
- Closes the apply_run row with counters, releases all
reservations.
The lifecycle dedup is what makes this safe: a parallel writer that touches different addresses doesn't interfere with the row-level UPDATEs, because the table doesn't have to lock addresses we're not writing.
The raw_state re-projection at step 4 is non-trivial: two parallel applies finishing at serials N+1 and N+2 each produce their own raw_state blob. The blob assembly must be linearized (one writer at a time produces the canonical raw_state for a given serial) but the underlying row writes can proceed in parallel up to that point. The lock taken at step 4 is over assemble new raw_state, not over write rows.
kl apply CLI
kl apply --plan plan.tfplan [--actor=...] [--timeout=15m] [--dry-run]
Behavior:
--planis required. v2 does not own planning.--dry-runparses the plan, computes write/read sets, attemptsto acquire reservations (and immediately releases them),
reports what would happen. No state changes.
- Exit codes:
- 0 on commit;
- 1 on apply failure (rolled back, no commit);
- 2 on reservation conflict (prints the holder + glob);
- 3 on usage / config errors.
The actor string is recorded on the apply_run row and on every reservation; the demo (v2d) uses it to label two terminals.
Coexistence with vanilla terraform apply
Engineers using plain terraform apply against a v2 state must still work. The HTTP backend's LOCK handler treats a vanilla lock acquisition as a whole-state write reservation: address_glob = '*', mode = 'write'. It conflicts with every in-flight per-resource reservation; conversely, any kl apply arriving while a vanilla lock is held also conflicts (its glob is non-*, but the whole-state glob * intersects everything).
This is intentional. The vanilla path is a correctness floor: old workflows keep working, but they don't get the parallelism. Operators opt in to parallel apply by switching to kl apply for the relevant plans.
The five hard questions, with answers
These are decisions baked into the design above; calling them out so they aren't surprises when the code lands.
1. Read-set semantics
Q. If A's plan reads R but doesn't write R, and B writes R between A's plan and A's commit, what does A see?
A. A acquires a read reservation on R when its apply starts. B's write reservation on R conflicts. B must wait (or error out with exit 2) until A commits and releases. A proceeds against the value of R that existed at plan time, and B's write lands after.
This is strict pessimistic locking on the read set, matching how SERIALIZABLE transactions work in a database. The cost is that R's writers wait; the win is that A is guaranteed to commit against the values it planned against.
2. Cross-subgraph dependencies
Q. If module.web reads module.db.aws_rds.primary.endpoint in its plan, and module.db is being applied in parallel, what does module.web's plan see?
A. module.db.aws_rds.primary is in module.web's read set (by transitive closure of dependency edges). It conflicts with the in-flight module.db write reservation. module.web either waits or fails fast. Once module.db commits, module.web re-plans against the new value.
Cross-state references are out of scope for v2 (deferred). Within a single state, transitive dependency closure on the read set gives us cross-subgraph correctness for free.
3. Plan staleness window
Q. Between terraform plan -out=plan.tfplan and kl apply --plan, the world can change. What stops a plan from being applied against an inconsistent state?
A. Two guards:
- The plan file carries the source state's serial. The apply
orchestrator refuses to proceed if the trunk's current serial
for any address in the read set is newer than the plan's
recorded serial. Operator must re-plan.
- The plan file carries the Terraform version + provider
versions. Mismatch with the trunk's recorded
state_versions.terraform_versionis a warning, not a hardfail — operators may legitimately upgrade providers between
plan and apply.
4. Failed-apply rollback
Q. Terraform's sliced apply fails halfway through. What's left in the database?
A. Nothing. The lifecycle write path runs inside a single transaction; if the apply fails before commit, no rows are inserted, no state_versions row is created, the trunk is unchanged. The apply_runs row records the failure with status='failed'. Reservations are released.
The cost is that we hold reservations for the duration of the apply, blocking other writers to the same subgraph. The alternative (start writing as resources finish) would let partial failures leave half-applied state, which is strictly worse than blocking. Vanilla terraform apply has the same behavior at the whole-state level.
5. Coexistence with vanilla terraform apply
Q. Can an engineer still use plain terraform apply against a v2 state?
A. Yes, with the caveat above (whole-state reservation that conflicts with everything). This is the correctness-floor-without-the-parallelism answer: nothing breaks, nothing speeds up.
CLI surface and operator UX
A canonical session:
# Engineer A, module.web changes
terraform plan -out=web.tfplan
kl apply --plan web.tfplan --actor=alice
# → output:
# reservations acquired (write: module.web.*, read: module.db.aws_rds.primary)
# apply running... (45s)
# apply complete: 12 changed, 0 failed
# committed at serial 47 (source=apply, run=ar_4f2a...)
# Engineer B, module.db changes, simultaneously
terraform plan -out=db.tfplan
kl apply --plan db.tfplan --actor=bob
# → output:
# reservations acquired (write: module.db.*)
# apply running... (38s)
# apply complete: 5 changed, 0 failed
# committed at serial 48 (source=apply, run=ar_9c0e...)
Both succeed. Trunk now has alice's web changes at serial 47 and bob's db changes at serial 48.
A conflicting session:
# Engineer C tries to update module.web while alice is mid-apply
terraform plan -out=web2.tfplan
kl apply --plan web2.tfplan --actor=carol
# → output:
# reservation conflict on module.web.*:
# held by alice (run ar_4f2a..., acquired 12s ago, expires in 14m48s)
# exit code 2
# carol decides: wait and retry, or rebase the plan after alice finishes.
The conflict message is the headline ergonomic — operators must be able to see immediately who's holding what and how long they've been holding it. The query backing it is one row in resource_reservations.
Worked example: the v2d demo
Two terminals against a 3-module fixture (network / web / db):
Terminal A (alice) Terminal B (bob)
───────────────── ─────────────────
plan web.tfplan plan db.tfplan
kl apply --plan web.tfplan kl apply --plan db.tfplan
reservations: write module.web.* reservations: write module.db.*
applying... applying...
(commits first, serial 24)
commits, serial 25
done done
Demo assertion: a single SELECT * FROM apply_runs ORDER BY started_at shows two overlapping run windows (start_at(A) < end_at(B) AND start_at(B) < end_at(A)). On a flat-state backend, this is impossible by construction.
A second demo run with overlapping subgraphs (both writers target module.web.*) shows the conflict path with the exact error message above.
Implementation breakdown
Sized as bite-sized commits that each end on green CI.
v2a — Reservations substrate.
- Migration 0007:
resource_reservationstable + indexes. - Migration 0008:
apply_runstable. Store.AcquireReservations(ctx, stateID, applyID, want []Reservation)with the conflict matrix as pure-SQL (
SELECT ... FOR UPDATE).Store.ReleaseReservations(ctx, applyID).- Heartbeat:
Store.RenewReservations(ctx, applyID, lease). - Unit + integration tests for: clean acquire, conflict (read-write,
write-write, write-read), expiry reclaim, heartbeat.
v2b — Plan introspection + state slicing.
internal/plan/: parseterraform show -jsonoutput; extractwrite set, read set (via transitive dep closure against the
current
resource_dependenciesview).internal/slice/: build a.tfstateslice from trunk +write/read sets.
kl apply --plan --dry-runlands: prints the predictedreservations and the slice contents, exits without acquiring.
- Tests: end-to-end against a recorded
terraform show -jsonfixture covering create / update / delete / replace.
v2c — Sliced apply + row-level commit.
- The orchestrator drives
terraform applyagainst a slice with-state=,-state-out=,-lock=false. - Row-level commit reuses
applyResourceDeltafiltered to thewrite set.
state_versions.raw_statere-projection from the trunk atcommit time, under a brief whole-state assembly lock.
kl apply(no--dry-run) lands end-to-end.- Integration tests: one-engineer happy path; apply failure
leaves trunk untouched; reservations cleared.
v2d — Parallel-apply demo.
examples/parallel-apply-demo/: 3-module fixture, twokl applyinvocations in two terminals, scripted viatmuxor(... &)to run truly concurrently.- Asserts on
apply_runsthat the two windows overlap in time. - Asserts on
resourcesthat both write sets landed. - README explaining what just happened, with timings.
v2e — Coexistence + docs.
- HTTP backend's LOCK handler emits a
*-glob write reservation;UNLOCK releases it.
- Vanilla
terraform applycontinues to work against v2 states. - README and ADR updates; positioning page that explicitly names
StateGraph Velocity as the reference design we're matching.
Open questions / risks
Calling these out so we don't pretend they're solved:
- Plan file format stability.
terraform show -jsonoutputhas been stable since Terraform 0.12, but provider plan
encodings can change. We need a compatibility test matrix
against terraform 1.3+ and the corresponding OpenTofu releases.
- Outputs. v2 includes all outputs in every slice (cheap, but
means an apply that doesn't touch outputs still sees them).
StateGraph documents per-output slicing; we defer that to v2.5
with the cross-state work.
- Provider state. A sliced apply runs Terraform with its own
provider configuration (from the slice's
terraformblock). If two parallel applies use different provider versions
/ configurations, behavior is undefined. v2 enforces that all
applies against a state use the same provider versions
(recorded in
state_versions.terraform_version+ a futureprovider_versionscolumn).
- Read set explosion. For some states, every resource
transitively depends on a few foundational ones (e.g. a
shared VPC). Those resources end up in everyone's read set,
which means everyone serializes through their writers. We
accept this as a v2 limitation; the fix is finer-grained
dependency tracking (per-attribute) which is a research
project, not a v2 commit.
- Postgres connection pool. Long-running applies hold
reservations and need a DB connection for the heartbeat.
We must size the pool for `apply_runs.max_parallel × 2 +
ambient_load`. v2 documents the sizing; v2.5 may move
heartbeats to a single shared goroutine.
- Crash recovery semantics. An
kl applySIGKILLedmid-commit leaves an
apply_runsrow inrunningandreservations held until
expires_at. The demo scriptdocuments
kl apply abort <run_id>as the operatorescape hatch. Implementation lands in v2a alongside the
reservations substrate.
Positioning
The honest framing for v2 in the project's README and pitch:
Kilolock v2 is the open-source implementation of resource-level locking for Terraform state, modeled on StateGraph Velocity. Two engineers updating disjoint parts of the same state apply in parallel. The data model is shared with v0 (queryable state) and v1 (out-of-band refresh): a normalized graph in Postgres with content-addressable lifecycles. Apache 2.0, self-hostable, no SaaS dependency.
We are not claiming to have invented this. We are claiming that the value of an OSS implementation — auditable, self-hostable, extensible — is high enough to be worth doing.
Addendum: 2026-05-14 — v2 spike findings
A throwaway spike validated the riskiest unknowns above before any code lands in the main binary. This addendum records the design changes the spike forced.
What the spike validated as-designed
- Plan JSON is sufficient.
resource_changes[].change.actionsgives the write set;
configuration.root_module.resources[].expressionsyields the static dep graph via recursive
referencescollection(after filtering
var.*,local.*,each.*,count.*,path.*,terraform.*). The bidirectional fixed-point closuredescribed in the ADR works as written.
- The conflict matrix and reservation shape in
resource_reservationssurvive contact with reality. The spikepredicted reservations identical to what the formal model
produces.
What the spike invalidated
Three concrete assumptions in the design above are wrong:
- **
terraform apply -state=slice.tfstate -lock=falseis silentlyignored when a backend is configured.** Terraform sends the
apply through the configured HTTP backend regardless of
-state/
-state-out. The "data flow" diagram (`terraform apply-state=slice.tfstate`) does not run.
- **Plan files are bound to the backend they were generated
against.** Copying
plan.tfplanto a local-backend workingdirectory and applying it sends writes back to the original
HTTP backend. The CLI shape
kl apply --plan plan.tfplanthat uses Terraform to replay an externally-generated plan **does
not work.** v2 must re-plan inside its own apply working directory.
- **A state slice limited to
W ∪ Ris too thin.** Terraformre-plans inside the apply directory; if any HCL-declared resource
is missing from the slice, Terraform plans it as
create. Theslice must include every trunk resource the HCL configuration
describes, not just the read/write sets.
Revised v2 apply model
The apply orchestrator becomes more involved than the original sketch. The new flow:
kl apply [config-dir] [--targets=...]
1. fetch trunk via Store (read-only snapshot)
2. preflight: terraform plan inside a tmp dir against trunk
→ terraform show -json → write_set, read_set
3. acquire reservations on (W, R)
4. set up apply tmp dir:
- copy HCL minus any backend.tf
- inject `terraform { backend "local" {} }`
- write slice = trunk ∩ HCL-described addresses
as terraform.tfstate (NOT just W ∪ R)
- link providers via TF_PLUGIN_CACHE_DIR
5. terraform init && terraform apply -auto-approve
(Terraform re-plans against the slice; assert the re-plan's
write_set matches the predicted write_set or fail loud)
6. read resulting terraform.tfstate
7. assert writes ⊆ predicted write_set
8. for each addr in write_set:
upsert via the v1 lifecycle write path
9. release reservations; write state_versions row (source='apply')
Two-phase CLI surface (replaces the --plan plan.tfplan shape):
kl plan [config-dir] # writes kl-plan.json
# (our descriptor, not terraform's)
kl apply kl-plan.json
kl-plan.json is the spike's predicted-reservations payload: write set, read set, preflight plan summary, source state serial. Operators review the spec; kl apply re-plans against the slice and asserts the result matches the spec.
Why "merge only write_set, not no-ops"
Terraform's apply re-serializes attribute JSON with alphabetical keys. The trunk's existing rows may have different key order. Byte-comparing no-op rows pre- vs post-apply produces false positives ("drifted" on every no-op). Demonstrated on the deps fixture: random_id.vpc is semantically identical pre and post but byte-different.
The commit rule the ADR already prescribes — "filter resources down to W; anything outside is a bug" — is also what makes the merge correct under terraform's re-canonicalization. The spike confirms this; the rule survives. It's worth noting that v2c integration tests must assert this property explicitly (post-apply byte-diff on no-op rows is expected and ignored; post-apply byte-diff on a non-write_set row is a fail-loud condition).
Implementation breakdown — revised
The five-phase breakdown above stays, but with the new model:
- v2a — Reservations substrate — unchanged.
- v2b — Plan introspection + slicing — the spike code promotes into
internal/plan/(JSON parsing, dep graph, write/read sets) andinternal/slice/(HCL-footprint slice computation).kl planCLI lands here; it writeskl-plan.jsonand exits. - v2c — Sliced apply + row-level commit — owns the tmp-dir
orchestration described above. The orchestrator never calls
terraform applywith-state=/-state-out=; it materializesa working directory with a local backend and runs Terraform
cleanly inside it.
- v2d — Parallel-apply demo — unchanged in scope but the demo
script will use
kl plan+ `kl applykl-plan.json
, notterraform plan -out=+klapply --plan`.
- v2e — Coexistence + docs — unchanged.
Things the spike did not cover
- Modules with
count/for_each(need indexed-address supportin dep graph normalization)
- Cross-module references
- Read reservations on data sources (they're
read-only in planoutput and need their own reservation class)
- Terraform's
replace_triggered_bylifecycle blocks (these addedges not captured in
references)
These are v2b implementation work, not blockers on the design.
References
- ADR 0004 (lifecycle model — the data substrate for row-level
writes): ./0004-resource-lifecycles.md
- ADR 0006 (refresh orchestrator — the closest existing analog
for what
kl applywill look like): - v2 spike (plan introspection + slicing validation): earlier internal
design notes
- StateGraph Velocity (reference design):
<https://stategraph.com/docs/velocity>
- StateGraph product overview:
<https://stategraph.com/how-stategraph-works>
- Terraform plan JSON format:
<https://developer.hashicorp.com/terraform/internals/json-format>
- Postgres
SELECT ... FOR UPDATEsemantics:<https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE>