# ACDL Module Engineering Standards

Standards for authoring and reviewing ACDL modules. These standards
govern the two module tiers — **L1 primitives** (single cloud resource
or small group of related resources) and **L2 modules** (compositions
that reference L1 primitives to deploy a complete stack) — and the
engine adapter that compiles them to Terraform. They are written for
**platform engineers** and **AI agents** that author or review new
modules against the existing corpus (12 L1 primitives and 2 L2 modules
shipped in v1.8).

A module that fails any section below is not ready to publish.

## 1. Overview

These standards codify the conventions already established by the
shipped modules (`s3`, `vpc`, `ecs-cluster`, `ecs-service`, `iam-role`,
`alb`, `ecr`, `cloudfront`, `waf`, `rds`, `kms-key`, `uptime`; the L2
modules `static-assets` and `microservice`). They exist so that:

- platform engineers can review a new module against a fixed checklist;
- AI agents authoring modules produce code that passes review without
  iteration; and
- the engine adapter (`adapters/terraform/adapter.py`) can compile a
  module instance with no module-specific code in the adapter beyond the
  three tables in §8.

When this document and an existing module disagree, the existing module
is the authority for v1.x. A change to this document is a MINOR version
bump of the standards; a change that breaks shipped modules is a MAJOR
bump and requires a migration plan.

## 2. L1 Primitive Standards

An L1 primitive is a single cloud resource or a small group of related
resources (e.g. a VPC with subnets and a route table). It is declared by
an `interface.json` and realized by the engine adapter; it does not
own Terraform code.

### 2.1 Required files

Every L1 primitive MUST contain, at minimum:

| File | Purpose |
|------|---------|
| `interface.json` | Angine-agnostic declaration: inputs, outputs, NFRs, optional multi-resource graph. |
| `instance.json` | A concrete instance used as the adapter regression baseline. |
| `README.md` | Plain-language documentation following `README-TEMPLATE.md` (see §7). |
| `examples/simple.yml` | A minimal contract that uses the primitive with required inputs only. |
| `examples/complex.yml` | A contract that exercises optional inputs, NFRs, and (if applicable) the multi-resource graph. |

Directory layout:

```
modules/l1/<name>/
  interface.json
  instance.json
  README.md
  examples/
    simple.yml
    complex.yml
```

### 2.2 interface.json schema

`interface.json` MUST be a JSON object with the following required
fields:

| Field | Type | Constraint |
|-------|------|------------|
| `name` | string | `^[a-z][a-z0-9-]*$`; MUST match the module folder name. |
| `version` | string | Semver (`^\d+\d+\.\d+$`); MUST match the registry entry semver. |
| `kind` | string | Literal `"l1"`. |
| `type` | string | Stack type in `aws:<service>:<kind>` format (see §2.7). |
| `description` | string | One or two sentences in plain language; no Terraform jargon. |
| `inputs` | object | Keyed by input name; each value is an input declaration (§2.3). MAY be empty. |
| `outputs` | object | Keyed by output name; each value is an output declaration (§2.4). MAY be empty. |
| `nfrs` | object | Keyed by NFR name; each value is an NFR declaration (§2.5). MUST include `deletion_protection` and `encryption_enabled`. |

Optional fields for multi-resource primitives:

| Field | Type | Constraint |
|-------|------|------------|
| `resources` | array | One entry per distinct cloud resource; see §2.6. |
| `intra_refs` | array | Internal wiring between resources; see §2.6. |

A primitive that creates a single resource (e.g. `s3`, `iam-role`,
`rds`, `kms-key`) omits `resources` and `intra_refs`; its `type` field
is the single resource's stack type. A primitive that creates a small
group of related resources (e.g. `vpc`, `alb`, `cloudfront`) declares
`resources[]` with one entry per resource and `intra_refs[]` for the
internal wiring; its `type` field is the *primary* resource's stack
type.

### 2.3 Input declaration

Each entry in `inputs` is an object:

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `type` | string | yes | One of: `string`, `number`, `boolean`, `array`, `object`. |
| `description` | string | yes | Plain language; no Terraform jargon. |
| `required` | boolean | yes | `true` if the consumer MUST supply this input. |
| `default` | (any) | no | Present only when `required` is `false`. MUST match the declared `type`. |
| `enum` | array | no | Allowed values for `string`/`number` inputs (e.g. RDS `engine`). |

