ADR 0021: Large state payloads, embedded artifacts, and object-storage offload
- Status: Proposed
- Date: 2026-06-04
- Decision makers: Kilolock maintainers
- Relates to: ADR 0001, ADR 0004, ADR 0013, cloud-first managed operations, [
docs/protocol.md](../protocol.md)
Context
Today Kilolock stores the full raw Terraform/OpenTofu state snapshot inside Postgres for every state_version.
That works well for ordinary IaC state, but some providers and workflows can embed large payloads into state, for example:
- Lambda zip/archive metadata
- generated
archive_fileoutputs - large inline templates or rendered blobs
- provider-managed values that include base64 or other large strings
Postgres can technically tolerate large rows using TOAST, so the current model is not immediately broken. However, repeated full-snapshot history with embedded blobs creates real operational pressure:
- larger write transactions
- more TOAST churn and vacuum pressure
- slower backup/restore and replication
- larger history retention cost
- more expensive cross-database migration / export workflows
This matters more as we move toward:
- paid SaaS environments
- larger enterprise states
- dedicated-host / BYODB offerings
- stronger support expectations around rollback/history
We also already enforce resource-count quotas, but those do not protect us against payload-size explosions. A state with modest resource count can still be operationally heavy if it embeds large binary-like strings.
Decision
Kilolock will keep the raw state JSON as the authoritative logical state document, but future large-payload handling will become size-aware.
We will introduce a two-tier storage model:
- Small/ordinary state versions stay inline in Postgres.
- Large state payloads are offloaded to object storage, while Postgres
keeps metadata, digest, size, and retrieval pointers.
This ADR does not require immediate implementation. It defines the target design and phased path.
Rationale
Why not treat the database as generic blob storage forever
Postgres TOAST is a good safety net, but it is not a product strategy for artifact-heavy Terraform states. We want the database to remain strong at:
- transactional metadata
- state version indexing
- normalized resources
- audit and control-plane joins
We do not want state-history economics to be dominated by archived zip-like payloads living forever inside row storage.
Why keep raw state as the logical source of truth
Terraform/OpenTofu backend compatibility depends on serving and accepting a full raw state document. Kilolock should not redefine the state format or rely only on normalized tables.
So the target is:
- raw state remains authoritative
- normalized rows remain query/index substrate
- storage location of the raw blob becomes an implementation detail
Target design
D1. Split state-version metadata from raw payload placement
Each state_version should conceptually carry:
- version identity / serial / lineage
- created time / actor
- logical raw state digest
- raw state byte size
- compression metadata
- payload location
Payload location has two modes:
inline_dbobject_storage
For inline_db, current behavior remains.
For object_storage, Postgres stores:
- payload digest
- compressed size / uncompressed size
- object key / URI reference
- encryption metadata if needed
D2. Add byte-size aware policy
In addition to resource-count quotas, add payload-aware controls:
max_state_bytes_softmax_state_bytes_hard- optionally later:
max_environment_history_bytes
These are separate from resource-count limits because they protect a different risk class.
D3. Compression first, offload second
Before offloading, Kilolock should support compression of raw state payloads.
Preferred write path:
- receive raw state
- normalize resources as today
- compute digest and byte size
- compress payload
- decide inline vs offload based on configured threshold
This preserves one retrieval path while reducing storage cost even for inline states.
D4. Object storage is implementation-specific, not protocol-visible
Clients continue to see the normal HTTP backend behavior:
GETreturns raw state JSONPOSTaccepts raw state JSON
No client should know whether the payload is stored:
- inline in Postgres
- compressed in Postgres
- offloaded to object storage
D5. History and rollback remain supported
Rollback/history semantics stay intact:
- each version still has a logical raw state payload
- retrieval may load from object storage instead of row storage
- normalized resources still reference the same logical version
This preserves current product direction:
- operator rollback remains possible
- historical queryability remains possible
D6. Dedicated-host / BYODB boundaries
When environments are on dedicated DB or BYODB, we must keep storage boundary rules explicit:
- default expectation: large payload object storage lives within the same trust
boundary as the environment
- shared-host customers may use platform-managed object storage
- dedicated/BYODB customers may later require:
- customer-owned bucket
- platform-managed bucket with encryption guarantees
- “no offload allowed” mode
This ADR defines the need for that policy, not the final enterprise SKU matrix.
Consequences
Positive
- reduces pressure on Postgres for blob-heavy states
- keeps raw state compatibility intact
- gives us a clean future path for enterprise-scale state history
- lets us add real payload-byte quotas instead of relying only on resource count
Tradeoffs
- more operational complexity
- more moving parts on read/write path
- backup/restore now spans DB + object storage
- BYODB policy becomes more nuanced
Non-goals
This ADR does not propose:
- storing provider artifacts as first-class structured objects
- changing Terraform/OpenTofu state format
- removing raw state history entirely
- implementing customer-facing large-object migration workflows now
Phased implementation path
Phase 1: observability only
- record raw state byte size and compressed size
- add operator queries / dashboards for large states
- add warning thresholds in control/operator surfaces
Phase 2: quota policy
- add soft/hard byte quotas per state
- optionally add environment-level history byte visibility
- improve error messages for oversized state writes
Phase 3: compression
- compress raw state payloads at rest
- transparently decompress on read
- validate rollback/export behavior unchanged
Phase 4: object-storage offload
- add payload location metadata
- write large state versions to object storage
- keep smaller states inline
- preserve full HTTP backend compatibility
Phase 5: enterprise boundary options
- dedicated-host / BYODB storage policy
- customer-managed bucket support if needed
- backup/restore and retention alignment across DB + object storage
Practical product guidance before implementation
Until this ADR is implemented:
- Kilolock should be positioned as state management, not artifact storage.
- Large payloads embedded into state are supported only on a best-effort basis
by current Postgres storage behavior.
- Resource-count quotas must not be treated as sufficient protection against
oversized payload states.
References
- Terraform/OpenTofu HTTP backend contract
- PostgreSQL TOAST behavior
- Existing protocol limits in [
docs/protocol.md](../protocol.md)