Files
acdl/modules/STANDARDS.md
T
Jon Chery 63f3a2b66c feat(P1): Nova rebrand — docs/decks/prose/schema-$id/release-titles (REQ-155/156/157)
Rebrand ACDL/Agentic Cloud Delivery Platform → Nova across README, docs/,
decks (markdown + mermaid .mmd + HTML), pyproject.toml name/description,
schema $id URLs (acdl.cloudinit.dev→nova.cloudinit.dev), release.yml
title/workflow-name. Nova tagline added to README header + both deck title
slides + docs/vision.md (alongside existing North Star, D-106). S&P theme
untouched (D-107). New docs/NOVA_MIGRATION.md consumer guide. Data values
(env vars, resource names, tag keys, SSM/consumer paths) left for P2-P4.

---ci---
project: acdl
phase: 1
milestone: v1.15
status: execute
---/ci---
2026-07-30 00:56:19 +00:00

614 lines
26 KiB
Markdown

# Nova Module Engineering Standards
Standards for authoring and reviewing Nova 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.