`region` is a required `string` input on every primitive that creates a
regional resource. Global resources (e.g. CloudFront) still declare
`region` because the provider region is used for child resources (the
OAC in the `cloudfront` case).

Every primitive that holds at-rest data MUST declare an optional
`kms_key_arn` input (`string`, `required: false`); see §4.

### 2.4 Output declaration

Each entry in `outputs` is an object:

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `type` | string | yes | `arn` for ARN outputs; `string` for all others. |
| `description` | string | yes | Plain language. |

Use `arn` (not `string`) for any output that returns an AWS ARN — the
adapter and policy engine key off the `arn` type to apply ARN-scoped
rules.

### 2.5 NFR declaration

Each entry in `nfrs` is an object:

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `type` | string | yes | One of: `string`, `number`, `boolean`. |
| `description` | string | yes | Plain language. |
| `default` | (any) | yes | MUST match the declared `type`. NFRs always have a default. |

Mandatory NFRs on every L1:

| NFR | Type | Default | Notes |
|-----|------|---------|-------|
| `deletion_protection` | boolean | `true` | See §5. |
| `encryption_enabled` | boolean | `true` | See §4. |

A primitive for which an NFR does not conceptually apply (e.g. an IAM
role has no at-rest data) still declares it with `default: true` and a
description noting the non-applicability, so the standards check and the
adapter emit logic stay uniform. The shipped `iam-role` primitive is the
reference for this case.

Additional NFRs are encouraged where they carry operational meaning
(e.g. `s3.versioning`, `rds.backup_retention_period`,
`kms-key.enable_rotation`, `vpc.flow_logs_encrypted`). Name them in
lowercase snake_case.

### 2.6 Multi-resource pattern

A primitive that creates more than one cloud resource (e.g. `vpc`
creates `aws_vpc` + `aws_subnet` + `aws_route_table`; `alb` creates
`aws_lb` + `aws_lb_target_group` + `aws_lb_listener`; `cloudfront`
creates `aws_cloudfront_distribution` +
`aws_cloudfront_origin_access_control`) declares a `resources` array.

Each `resources[]` entry:

| Field | Type | Notes |
|-------|------|-------|
| `type` | string | The resource's stack type (`aws:<service>:<kind>`). |
| `description` | string | Plain language. |
| `inputs` | array | Names (strings) of inputs from the top-level `inputs` object that this resource consumes. |
| `outputs` | array | Names (strings) of outputs from the top-level `outputs` object that this resource produces. |

The top-level `inputs`/`outputs` objects remain the single source of
truth; `resources[].inputs` and `resources[].outputs` are arrays of
*names* referencing those objects, not re-declarations.

`intra_refs[]` wires outputs of one resource to inputs of another
within the same primitive. Each entry:

| Field | Type | Notes |
|-------|------|-------|
| `from` | string | `<resource-type>.<output-name>` — the producing side. |
| `to` | string | `<resource-type>.<input-name>` — the consuming side. |

Reference: `cloudfront/interface.json` declares an intra-ref from
`aws:cloudfront:distribution.oac_id` to
`aws:cloudfront:originaccesscontrol.oac_id`; `vpc/interface.json`
declares intra-refs from the subnet and route table to the VPC's
`vpc_id`.

### 2.7 Naming and stack types

- Module folder names and `interface.json` `name` values MUST match
  `^[a-z][a-z0-9-]*$` (lowercase, hyphenated, leading letter). Examples:
  `s3`, `ecs-cluster`, `kms-key`, `iam-role`, `uptime`.
- Input and output names are lowercase snake_case.
- Stack types follow `aws:<service>:<kind>`:
  - `aws:s3:bucket`
  - `aws:ec2:vpc`, `aws:ec2:subnet`, `aws:ec2:routetable`
  - `aws:ecs:cluster`, `aws:ecs:task_definition`, `aws:ecs:service`,
    `aws:ecs:uptime-service`
  - `aws:iam:role`
  - `aws:elbv2:loadbalancer`, `aws:elbv2:listener`,
    `aws:elbv2:targetgroup`
  - `aws:ecr:repository`
  - `aws:cloudfront:distribution`, `aws:cloudfront:originaccesscontrol`
  - `aws:wafv2:webacl`
  - `aws:rds:instance`
  - `aws:kms:key`, `aws:kms:alias`
- The engine adapter is a **stateless assembler** (v1.11, D-098): it reads
  the registry, emits a root `main.tf` instantiating each L1 as
  `module "x" { source = "..." }` with resolved inputs and wired refs. There
  is no `TYPE_MAP` (deleted in the v1.11 stateless rewrite). A new stack
  type requires a `terraform/` dir in the L1 module + a registry entry with
  a `terraform_dir` field.

