Feature Specification: Nested Resource Generation

Created: 2026-09-22

Status: Implemented

Input: Reconstructed from the code at 3c0765d, the nix-unit suite, the commit and pull request history of nix-tf-schema, and the Claude Code logs.

User Scenarios & Testing (mandatory)

User Story 1 - Type a resource with nested blocks (Priority: P1)

A maintainer has a resource schema with nested objects, in the terraform-plugin-sdk/v2 dialect (block_types), the terraform-plugin-framework dialect (nested_type), or both. The maintainer gets one Nix option per settable top-level attribute or block, with nested submodules.

Why this priority: most real resources have nested blocks. The flat helpers of 001-flat-schema-conversion cannot type them.

Independent Test: convert an sdk/v2 resource with a plural block and a singleton block (max_items = 1), and a framework resource with a single and a list nested attribute. Cast a configuration against the options. The plural block and the framework list take a list of objects; the singleton and the framework single take one object.

Acceptance Scenarios:

  1. Given a block with nesting single or group, When the maintainer converts it, Then the type is a submodule.
  2. Given a block with nesting list or set, When the maintainer converts it, Then the type is listOf a submodule; with map, Then it is attrsOf a submodule.
  3. Given an sdk/v2 block with max_items = 1, When the maintainer converts it, Then the type is one submodule, not a list.
  4. Given a block that is not required, When the maintainer converts it, Then the option is nullable with default = null.
  5. Given an attribute that is computed and not optional, at any depth, When the maintainer converts the resource, Then it is absent.

User Story 2 - Generate the variables and the resource body (Priority: P1)

A maintainer generates the Terraform module for a resource: one variable per top-level node, and a resource body that reads each variable. Values come in at run time (for example as TF_VAR_*).

Why this priority: Terraform cannot fill an sdk/v2 block from a variable directly. Without generated dynamic blocks, the maintainer must write them by hand for each resource.

Independent Test: apply a generated module with the hashicorp/tls provider and a plural block, a singleton block, and an unset block, with the values in TF_VAR_*. The plural value reaches the resource as a list, the singleton as a one-element list, and the unset block is absent.

Acceptance Scenarios:

  1. Given a plain attribute, When the maintainer generates the body, Then the value is ${var.<name>}, and the variable has the HCL type constraint of the attribute (dynamic as any), so that Terraform reads a collection in the TF_VAR_* string as the collection.
  2. Given an sdk/v2 block, When the maintainer generates the body, Then the body has a dynamic block that iterates the variable, an empty list when the variable is null, and a one-element list for a singleton.
  3. Given a map block, When the maintainer generates the body, Then the dynamic block has labels from the map key.
  4. Given a framework nested attribute, When the maintainer generates the body, Then the value is assigned directly as ${var.<name>}, without a dynamic block.
  5. Given a nested block, When the maintainer generates the variables, Then the variable has an HCL type constraint, with optional(...) for the optional attributes of objects, so that Terraform reads the TF_VAR_* string as an object.
  6. Given a schema without nested blocks, When the maintainer generates variables and body, Then the result is the same as from the flat helpers of 001-flat-schema-conversion.

User Story 3 - Read the settable surface as a tree or a flat list (Priority: P2)

A consumer (for example a form generator or a diff tool) wants the settable surface of a resource without the Nix types. It gets a tree of nodes, or a flat map keyed by dotted path.

Why this priority: other renderers than the module system can use the same walk.

Independent Test: walk an sdk/v2 resource with a singleton block smtp_server with a required host. The flat map has smtp_server (a block with singleBlock = true) and smtp_server.host (an attribute).

Acceptance Scenarios:

  1. Given any node, When a consumer reads it, Then it has the keys path, kind, tfType, required, optional, sensitive, description, nesting, singleBlock and blockSyntax, in both dialects.
  2. Given a sensitive attribute nested in a block, When a consumer reads the flat map, Then the attribute is present with sensitive = true.

Edge Cases

  • A schema description that is null (Terraform emits it for blocks without documentation) becomes the empty string.
  • A set block is typed as a list. The order is not normalized and duplicates are not rejected.
  • An sdk/v2 resource and a framework attribute in one resource (for example incus_instance) use the same walk.
  • There is no way to say "explicitly null" as different from "unset". The README records this as a known limitation that does not matter for the generated output.
  • A failure in the walk names the path in the schema through [nix-tf-schema] error contexts (constitution principle V). On success the contexts do not change the output.

Requirements (mandatory)

Functional Requirements

  • FR-001: The library MUST walk one resource schema into a tree of settable nodes that covers both schema dialects with one node shape.
  • FR-002: The walk MUST drop attributes that are computed and not optional, at every depth.
  • FR-003: The library MUST convert the tree to Nix options, typed per nesting mode as in User Story 1.
  • FR-004: The library MUST generate one Terraform variable per top-level node, with default = null for a node that is not required, and with a type from the schema for every node, so that a value that arrives as a string at run time (for example through TF_VAR_*) gets its Terraform type.
  • FR-005: The library MUST render HCL type constraints for all Terraform types, including object with optional(...), tuple, and dynamic as any.
  • FR-006: The library MUST generate a resource body that reads each variable, with dynamic blocks for sdk/v2 blocks and direct assignment for framework nested attributes.
  • FR-007: An unset optional block MUST render as no block.
  • FR-008: The library MUST give the settable surface as a flat map keyed by dotted path.
  • FR-009: Every recursion step MUST carry a [nix-tf-schema] error context that names the path.

Key Entities

  • Node: one settable attribute or block of a resource, with path, kind (attr or block), tfType, the flags, nesting, singleBlock (sdk/v2 singleton) and blockSyntax (sdk/v2 block, which needs a dynamic block); a block has children.
  • Resource body: the attribute set under resource.<type>.<name> in Terraform JSON.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: The settableTree, settablePaths, nodeTo*, resourceTo* and tfTypeConstraint cases of the nix-unit suite pass in CI, including the case that applies a generated module with tofu.
  • SC-002: For a flat schema, the nested generators give output equal to the flat helpers (tested).

Known Deviations

None.

Assumptions

  • Values reach Terraform as variables at run time. How they get there (environment variables, a tfvars file) is the consumer's choice.
  • Terraform treats an argument set to null as omitted.