Feature Specification: Module Authoring Primitives

Created: 2026-09-22

Status: Implemented

Input: Reconstructed from the code at 3c0765d, the nix-unit suite, the README, the comparison page, the commit and pull request history of nix-tf-schema (in particular the tofunix cherry-picks), and the Claude Code logs.

User Scenarios & Testing (mandatory)

User Story 1 - Assemble a Terraform module as JSON (Priority: P1)

A maintainer has provider requirements, variable names, and hand-written resource, data and output blocks. The maintainer gets one attribute set that builtins.toJSON turns into a main.tf.json.

Why this priority: it is the last step between the generated parts and a module that tofu can run.

Independent Test: assemble a module with one provider, the variables name and slug, one resource and one output. The JSON has terraform.required_providers, a variable entry per name, the resource and the output. Assemble it again with a data block; the JSON has it.

Acceptance Scenarios:

  1. Given a list of variable names, When the maintainer assembles the module, Then each name has an empty variable entry; Given an attribute set of variables (for example from 002-nested-resource-generation), Then it is used as it is.
  2. Given no data or an empty data, When the maintainer assembles the module, Then the JSON has no data key.
  3. Given no backend, When the maintainer assembles the module, Then the JSON has no terraform.backend; Given a backend such as { http = { }; }, Then it is terraform.backend.

User Story 2 - Refer to other objects in hand-written blocks (Priority: P1)

A maintainer writes a data block, an output, or an extra resource attribute that points at another object. The maintainer builds the reference with a function chain (ref "type" "name" "attr", ref.var "x", ref.data ...), not with a hand-written ${...} string, and a module type accepts either a literal or a reference where the maintainer wants that.

Why this priority: hand-written reference strings are easy to get wrong and a typo is found only at plan time.

Independent Test: render ref "a" "b" "c", ref.data "a" "b" "c", a splat "*", an integer index and a ref.count "index" index. The strings are ${a.b.c}, ${data.a.b.c}, ${a.b[*].c}, ${a.b[0].c} and ${a.b[count.index].c}.

Acceptance Scenarios:

  1. Given any prefix of a reference chain, When the maintainer renders it, Then it is a valid reference; there is no terminator.
  2. Given the reference type, When a value is a bare string without ${...} or a string with only part of a splice, Then the type rejects it.
  3. Given a reference chain as the value of the reference type, When the module system merges it, Then the value is the rendered string, so builtins.toJSON does not see an attribute set.
  4. Given refOr int, When the value is 3 or a reference, Then the type accepts both.
  5. Given a reference, When the maintainer removes the splice, Then the result is the bare expression; a literal stays as it is.

User Story 3 - Set resource meta-arguments (Priority: P2)

A maintainer sets count, for_each, depends_on, provider or lifecycle (with create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, precondition, postcondition) on a resource. The maintainer gets typed options for them and a body fragment to merge over the schema-derived resource body.

Why this priority: Terraform resolves these before it knows variables, so they cannot go through the variable path of 002-nested-resource-generation.

Independent Test: render count = 2, depends_on = [ (ref "a" "b") ] and lifecycle.prevent_destroy = true. The fragment is count = 2, depends_on = [ "a.b" ] and lifecycle.prevent_destroy = true, and nothing else.

Acceptance Scenarios:

  1. Given no meta-argument values, When the maintainer renders them, Then the fragment is empty.
  2. Given a reference in depends_on or replace_triggered_by, When the maintainer renders it, Then it has no ${...}, as Terraform requires.
  3. Given the meta-argument options, When the maintainer generates the variables of a resource, Then no meta-argument is a variable.
  4. Given the fragment and a schema-derived body, When the maintainer merges them, Then no key collides.

Edge Cases

  • A schema-derived option does not accept a reference (constitution principle III). A reference placed in a variable value reaches the plan as a literal string.
  • A reference is not checked against a schema. Terraform checks it at plan time.
  • count also accepts a reference, so a count can come from a variable or another resource.

Requirements (mandatory)

Functional Requirements

  • FR-001: The library MUST assemble a Terraform module with terraform.required_providers, variable, optional data, resource and output.
  • FR-002: The library MUST build reference expressions by function application, with the Terraform scopes var, local, data, module, each, count, self, path and terraform as attributes.
  • FR-003: A string segment MUST render as a dotted attribute; an integer, a nested reference or "*" MUST render as a bracket index.
  • FR-004: The library MUST give a module type for reference expressions that merges to a string, and a helper that widens any type to also accept a reference.
  • FR-005: The library MUST give options for all resource meta-arguments, each with a null default that means "do not emit".
  • FR-006: The library MUST render meta-argument values into a resource body fragment and drop unset values.
  • FR-007: The module assembly MUST take the state backend as an optional parameter. When the caller does not give a backend, the module MUST NOT have terraform.backend. The choice of a backend belongs to the consumer (constitution principles I and II).

Key Entities

  • Reference: a Terraform expression spliced as ${...}, built as a chain that is both callable and renderable.
  • Module JSON: the attribute set that becomes main.tf.json.
  • Meta-arguments: the resource arguments that Terraform handles, not the provider.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: The mkTfModuleJson, ref, referenceType, refOr, unbrace and metaArg* cases of the nix-unit suite pass in CI.

Known Deviations

None.

Assumptions

  • The module JSON uses the Terraform JSON syntax, where references are strings with ${...} and depends_on takes bare references.