## 3. L2 Module Standards

An L2 module is a composition that references one or more L1 primitives
to deploy a complete stack (e.g. an ECS Fargate microservice, a static
asset site behind CloudFront + WAF). It is declared by a
`composition.json`; it does not own Terraform code and does not have an
`instance.json`.

### 3.1 Required files

| File | Purpose |
|------|---------|
| `composition.json` | The composition tree: children, wires, outputs, optional features. |
| `README.md` | Plain-language documentation following `README-TEMPLATE.md` (see §7). |
| `examples/simple.yml` | A minimal contract that uses the module with required inputs only. |
| `examples/complex.yml` | A contract that exercises optional inputs and feature flags. |

Directory layout:

```
modules/l2/<name>/
  composition.json
  README.md
  examples/
    simple.yml
    complex.yml
```

There is no `instance.json` for an L2 module — the L2 is deployed by
resolving the composition tree to L1 instances at compile time, not by
loading a pre-baked instance.

### 3.2 composition.json schema

`composition.json` MUST be a JSON object with the following fields:

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | `^[a-z][a-z0-9-]*$`; matches the module folder name. |
| `version` | string | yes | Semver; matches the registry entry. |
| `kind` | string | yes | Literal `"l2"`. |
| `depth` | integer | yes | Literal `1` in v1 (see §3.5). |
| `description` | string | yes | Plain language. |
| `children` | array | yes | One entry per referenced L1 module (§3.3). |
| `wires` | array | yes | Wires from contract inputs / child outputs to child inputs / stack outputs (§3.4). |
| `outputs` | array | yes | Wires from child outputs to stack outputs (§3.4). |
| `features` | object | no | Feature flags propagated to children by the resolver (§3.6). |

### 3.3 Children

Each `children[]` entry:

| Field | Type | Notes |
|-------|------|-------|
| `id` | string | The child id, unique within the composition. `^[a-z][a-z0-9-]*$`. The id is the local name used in wires (e.g. `vpc`, `cluster`, `kms`). |
| `module` | string | `<name>@<semver>` referencing a registered L1 module. |

Children MUST reference L1 modules registered in `registry.json` (see
§6). The referenced semver MUST exist in the registry. An L2 MUST NOT
reference another L2 (no L3 in v1; see §3.5).

Reference: `microservice/composition.json` declares seven children
(`vpc`, `cluster`, `ecr`, `roles`, `alb`, `service`, `kms`), each
referencing an L1 at `@1.0.0`.

### 3.4 Wire format

A wire is a JSON object `{"from": "<source>", "to": "<target>"}` with an
optional `default` field for contract-input wires.

Sources (the `from` side):

| Source form | Meaning |
|-------------|---------|
| `contract.inputs.<name>` | A value supplied by the consumer's contract YAML. |
| `<childId>.outputs.<name>` | An output produced by a child L1 module. |

Targets (the `to` side):

| Target form | Meaning |
|-------------|---------|
| `<childId>.inputs.<name>` | An input on a child L1 module. |
| `stack.outputs.<name>` | A value the L2 exposes as a stack output. |

Wires that source from `contract.inputs.<name>` MAY carry a `default`
value used when the consumer omits the input. Reference:
`microservice/composition.json` wires `contract.inputs.bucket_name` to
`vpc.inputs.cidr` with `default: "10.0.0.0/16"` (a historical quirk
preserved for regression).

The `outputs[]` array uses the same wire shape but its `to` is always
`stack.outputs.<name>` and its `from` is always
`<childId>.outputs.<name>`.

### 3.5 Maximum depth

`depth` is `1` for every L2 in v1. The composition tree is strictly L2
→ L1: an L2 may reference only L1 primitives, never another L2. There
is no L3 in v1. The stack schema permits `depth` up to 5 for forward
compatibility, but the v1 resolver and adapter only handle depth 1.

### 3.6 Feature flags

An L2 MAY declare a `features` object. Two flags are defined in v1:

| Flag | Type | Default | Effect |
|------|------|---------|--------|
| `deletion_protection` | boolean | `true` | When `true`, the resolver propagates `deletion_protection: true` to every child's NFRs. When `false`, children are deployed with `deletion_protection: false` (used by decommission; see §5). |
| `uptime_enabled` | boolean | `true` | When `true`, the uptime monitoring L1 is deployed after the L2 module in a separate terraform state. When `false`, the uptime deployment is skipped. |

