terraform validate failed on nearly every multi-file run with the same error class:
Error: Reference to undeclared input variable
on iam.tf line 14, in resource "aws_iam_policy" "app_access":
14: Environment = var.environment
An input variable with the name "environment" has not been declared.
This variable can be declared with a variable "environment" {} block.
Same error on var.project_name. Same error on var.region. Same error on var.account_id. Four names, referenced across storage.tf and iam.tf and database.tf, declared nowhere.
I had not written any of those references. An LLM had, because I told it to.
The pipeline, briefly
TerraformAgent is a 6-node LangGraph pipeline that turns a natural-language infrastructure request into validated Terraform: orchestrator → researcher → domain_subagents → aggregator → reviewer → evaluator, with a conditional edge from evaluator back to domain_subagents for targeted retries. The orchestrator picks domains deterministically, keyword-matching the task and mapping services through a static table, with security always included. Each domain subagent generates its own files in parallel, the aggregator merges them into one directory, and the evaluator runs real terraform fmt and terraform validate against a provider cache baked into the container image.
The line that did it
Every domain subagent prompt carried this requirements block:
Requirements:
- Use proper HCL2 syntax
- Add descriptive comments for each resource
- Follow AWS best practices (tags, encryption, least privilege)
- No hardcoded secrets — use variables or data sources
- Reference other domains' resources by the conventional names given in the Research Context
Line three is the problem. "Follow AWS best practices (tags, encryption, least privilege)."
The subagents complied. Tagging a resource properly means an Environment tag and a Project tag, and any competent Terraform author reaches for var.environment and var.project_name to fill them. IAM policies need an account ID and a region in the ARN, so var.account_id and var.region showed up too. The model produced exactly the code a human would have written.
A human would also have written the variable blocks. The model could not, because of where it sits in the pipeline.
Why the architecture hid it
A single-file generator never hits this. It declares the variable it uses, in the file it is writing, in the same pass.
Generation here is split across parallel subagents that each own one domain's files:
DOMAIN_FILES = {
"storage": ["storage.tf"],
"database": ["database.tf"],
"security": ["iam.tf"],
...
}
The storage subagent owns storage.tf. The security subagent owns iam.tf. A variable "environment" block belongs to neither. It belongs to the merged directory, and no subagent knows the merged directory exists.
So every subagent's output was individually valid HCL. The aggregated directory was not. Splitting generation across agents created a shared namespace that no agent owned, and the failure lived entirely in that gap.
Fix, part one: something above the subagents declares it
There was already a precedent I had not recognized as a category. versions.tf had this exact shape: a file no domain owns, generated once by the orchestrator, merged in by the aggregator.
def render_versions_tf() -> str:
"""Render the shared versions.tf content from the known provider list.
Generated once by the Orchestrator and merged in by the Aggregator, so
no domain subagent needs to emit its own required_providers block.
"""
Variables belong in the same bucket. So render_variables_tf(), following that pattern:
def render_variables_tf() -> str:
"""Render the shared variables.tf content every domain subagent is told
to use for tagging (var.environment, var.project_name) plus the
aws_caller_identity/aws_region data sources for account ID and region.
Domain subagent prompts are instructed to follow AWS tagging best
practices, which reliably produces references to these exact names —
but no domain subagent declares them. Generated once by the
Orchestrator and merged in by the Aggregator (same pattern as
versions.tf), so `terraform validate` always finds them declared.
"""
return """variable "environment" {
description = "Deployment environment name (e.g. dev, staging, prod)"
type = string
default = "dev"
}
variable "project_name" {
description = "Project name used for resource naming and tagging"
type = string
default = "terraform-agent"
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
"""
Both variables carry defaults. That is not cosmetic. The evaluator runs terraform validate with no tfvars file, so a variable without a default would trade one failure for another.
The orchestrator generates it alongside versions.tf:
def orchestrator_node(state: dict) -> dict:
task = state.get("task", "")
services = detect_aws_services(task)
active_domains = sorted({service_to_domain(s) for s in services} | {"security"})
return {
"active_domains": active_domains,
"shared_versions_file": render_versions_tf(),
"shared_variables_file": render_variables_tf(),
"status": AgentStatus.RESEARCHING,
"current_node": "researcher",
}
And the aggregator picks it up. Before:
merged = {
"versions.tf": state.get("shared_versions_file", ""),
}
for domain_files in state.get("domain_outputs", {}).values():
merged.update(domain_files)
After:
merged = {
"versions.tf": state.get("shared_versions_file", ""),
"variables.tf": state.get("shared_variables_file", ""),
}
for domain_files in state.get("domain_outputs", {}).values():
merged.update(domain_files)
Fix, part two: two of the four symbols stopped existing
My instinct was to declare all four names. That instinct was half wrong.
Account ID and region are not deployment inputs. They are facts about the credentials and provider config the run already has. Terraform will tell you both without being asked:
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
So var.account_id and var.region did not get declarations. They stopped being referenced. Two of the four undeclared symbols went away by removing the need for the symbol rather than satisfying it.
This is the part I keep coming back to. When something references a name that does not exist, declaring the name is the obvious move and often the worse one. Half this bug was solved by shrinking the namespace instead of filling it.
Fix, part three: the prompt reads the declarations instead of describing them
The first version of this fix hand-wrote the scope into the prompt string:
A shared variables.tf is already provided (do not redeclare it). It gives you:
- var.environment (string, default "dev")
- var.project_name (string, default "terraform-agent")
- data.aws_caller_identity.current.account_id — use this, NOT var.account_id
- data.aws_region.current.name — use this, NOT var.region
Do not reference any other var.* name — anything else will fail terraform validate
because it will not be declared anywhere.
That worked and it was wrong. Two places now knew the list of shared symbols: render_variables_tf(), which declares them, and this string, which describes them. Nothing kept the two in agreement. Add a variable to the renderer and forget the prompt, and subagents never use it. Remove one from the renderer and leave it in the prompt, and you are back to undeclared references and the identical validate failure.
So the prompt stopped describing the scope and started parsing it. parse_shared_scope() pulls the declared names out of the rendered variables.tf:
_VARIABLE_BLOCK_RE = re.compile(r'variable\s+"([^"]+)"\s*\{([^}]*)\}', re.DOTALL)
_DATA_BLOCK_RE = re.compile(r'data\s+"([^"]+)"\s+"([^"]+)"\s*\{')
_DEFAULT_ATTR_RE = re.compile(r'default\s*=\s*"?([^"\n]*)"?')
def parse_shared_scope(variables_tf: str) -> List[Dict[str, str]]:
if not variables_tf:
return []
entries: List[Dict[str, str]] = []
for name, body in _VARIABLE_BLOCK_RE.findall(variables_tf):
default_match = _DEFAULT_ATTR_RE.search(body)
default_value = default_match.group(1).strip() if default_match else ""
entries.append({"kind": "variable", "name": name, "default": default_value})
for data_type, data_name in _DATA_BLOCK_RE.findall(variables_tf):
entries.append({"kind": "data", "type": data_type, "name": data_name})
return entries
And render_domain_scope_note() builds the prompt text from that parse:
def render_domain_scope_note(variables_tf: str) -> str:
scope = parse_shared_scope(variables_tf)
if not scope:
return ""
lines = [
"A shared variables.tf is already provided (do not redeclare it). "
"It gives you:"
]
for entry in scope:
if entry["kind"] == "variable":
default_note = f' (default "{entry["default"]}")' if entry["default"] else ""
lines.append(f' - var.{entry["name"]}{default_note}')
else:
attr = _DATA_SOURCE_PRIMARY_ATTRIBUTE.get(entry["type"])
ref = f'data.{entry["type"]}.{entry["name"]}' + (f".{attr}" if attr else "")
lines.append(f" - {ref}")
declared_var_names = ", ".join(
f'var.{e["name"]}' for e in scope if e["kind"] == "variable"
)
lines.append(
f" Do not reference any var.* name other than {declared_var_names or '(none declared)'} "
"— anything else will fail terraform validate because it will not be declared anywhere."
)
return "\n".join(lines)
The subagent prompt is now a single interpolation:
def _domain_prompt(domain: str, state: dict, feedback: str) -> str:
files = ", ".join(DOMAIN_FILES[domain])
feedback_str = f"\n[RETRY] Previous Reviewer/Evaluator feedback to fix: {feedback}" if feedback else ""
scope_note = render_domain_scope_note(state.get("shared_variables_file", ""))
return f"""You are an expert DevOps Coder responsible for the '{domain}' domain of this infrastructure request.
...
{scope_note}
One thing does not survive the parse. Which attribute you actually want off a data source is provider schema knowledge, not something the HCL declaration states, so that stays a lookup:
_DATA_SOURCE_PRIMARY_ATTRIBUTE: Dict[str, str] = {
"aws_caller_identity": "account_id",
"aws_region": "name",
}
A data source with no entry here still gets listed in the scope note, just without the attribute hint. That is the one piece of the contract still maintained by hand, and it degrades quietly rather than producing invalid code.
Stating the consequence matters too. "Do not reference other variables" gets partial compliance. "It will not be declared anywhere and validate will fail" gets better compliance, because the model can reason about the outcome.
The second bug, same evaluator run
The reviewer flagged scope creep in the same pass. Subagents were emitting IAM roles, custom policies, and KMS keys nobody had asked for. A request for a DynamoDB table came back with a customer-managed KMS key and a key policy attached to it.
Same source line. "Least privilege" and "encryption" read as instructions to build IAM resources and encryption resources, not as constraints on the resources actually requested. The subagent has no notion of scope beyond what the prompt gives it, and the prompt gave it a mandate.
The requirements block now carries a boundary:
- Build ONLY the resources this request needs. Do not add IAM roles, policies,
KMS keys, or other supporting resources unless the request requires them or
the specific resource you're creating cannot function without them (e.g. an
EC2 instance needing a security group). When AWS provides a sensible default
(e.g. DynamoDB's AWS-owned encryption key), use the default instead of
creating a custom resource for it.
One vague instruction, two unrelated failure modes. Undeclared references and unrequested resources have nothing in common except the line that caused them.
Verification
I reproduced the error before fixing it. A DynamoDB table plus an IAM policy referencing all four names, written to a real directory, run through real terraform init -backend=false and terraform validate. Fails without variables.tf. Passes with it.
Tests at three levels. Renderer content in test_tools.py:
class TestRenderVariablesTf:
def test_declares_environment_and_project_name(self):
content = render_variables_tf()
assert 'variable "environment"' in content
assert 'variable "project_name"' in content
def test_variables_have_defaults(self):
content = render_variables_tf()
# Every declared variable must have a default so subagent-generated
# code that references it validates with no tfvars supplied.
assert content.count('variable "') == content.count("default")
The drift regression test, which is the one that made part three worth building. Append a variable to the rendered output and assert the prompt mentions it, with no hand-written change anywhere:
def test_scope_note_derived_from_same_parse_as_variables_tf(self):
"""If a variable is added to render_variables_tf(), the scope note
must mention it automatically with no hand-written change needed —
this is the actual regression test for the prompt/renderer drift bug."""
synthetic_variables_tf = render_variables_tf() + '\nvariable "extra_flag" {\n default = "x"\n}\n'
note = render_domain_scope_note(synthetic_variables_tf)
assert "var.extra_flag" in note
And the wiring, in test_nodes.py, asserting the prompt reflects state and does not carry a stale scope block when state is empty:
class TestDomainPrompt:
def test_scope_note_reflects_state_variables_file(self):
state = {"task": "x", "shared_variables_file": render_variables_tf()}
prompt = _domain_prompt("storage", state, feedback="")
assert "var.environment" in prompt
assert "data.aws_caller_identity.current.account_id" in prompt
def test_missing_variables_file_produces_no_stale_scope_note(self):
state = {"task": "x"}
prompt = _domain_prompt("storage", state, feedback="")
assert "var.environment" not in prompt
That second one matters more than it looks. If shared_variables_file is missing from state, the scope note is empty rather than advertising symbols that were never merged in.
The evaluator caught this, not me
Worth stating plainly. Nobody read the generated code and noticed a missing declaration. The evaluator ran terraform validate and the run failed with a file, a line number, and a symbol name.
A generator without a real validation gate ships confidently wrong output. The HCL was well-formed, commented, and idiomatic. It looked correct in review. An LLM judge asked "is this good Terraform?" would have said yes, because it is good Terraform, apart from being invalid. The reason I can describe this failure to the exact line is that something mechanical ran the actual tool.
When you split code generation across parallel agents, any symbol that crosses an agent boundary has to be produced by something that sits above all of them, and described to them by reading what it produced.


Top comments (0)