Compatibility reference#

What choudoufu admits and refuses: the provider and resource types, how a configuration must be written, and how it may be run.

This is the enumerated list. For why static evaluability is the rule behind most of it, see Identity. To check your own configuration against this list, see How to check a configuration before migrating.

Your provider#

AWS only. Every google_*, azurerm_*, kubernetes_* and helm_* resource is refused. There is no second cloud on the roadmap (#5).

Your resource types#

A type is admitted when its identity recovers from the live system, through the admission table, the provider’s own identity schema, or the way your configuration names it.

Common types are largely covered. The connective tissue that long was not, aws_ecs_service, aws_lambda_permission, aws_cloudwatch_event_rule and aws_cloudwatch_event_target, carries full table rows since the 2026-08-15 ratification batch. API Gateway assembly is the named gap. aws_api_gateway_deployment and aws_api_gateway_resource reach none of the three admission paths.

live/LIMITATIONS.md carries the per-type detail.

Readiness tiers#

Beyond admission, the tier definitions (#417) names four tiers by what recovers a type’s identity when the record store, the state file, or the tool itself is gone. Three tiers name the carrier (marker-carried, declaration-carried, record-carried); the fourth is excluded by design. live/readiness.json assigns every provider type exactly one tier and one of six statuses; the table below is generated from it. live/COVERAGE.md carries the same table with more context, and Resource tier lookup carries it broken out per type, in customer language, with a reason for anything short of in-contract.

One reading trap before the table. The record-carried row counts two populations: the types the record-located mechanism actually covers, and a larger set of untaggable types with no admission row yet that the classifier lands there by elimination. They differ threefold, and Resource tier lookup separates them.

Tierin-contractpending-ratificationneeds-separatorneeds-evidencepending-mechanismexcludedTotal
marker-carried6821611200846
declaration-carried341370100379
record-carried96294316620471
excluded by design0000033
Total11194924196231699

live/readiness.json last committed at commit 1b09912946 on 2026-09-06T20:17:12-06:00. Regenerate with go run ./tools/readiness-gen and re-render with go run ./tools/readiness-gen -render before trusting this against a newer commit.

How your configuration is written#

This is the group that catches people. Every row below is a different way of asking an address or an identity to resolve before a provider can answer it; Identity states the rule in full.

Expansion#

count and for_each are expanded before anything is read, so that a marker exists for every instance. That does not mean the values have to be written in the configuration text. Two phases run in front of resolution and both feed it:

  • Data sources are read first (#179), so count = length(data.aws_availability_zones.all.names) and for_each = toset(data.aws_subnets.x.ids) both expand normally. The phase calls a provider on purpose, ahead of resolution, precisely so that they can. What is refused is a data source that cannot be read that early.
  • A sibling’s own keys carry across. for_each = aws_subnet.this borrows that resource’s expansion and count = length(aws_eip.pool) borrows its cardinality, neither needing a single live ID. Where a run has already resolved and discovered once, a second pass can also answer a count or for_each that reads a genuinely computed attribute of a sibling (#187).
Written like thisWhy it stops
for_each = module.net.subnet_ids, and count = length(module.net.subnet_ids)a module output is evaluable in an identity argument but not in an expansion. The expansion pass refuses it as “Module output not supported in static context” even when the output is a literal
for_each = { for s in aws_subnet.app : s.id => s }the key clause reads the iteration variable, so the key is a live ID rather than one of the parent’s own keys. A comprehension whose key clause does not read the value variable expands from the parent’s keys
for_each over a count-expanded resourcecount produces a tuple, and stock OpenTofu rejects a tuple as a for_each argument too
count.index in an identity-bearing argument, where two indices render the same valueboth instances resolve to one live identity, so one marker is written over the other
a data source that cannot be read before the planit depends on a managed resource, names one in depends_on, has a non-static argument, or its provider cannot be configured pre-plan

That last-but-one row is much narrower than it once was. count.index in an ordinary tag, description or other non-identity argument is not refused at all, and in an identity-bearing argument the test is collision rather than indexing: "name-${count.index}", 100 + count.index, format("web-%d", count.index) and var.zones[count.index] over distinct zones are all admitted, and count.index % 3 is admitted at count = 3 and refused at count = 5. live/LIMITATIONS.md’s count-index-in-tag entry carries the full rule.

for_each keys#

An instance key becomes part of the tofu-address marker, so it must survive being written to a tag and read back. Since #210 that boundary is wide: every printable rune is permitted except six. A key outside the raw AWS tag-value character set is escaped into the marker rather than refused, so dotted keys and CIDR blocks work. alice.smith, 2001:db8::/64, eu-west-1a, eu/west and at@sign are all ordinary keys.

Letters, digits, space and + - = . _ : / @ need no escaping, with one wrinkle: + is the escape introducer, so a literal + is doubled inside the marker. It round-trips, and no key is refused for containing one.

The six exclusions are " \ $ % [ ], each colliding with an escaping rule this fork does not own:

RuneWhy
" and \addrstoHCLQuotedString backslash-escapes them when OpenTofu renders a key into the declared side of an address comparison, so the key would decode differently on each side
$ and %the same function doubles either one when it immediately precedes {, a transformation with no per-rune inverse
[ and ]markersEscapeAddress scans for them to find an instance key’s boundaries, before any key-level escaping runs, so a raw bracket corrupts the scan itself

An empty key is refused too. An escaped address ending in a bare : does not parse back as a marker.

One case is narrower. When the for_each expression itself is rooted at a data source or another resource - anything known only once the cloud has been read - the stamping pass cannot build the per-key escape table, so the key set narrows back to the unescaped one: letters, digits, space and

`+` `-` `=` `.` `_` `:` `/` `@`

(#227).

The rule lives in internal/live/markerkey, and both enforcement points, lint and the resolver, read it from there, so the two cannot drift.

Identity arguments#

Where the name in configuration is the identity, that argument must resolve before a provider is called. An expression containing no managed-resource reference is statically evaluated - and that covers literals, string templates, input variables, locals, functions, path.*, terraform.workspace, module outputs, data-source results, and arbitrary composition of those. All three of these resolve:

name   = lower("${var.env}-app")   # pure functions are fine
name   = module.naming.prefix      # a module output is an expression written
                                   # in the child scope, and this resolver
                                   # can enter that scope and evaluate it
bucket = data.aws_s3_bucket.x.id   # read by the data-read phase before
                                   # resolution begins

An expression that does reference a managed resource takes one of two routes instead. By default it is matched structurally: a bare traversal becomes a reference to that parent’s identity attribute, and a string template becomes a sequence of literal and parent parts, which is what makes "${aws_route_table.main.id}_0.0.0.0/0" expressible. After one resolve-and-discover cycle, a later pass can also read a sibling’s genuinely computed attribute out of what the cloud holds, which is what admits aws_acm_certificate.cert.domain_validation_options.

Some expression shapes still stop:

Written like thisWhy it stops
a managed-resource reference inside a function call or arithmetic, with no live value for itstructural matching handles traversals and templates, not computation over a value that does not exist yet
bucket_prefix = "app-" with no bucketthe identity argument is not set
name = var.secret_name where the variable is sensitiveidentities appear in logs and plan output. Wrap the specific value in nonsensitive(...) where it is not genuinely secret
name = "app-${uuid()}"uuid(), timestamp() and bcrypt() return a different value on every evaluation
reading .arn where the table expects namethat attribute is not part of the identity

internal/live/identity/refusals.go registers every refusal this pass can produce, each with a one-line description, and TestRefusalsRegistered fails if a new one is added without describing it there. It is the list the code enforces.

Your modules#

A marker binds to a configuration address and stays correct as long as that address stays stable. That one test decides which module forms work.

A plain module "app" {} call with neither count nor for_each is traversed like the root module. A resource inside binds by its module-qualified address, module.app.aws_x.y or module.a.module.b.aws_x.y at any depth.

for_each on a module call works when every key is evaluable from configuration alone. A key you chose does not move when a sibling appears or goes, so module.app["prod"] survives whatever happens to module.app["staging"]. Keys follow the same marker-safe character and length rules as a resource’s own for_each key, because the key becomes part of every address inside the module.

count on a module call is admitted when the count is statically evaluable and any count.index in the call’s own arguments is provably injective - a bare count.index, a template such as "n-${count.index}", or an arithmetic offset - (#195). Resolution traverses each instance, and module.app[0].aws_x.y binds exactly as soundly as module.app.aws_x.y does. Shrinking a count retires the highest index and never renumbers a survivor, which is what makes the address stable.

Two shapes are still refused: a count this pass cannot evaluate at all, as non-static, and a statically-evaluable count whose own arguments read count.index in a shape this pass cannot prove injective - typically an index into a sibling’s count-expanded collection, such as var.names[count.index]. Unlike the same shape inside a resource’s own body, a module call’s own arguments are never rendered against real instance values, so an index is refused here even when the values it would render happen to differ; a module has no identity schema of its own to narrow the check with, so any unprovable shape anywhere in the call’s arguments refuses it.

Stamping keeps up, and since #644 it needs no special case for a keyed call at all: markers are written per instance, during the plan walk, from the instance’s own address. module.sites[0] and module.sites[1] carry their own addresses because they are two instances with two addresses, not because anything rendered a template.

A resource inside a for_each’d module needs its own marker built by hand from the module’s own key; see How to write markers inside a for_each’d module.

Crossing a module boundary#

A marker carries the full module-qualified address, escaped into a tag value per live/MARKERS.md ([ becomes :; ] and " are dropped). choudoufu live-mv handles those like any root address. Flattening a module into the root, moving a resource into a module, and renaming across two module instances are ordinary renames. A step through a count-keyed module instance is one too, since #317 retired the premise that a module count renumbers its survivors. What live-mv refuses is the pair of addresses that describes no move: the same address twice, two different resource types, and anything that is not a managed resource.

choudoufu live-import traverses every managed resource instance in the whole state - root and child modules alike. #59’s module epic gave the other walkers (identity, discovery, stamp, projection, mv) real traversal and this one matches them, so a resource’s tofu-address marker carries its full module path exactly as an ordinary plan and apply would write it.

The one piece #59 left for later is provider aliasing that crosses a module boundary. A module inheriting its caller’s provider, the overwhelmingly common case, is unaffected.

Your accounts and regions#

An estate can span provider configurations. One provider "aws" block per account or region, each with its own assume_role, resources pinned with the provider meta-argument. Admitted, and proven end to end against the emulator.

One bound. Resources needing marker discovery must share a single provider configuration. The line runs through how identity is recovered rather than through which account a resource sits in.

Client-named types span freely. An S3 bucket, an IAM role, a log group. Their identity is already in your code, so nothing goes looking for them and any provider configuration can manage them.

Server-assigned types share one. A VPC, a subnet, a security group, a KMS key. AWS assigns their identity and choudoufu recovers it by reading markers back, so a list issued against the wrong account or region reports the estate as missing rather than unreachable. Spanning configurations with these is refused, naming the configurations involved.

Split the configuration so discovery-needing resources share one provider configuration, and run them separately. -target does not help, because the check runs over the whole configuration during discovery, before any target filter applies.

This is where the mode stands today rather than a permanent boundary. The multi-pass machinery already exists.

A module call’s providers mapping is honoured, and used to be refused. Since #188 internal/live/providerscope walks every ancestor call’s mapping, the same resolution stock OpenTofu performs, and planning, discovery and the projection all read a resource’s provider configuration through it. So providers = { aws = aws.useast1 } plans and applies against the account or region it names. The one mapping still refused is the child-side configuration_aliases shape, providers = { aws.primary = aws }, and only when the root declares no configuration under that same alias name.

One consequence of spanning configurations remains. An adoption hint’s --region and --endpoint-url can name the wrong region for a resource found under a different configuration. The printed command is wrong, not the plan. Check the region before pasting it.

How you run it#

An acceptable configuration can still be refused by how it is invoked.

  • A backend "s3" {} or cloud {} block is refused. There is no state to store.
  • Any workspace other than default is refused, and so are workspace new and workspace select.
  • Every tofu state subcommand is refused, including read-only state list and state show.
  • Four commands are refused: import, refresh, taint, untaint.
  • A saved plan works, in the stock form, with one difference that matters: plan -out=FILE writes stock’s own plan file, and apply FILE re-reads the live system and plans against it, then compares that fresh plan with the one the file describes. Same resources, same actions, same live objects, same planned values - compared canonically, with unknown (“known after apply”) attributes skipped and sensitive values compared as a stable digest rather than in plaintext - and it applies without re-prompting, exactly as stock does. Different, and it refuses - The approved plan no longer matches the live system - and exits 3, so a pipeline can route it back to review rather than treat it as a broken run. This is how most CI runs Terraform, and it is why the file is an approval rather than an instruction: it is never prior state, and it is never consulted for ownership. #878. live-plan -estate=NAME -out stays refused: that directory has no live block, so plain apply in it is an ordinary state-backed command.
  • The -json and -json-into flags are refused.
  • A -refresh-only run is refused: both sides of that comparison are the live system here, so there is nothing for it to do.
  • Four more flags are refused: -state, -state-out, -backup, -generate-config-out.

apply -destroy (and choudoufu destroy) work. They remove every object this estate owns, in one apply, the same way apply after deleting every resource block already did - see #320.

Constructs this page used to refuse, and no longer does#

This section was a list of six constructs refused outright. All six have since been admitted, five of them conditionally, and the conditions are worth reading because each one is met by an ordinary estate.

  • provisioner "local-exec", "remote-exec" and "file", and resource-level connection blocks. Admitted since #353. Stock keeps one piece of memory about a provisioner, the tainted bit set when a create-time provisioner fails, and the estate’s record store is where that bit lives here. A live block implies a record store, so this needs no extra declaration. Nothing about the provisioner’s content is stored, exactly as stock stores nothing: changing a local-exec’s command between runs produces an empty plan either way.
  • data "terraform_remote_state". Read from its own backend before resolution needs it. Its own arguments (backend, config, workspace) must be statically evaluable, the same rule any data source’s arguments draw, and the backend’s reachability and credentials are treated as a fact about the run rather than about the configuration. The real limitation is staleness, not refusal: once a producer estate adopts markers it stops writing that state file, and a reader pointed at it keeps returning a snapshot frozen at migration time, with nothing on either side able to detect it. live/OUTPUTS.md’s cross-estate pattern is the answer to that.
  • moved blocks. Admitted. Two shapes are refused, and both are ways the alias would be built wrong rather than untidiness: a source address the configuration still declares, which stock refuses too as “Moved object still exists”, and a pair whose two ends name different resource types. An endpoint passing through a count-expanded module instance is admitted since #330, and so is one through a count-expanded resource.
  • random_password, random_bytes and the tls_* family. Admitted under the default. This is a setting, not a ban: what remembers a generated secret on stock OpenTofu is the state file, in clear, and what remembers it here is the estate’s record store. Under strict { secrets = "store" }, the default, they run the stock lifecycle. Under strict { secrets = "refuse" } they are refused, which is what that setting is for. The record store is not a secret manager, and an estate that must hold no secret material at all is the case the toggle exists to serve.
  • local_file and local_sensitive_file. Both admitted, local_file outright and local_sensitive_file under the same secrets default as above. local_file keeps one rule the record-backed types do not: its filename names a real file on the machine that ran the apply, so two instances at distinct addresses can still collide on one path, and the count.index check keeps running over its arguments.
  • module { count = ... }. Admitted when the count is statically evaluable and any count.index in the call’s own arguments is provably injective; an unprovable shape, typically an index into a sibling’s collection, is refused. See Your modules above.

One construct in the same family genuinely is refused, and it has its own section below: a module call’s child-side providers mapping, providers = { aws.primary = aws }, where the root declares no configuration under that alias name.

Effects do work#

null_resource, terraform_data, time_* and the random_* family are admitted. They run the stock provider lifecycle exactly as upstream, against a record in the estate’s record store. An older refusal message called them unsupported. They are not.

Nothing has to be turned on for that: a live block with no record_store block of its own gets an implied local one, a .tofu-records directory beside the module, the way stock implies a local state file. Declaring an estate is the whole setup step. Name a record_store to put the records somewhere a team shares instead.

# estate.chdf.hcl
estate = "my-estate"

record_store "ssm" {}

The label picks the backend, one of local, ssm or s3. Where things are stored has the arguments.

Two hazards that are now refusals#

Both used to fail silently. Lint now refuses them with a message naming the fix.

lifecycle { ignore_changes = [tags] } would defeat ownership markers. The stamp pass adds tofu-estate and tofu-address to the resource’s tags, and a plan ignoring tag changes discards the markers before they are written. The resource would never be marked, and the next plan would propose another one. Lint refuses ignore_changes = all, ignore_changes = [tags], and any entry naming a marker key (#103). Ignoring a tag key of your own, such as tags["Owner"], stays admitted.

A module call’s child-side providers mapping can name an alias nothing resolves. #104 opened this as a refusal of both shapes a mapping’s alias can take, because nothing in the live path read the mapping at all. #188 closed that for the parent-side shape: providers = { aws = aws.useast1 } is now resolved by internal/live/providerscope and honoured, which is every one of the 110 sites the corpus had ever produced for this rule. What stays refused is the configuration_aliases shape, providers = { aws.primary = aws }: the alias is on the child side, and the root declares no configuration under that name for the module’s resources to resolve against. The provider would be configured from the environment with nothing from the configuration reaching it. providers = { aws = aws } is admitted, naming what live mode already does, and so is { myaws = aws }, where only the child’s local name differs. Root-level provider aliases work correctly, and a resource’s own provider = argument is honoured.

A provider block inside a child module is a different question, and it is admitted. #70 originally refused every one of them, on the measurement that none of the ten most-installed shared AWS modules declares one and that upstream calls the pattern legacy. The corpus then found a real site using exactly that shape, and since #201 live mode walks to a module’s own provider block and honours it rather than falling back to the root. The one shape the rule still names is a module-local provider block reached through a call using count, for_each, enabled or depends_on. OpenTofu’s own configuration validation rejects it before lint ever runs in this fork and in stock alike.

Editors and linters#

The estate.chdf.hcl sidecar exists for this concern (#72). It holds the live configuration in a file whose extension stock tooling never reads, so every .tf file stays free of non-standard syntax and stock terraform validate, tflint and editors keep passing.

The in-terraform live block is the form that costs you. Stock Terraform and stock OpenTofu reject a configuration containing one.

Error: Unsupported block type

  on main.tf line 6, in terraform:
   6:   live {

Blocks of type "live" are not expected here.

Expected. live is this fork’s addition to the terraform block schema and nothing signals it to a tool that never heard of it. Any tool validating against upstream’s schema behaves the same way, tflint included, since it decodes HCL through OpenTofu’s own libraries. Tools that only tokenize HCL, including most highlighters and formatters, are unaffected.

Teams keeping the in-block form have three options. Run choudoufu validate in CI instead of stock terraform validate, keep the live block in a small root module stock tooling never touches, or move its content into the sidecar, which is one file and zero edited lines. Declaring both forms at once is an error.

For how the type and refusal counts on this page are measured, and what not to read into them, see How the compatibility numbers are measured.