Feature flags are propagated to children by the resolver; the L2
`composition.json` does not need to wire them explicitly as inputs. The
resolver reads `features` and injects the corresponding NFR/input on
each child.

## 4. Encryption by Default

Encryption is mandatory and on by default across the platform.

1. Every L1 MUST declare an `encryption_enabled` NFR (boolean, default
   `true`) in `interface.json`. See §2.5.
2. Every L1 that holds at-rest data (S3, RDS, ECR, ECS task
   definition env, VPC flow logs, CloudWatch log groups) MUST declare an
   optional `kms_key_arn` input (`string`, `required: false`). When
   supplied, the adapter wires it to the resource's KMS encryption
   argument.
3. L2 modules MUST wire a per-stack customer-managed KMS key to all
   children that accept `kms_key_arn`. The KMS key is a `kms-key` child
   of the L2 — one key per L2 deployment, no shared keys. Reference:
   both `static-assets` and `microservice` declare a `kms` child
   (`kms-key@1.0.0`) and wire `kms.outputs.kms_key_arn` to every child
   that accepts a CMK.
4. For a standalone L1 deployment (an L1 used outside an L2), if the
   consumer does not supply `kms_key_arn`, the adapter falls back to the
   AWS-managed default key for that service and emits a warning to
   stderr. The primitive is still encrypted; only the key manager
   differs.
5. The `kms-key` primitive enables key rotation by default
   (`enable_rotation` NFR, default `true`), and the adapter emits
   `enable_key_rotation = true` on the `aws_kms_key` resource.

A primitive that does not hold at-rest data (e.g. `iam-role`,
`ecs-cluster`, `alb`) still declares `encryption_enabled` for standards
uniformity (see §2.5) but does not declare `kms_key_arn`.

## 5. Deletion Protection by Default

Deletion protection is mandatory and on by default to prevent
accidental teardown of production infrastructure.

1. Every L1 MUST declare a `deletion_protection` NFR (boolean, default
   `true`) in `interface.json`. See §2.5.
2. When `deletion_protection` is `true`, the engine adapter emits a
   `lifecycle { prevent_destroy = true }` block on the corresponding
   Terraform resource. A `terraform destroy` against a protected
   resource fails with an error naming the resource.
3. L2 modules expose `features.deletion_protection` (default `true`).
   The resolver propagates the flag to every child's NFRs (see §3.6).
4. **Decommission mode.** To tear down a stack that was deployed with
   deletion protection, the consumer sets `inputs.deletion_protection:
   false` on the contract (or `features.deletion_protection: false` on
   an L2) and re-applies. The decommission transform
   (`decommission_transform`) zeroes capacity counts (e.g. ECS desired
   count to 0, RDS allocated storage to the minimum) so that the
   subsequent `destroy` applies against a quiesced stack. The transform
   is applied by the resolver before the adapter emits resources.

## 6. Registry

Every module — L1 and L2 — MUST be registered in
`modules/registry.json` at its semver. The registry is the source of
truth for what is published; the adapter and resolver refuse to compile
a module that is not registered.

Registry entry shape:

```json
{
  "<module-name>": {
    "<semver>": {
      "interface": "modules/<l1|l2>/<module-name>/<interface.json|composition.json>",
      "published_at": "<ISO 8601 timestamp>",
      "deprecated": false
    }
  }
}
```

- `interface` is the path (relative to the repo root) to the module's
  interface file — `interface.json` for an L1, `composition.json` for
  an L2.
- `published_at` is an ISO 8601 timestamp. Use a full
  `YYYY-MM-DDTHH:MM:SSZ` form; do not omit the seconds or the timezone
  designator.
- `deprecated` is `false` for a live module. A MAJOR version bump does
  not delete the old entry; it flips `deprecated` to `true` and starts a
  12-month deprecation window (see §7 Versioning).

A new semver of an existing module is a new key under the module's
object; old semvers are retained. The registry is append-only for
published semvers — a published semver is never edited or deleted.

## 7. README Standards

Every module README MUST follow the structure of
`modules/README-TEMPLATE.md`. Required sections, in order:

1. `# <name> — <plain-language description>` — title with the module
   name and a one-line description.
2. `## Overview` — one or two sentences in plain language.
3. `## Resources` — a table of the Terraform resources the module
   creates (L1) or the primitives it references (L2).
4. `## Inputs` — a table: `| Name | Type | Required | Default | Description |`.
5. `## Outputs` — a table: `| Name | Type | Description |`.
6. `## NFRs` — a table: `| Name | Type | Default | Description |`.
   `deletion_protection` and `encryption_enabled` are mandatory NFRs
   for every L1; they MUST appear in this table.
7. `## Usage` — a concrete snippet showing how a consumer references
   the module in a contract.
8. `## Compliance extension points` — resources or behaviors that could
   be added for the future compliance milestone (GDPR, SOX, SOC2,
   DORA). Not implemented yet; listed so the redesign can plan for them.
9. `## Examples` — links to `examples/simple.yml` and
   `examples/complex.yml` with a one-line description of each.
10. `## Versioning` — the module's semver policy: interface MAJOR,
    behavior MINOR, lifecycle PATCH. MAJOR bumps require a new
    `registry.json` entry (immutable publication); old entries enter a
    12-month deprecation window.

An L2 README's `## Resources` section lists the referenced L1 children
rather than Terraform resources, and its `## Inputs`/`## Outputs`
sections reflect the contract inputs and stack outputs of the
composition.

## 8. Stateless Assembler Pattern

The Terraform adapter (`adapters/terraform/adapter.py`) is a **stateless
assembler** (~80 lines). It owns no module content — no resource shape, no
nested HCL blocks, no defaults, no type-specific logic. It reads the
registry to find each L1 module's `terraform/` dir, then emits a root
`main.tf` that instantiates each resource as a
`module "<rid>" { source = ... }` block with resolved inputs and wired refs.

Engine-specific knowledge (resource type, arg names, nested blocks,
defaults, NFRs) lives in the per-module `terraform/` subdir, NOT in the
adapter. `interface.json` stays engine-agnostic (the contract); the
`terraform/` dir is the engine binding. A future Azure adapter would add
an `azure/` subdir per module without touching `interface.json`.

### 8.1 Per-module terraform dir

Each L1 module ships a `terraform/` subdir:

```
modules/l1/<name>/terraform/
├── versions.tf     # required_version + required_providers (aws ~> 5.0)
├── variables.tf     # one variable {} per interface.json input
├── locals.tf       # HEAVY: centralizes var-vs-default interpolation
├── main.tf         # resource {} blocks referencing locals (not vars directly)
└── outputs.tf      # one output {} per interface.json output
```

**`locals.tf` is the key file.** Every default that was previously
hardcoded in the adapter (CIDR blocks, assume_role_policy JSON, ECR/logs
inline policy, Fargate requires_compatibilities, assign_public_ip,
listener/target ports) moves here as a `locals` block that interpolates
the variable against its sensible default:

```hcl
locals {
  cidr_block = var.cidr != null ? var.cidr : "10.0.0.0/16"
  assume_role_policy = var.assume_role_policy != null ? var.assume_role_policy : jsonencode({ ... })
}
```

`main.tf` stays clean — pure resource blocks referencing `local.*`, never
interpolating vars directly. Trivial single-resource modules (e.g.
`kms-key`, `ecr`) may inline locals in `main.tf`; multi-resource modules
get the full 5-file split.

### 8.2 How the adapter assembles

Given a resolved stack instance, the adapter:

1. Reads `modules/registry.json` → builds a `module_name → terraform_dir` map.
2. For each resource, extracts the module name from the resource's `module`
   field (e.g. `s3@1.0.0` → `s3`), looks up `terraform_dir`, and emits a
   `module "<rid>" { source = "<absolute terraform_dir>" ... }` block.
3. Passes each input (except `region`, which is provider-level) as a module
   argument. For `ref:<rid>.<output>` values, emits
   `module.<rid>.<output>` interpolations (terraform-native module outputs).
4. Emits root `output {}` blocks wiring module outputs to stack outputs.
5. Emits `providers.tf` (aws provider, region from the first resource) +
   `terraform.tf` (required_version + required_providers + S3 backend).

The adapter owns NO resource shape, NO nested blocks, NO defaults, NO
type-specific logic. It only assembles module instantiations and wires refs.

### 8.3 Adding a new L1

When a new L1 primitive is added:

1. Author the `terraform/` subdir (`versions.tf`/`variables.tf`/`locals.tf`/
   `main.tf`/`outputs.tf`) with the resource shape, nested blocks, and
   defaults. Defaults go in `locals.tf` (heavy interpolation of vars against
   sensible defaults).
2. Add a `terraform_dir` field to the module's `registry.json` entry.
3. Author `interface.json` (engine-agnostic), `instance.json` (regression
   baseline), `README.md`, and `examples/{simple,complex}.yml`.

**No adapter code changes.** The adapter is generic; it assembles any
module that has a `terraform_dir` in the registry.

## 9. Code Review Checklist

Use this checklist when reviewing a new module (L1 or L2). Every box
must be checked before the module is registered and published.

### 9.1 Files and structure

- [ ] All required files present:
  - L1: `interface.json`, `instance.json`, `README.md`,
    `examples/simple.yml`, `examples/complex.yml`,
    `terraform/` (versions.tf, variables.tf, locals.tf, main.tf, outputs.tf).
  - L2: `composition.json`, `README.md`, `examples/simple.yml`,
    `examples/complex.yml` (no `instance.json`, no `terraform/`).
- [ ] `interface.json` (L1) / `composition.json` (L2) validates against
  `schemas/stack.schema.json`.
- [ ] `examples/simple.yml` and `examples/complex.yml` validate
  against `schemas/contract.schema.json`.
- [ ] Module registered in `modules/registry.json` at its semver with a
  full ISO 8601 `published_at` and `deprecated: false`.

### 9.2 Interface (L1)

- [ ] `name` matches the folder name and `^[a-z][a-z0-9-]*$`.
- [ ] `version` is semver and matches the registry entry.
- [ ] `kind` is `"l1"`.
- [ ] `type` follows `aws:<service>:<kind>`.
- [ ] Every input has `type`, `description`, `required`; optional inputs
  carry a `default` of the correct type; `enum` present where the value
  set is constrained.
- [ ] Every output has `type` (`arn` for ARNs, `string` otherwise) and
  `description`.
- [ ] `nfrs` includes `deletion_protection` (boolean, default `true`)
  and `encryption_enabled` (boolean, default `true`).
- [ ] `kms_key_arn` input present if the primitive holds at-rest data.
- [ ] Multi-resource primitives declare `resources[]` (with `inputs`/
  `outputs` as arrays of names) and `intra_refs[]` with `{from, to}`.

### 9.3 Composition (L2)

- [ ] `kind` is `"l2"` and `depth` is `1`.
- [ ] Every `children[]` entry is `{id, module}` with `module` in
  `<name>@<semver>` form referencing a registered L1.
- [ ] No child references an L2 (no L3 in v1).
- [ ] `wires[]` use the `contract.inputs.<name>` /
  `<childId>.outputs.<name>` → `<childId>.inputs.<name>` /
  `stack.outputs.<name>` forms.
- [ ] `outputs[]` use `<childId>.outputs.<name>` →
  `stack.outputs.<name>`.
- [ ] A `kms` child (`kms-key@<semver>`) is present and its
  `kms_key_arn` output is wired to every child that accepts a CMK.
- [ ] `features` (if present) only uses defined flags
  (`deletion_protection`, `uptime_enabled`).

### 9.4 Adapter (stateless assembler)

- [ ] The new primitive's `terraform/` subdir exists with
  `versions.tf`/`variables.tf`/`main.tf`/`outputs.tf` and
  passes `terraform init + validate` standalone. `locals.tf` is required
  for multi-resource modules; trivial single-resource modules (e.g.
  `kms-key`, `ecr`, `ecs-cluster`) may inline locals in `main.tf`.
- [ ] `registry.json` has a `terraform_dir` field for the new primitive.
- [ ] No adapter code changes are needed (the adapter is generic; it
  assembles any module with a `terraform_dir` in the registry).
- [ ] The new primitive's `instance.json` round-trips through the
  adapter without error (regression baseline — the adapter emits a root
  `main.tf` with a `module "<rid>" { source = ... }` block).

### 9.5 README and docs

- [ ] README follows `README-TEMPLATE.md` with all required sections in
  order (§7).
- [ ] `## NFRs` table lists `deletion_protection` and
  `encryption_enabled` for an L1.
- [ ] `## Compliance extension points` lists at least one plausible
  future extension.

### 9.6 Tests

- [ ] A test is added for the new primitive covering adapter emission
  (the Terraform output for `instance.json` matches the expected
  fixture) and interface validation (`interface.json` validates against
  `stack.schema.json`).
- [ ] For an L2, a test is added that the composition resolves to the
  expected set of L1 instances and that the adapter emits a root module
  calling the L1 modules.