# Generate Iac

> Generate compliant IaC templates in all 4 formats — modular Terraform, CDK TypeScript, CloudFormation YAML, and CDK for Terraform. Uses parameter coverage matrix from mapping-results.json and API surface from validated.json.

- Skill: `aws-samples/generate-iac` (Agent Skill)
- Install (CLI): `npx skillmds@latest add aws-samples/generate-iac`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aws-samples/generate-iac/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: aws-samples (https://skillmd.com/u/aws-samples)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/aws-samples/generate-iac

---


# Service Approval — Generator: IaC Templates

Generate compliant infrastructure-as-code templates in all 4 required formats from the
Controls Matrix. Each template implements ALL controls as properties/variables on the
compliant resource.

**Output:** `.service-approval/<slug>/05-generate/iac/`

This is one of 3 focused generate sub-skills. Each writes to separate directories.

---

## Prerequisites

```bash
test -f .service-approval/<slug>/04-map/mapping-results.json && echo "mapping-results: OK" || echo "ERROR"
test -f .service-approval/<slug>/03-validate/validated.json && echo "validated: OK" || echo "ERROR"
```

Create directory structure:
```bash
mkdir -p .service-approval/<slug>/05-generate/iac/modules/_shared
for asset in $(python3 -c "import json; d=json.load(open('.service-approval/<slug>/03-validate/validated.json')); [print(a['name'].lower().replace(' ','-')) for a in d['assets']]"); do
  mkdir -p ".service-approval/<slug>/05-generate/iac/modules/${asset}"
done
```

Load controls and API surface:
```bash
python3 -c "
import json
mr = json.load(open('.service-approval/<slug>/04-map/mapping-results.json'))
vj = json.load(open('.service-approval/<slug>/03-validate/validated.json'))
print(f'Controls: {len(mr[\"controls\"])}')
print(f'Assets: {len(vj[\"assets\"])}')
print(f'API operations: {len(vj[\"api_surface\"][\"operations\"])}')
fw = mr['framework']
if isinstance(fw, dict): print(f'Framework: {fw[\"name\"]}')
"
```

## Artifact Header Template

Every generated file MUST include:
```
# SCOPE: resource
# LAYER: proactive
# POSTURE: preventative-proactive
# CONTROLS: <all control IDs>
# FRAMEWORK: <framework-name> — <FULL sorted union of ALL MAPPED objective IDs>
# MITIGATIONS: <all mitigation IDs>
# GENERATED: service-approval v3.0.0
# SERVICE: <service-name>
```

For JSON/YAML files, set `_metadata.posture` to `"preventative-proactive"` (IaC templates
implement preventative-proactive controls — they validate before any AWS call). For TF
`.tf` files, CDK `.ts` files, and the CFN `.yaml`, include the `# POSTURE:` comment in
the header block. CHECK-15 will verify `_metadata.posture == "preventative-proactive"`
for files under `iac/**`.

The `FRAMEWORK` line MUST be copied VERBATIM from
`mapping-results.json.framework_header_canonical` — `scripts/map-assemble.py` precomputes
this sorted-union string once so every artifact emits the identical header. Do NOT re-derive
the list from `_metadata.controls[]` or from per-control `framework_objectives[]`. The
identical string goes in EVERY IaC artifact including per-module TF files. CHECK-X1
(`validate_cross.py:120`) asserts header identity across artifacts — differing headers
fail the hook.

---

## Cross-generator consistency (MANDATORY)

The compliant IaC templates this skill writes MUST pass every rule in the
sibling `proactive/cfn-guard-rules.guard` file produced by `generate-preventive`.
Tier 1 runs cfn-guard against the CFN template using these rules; if the
compliant template fails its own guard rules it is self-inconsistent and
Tier 1 FAILs.

Before finalising any IaC template:

1. **Read the generated guard rules.** Open `proactive/cfn-guard-rules.guard`
   and enumerate every assertion. Three shapes matter:
   - `<Path>.<Prop> exists` — the template MUST set `<Prop>` on `<Path>`.
   - `<Path>.<Prop> == "<literal>"` or `<Path>.<Prop> in ["<a>","<b>"]` — the
     template MUST set `<Prop>` to a value the rule accepts.
   - `<Path>.<Prop> !empty` — the template MUST set `<Prop>` to a non-empty value.
2. **Reflect each assertion in every IaC format.** The CFN template is the
   format cfn-guard scores, but Terraform, CDK TypeScript, and CDKTF MUST
   mirror the same properties on the same resources (different syntax, same
   semantics). Do NOT write a CFN template that passes the rules and a TF
   module that omits the properties.
3. **Common gotcha — chained properties.** When a rule asserts
   `Parent.Child.Grandchild exists`, every intermediate key must be present
   in the template. Example: if the rule is
   `AWS::ECS::Cluster.Properties.Configuration.ManagedStorageConfiguration.FargateEphemeralStorageKmsKeyId exists`,
   every intermediate key (`Configuration`, `ManagedStorageConfiguration`,
   `FargateEphemeralStorageKmsKeyId`) must appear in the Cluster's Properties.
4. **Verify the guard rule's property path against the CFN schema before
   mirroring it.** The guard rule is not authoritative about where a property
   lives in the CFN schema — the generator that wrote it may have copied the
   API `path` from `validated.json`, which does not always match CFN. Before
   adding a property to the compliant template to satisfy a rule, confirm the
   resource type actually supports that property at
   https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/AWS_{cfn_prefix}.html
   or via `awsiac` MCP. If the CFN schema rejects the property (cfn-lint
   E3002), the guard rule is wrong, not the template — fix the rule in
   `proactive/cfn-guard-rules.guard` to target the correct resource type and
   path, then mirror that into every IaC format.
5. **When a rule is too strict for a reasonable default**, treat the rule as
   authoritative (after step 4 confirms the path): adjust the template to
   satisfy it, or split the control into "always" vs "conditional" rules in
   `generate-preventive`. Never leave the template failing its own rules
   "because the default is reasonable".

This cross-check is inherent in Tier 1 smoke validation — but do it at
write time, not after the fact. If you discover a mismatch during Tier 1,
fix the template (or fix the rule, if the rule is wrong), and re-run.

### Per-resource-type property names may differ within one service

CFN property names are defined per RESOURCE TYPE, not per service. A single
AWS service can use different property names for the "same" concept on
different resource types. Do NOT assume uniform naming across a service's
resources.

Concrete case surfaced in production runs with multi-resource encryption:
- `AWS::<Service>::<ResourceA>.EncryptionKeyArn` — KMS key for one resource type
- `AWS::<Service>::<ResourceB>.KmsKeyArn` — KMS key for another resource type

Both properties encrypt with a customer-managed KMS key, but the property
names differ. Other examples across AWS: `KmsKeyId` vs `KmsKeyArn` vs
`KMSMasterKeyId` vs `EncryptionKeyArn` appear across S3, DynamoDB, SQS,
SNS, KMS, Lambda, and various analytics services.

**Before writing any cfn-guard rule or CFN/TF/CDK property assignment**,
look up the exact property name for **each** resource type in the CFN
Template Reference page (or `awsiac` MCP). When a single rule targets
multiple resource types (e.g., "require CMK on all service resources"),
split it into per-type blocks:

```
rule cmk_required_resource_a {
    AWS::<Service>::<ResourceA> {
        Properties.EncryptionKeyArn exists
    }
}

rule cmk_required_resource_b {
    AWS::<Service>::<ResourceB> {
        Properties.KmsKeyArn exists
    }
}
```

NOT a single rule using one property name for both — the guard rule will
false-pass one resource type while false-failing the other.

Mirror the same per-resource-type awareness in Terraform modules, CDK
constructs, and CDKTF.

### maxItems=1 array constraint

**This rule applies ONLY when the authoritative CFN registry schema explicitly
declares `"maxItems": 1` on the target field.** Do NOT apply it based on
intuition about the service's "tenancy model" or "single-tenant-per-resource"
feel. Service topology does not imply the constraint — look up the schema.

Authoritative check (replaces the Template Reference page for this specific
question):

```
curl -sS "https://schema.cloudformation.<region>.amazonaws.com/aws-<service>-<resource>.json" | \
  jq '.. | objects | select(has("maxItems")) | {prop: input_filename, max: .maxItems}'
```

Or via `awsiac` MCP with explicit inspection of the target property.

When `maxItems == 1` IS declared, the CFN schema rejects any array with more
than one element — multi-AZ / multi-zone / multi-instance posture MUST be
expressed by creating multiple resources of that type, NOT by adding multiple
entries to the single-item array.

**Confirmed maxItems=1 cases** (verified against live CFN registry schemas):
- `AWS::DataSync::Agent.SubnetArns` and `.SecurityGroupArns` — multi-AZ requires
  multiple Agent resources.

**Counter-examples** (look like they might fit but don't):
- `AWS::Transfer::Server.EndpointDetails.SubnetIds` — no `maxItems` declared.
  Multi-AZ is expressed as a single Server with ≥2 SubnetIds. Do NOT split into
  multiple Server resources.

When in doubt, check the schema before applying the rule. An incorrect
application produces broken multi-resource templates for services that just
want array entries.

For these cases, the sibling cfn-guard rule MUST assert resource count, not
array cardinality:

```
# WRONG — fails on every template, because maxItems=1
rule multi_az when Resources.*[ Type == "AWS::DataSync::Agent" ] !empty {
    Resources.*[ Type == "AWS::DataSync::Agent" ] {
        Properties.SubnetArns[1] exists   # unreachable
    }
}

# RIGHT — asserts two Agent resources instead
rule multi_az {
    let agents = Resources.*[ Type == "AWS::DataSync::Agent" ]
    %agents !empty
    # Each Agent is pinned to one subnet/AZ by schema; multi-AZ ⇒ 2+ Agents
    # (enforce count via a guard-level check in the consuming pipeline, or
    # document the per-resource expectation here and pair with an OPA rule)
}
```

Add a comment in the template near each such resource noting the
`maxItems=1` constraint and why the template emits multiple resources.
This prevents a future edit from collapsing them into "one resource with
two subnets" and silently breaking deploy.

---

## Step 1: Parameter Coverage Matrix

For every control in `controls[]`, extract `parameters_controlled[]`. Build a matrix:

| Control ID | Scope | Category | Parameter | Template(s) |

Cross-check: every parameter must appear as a variable/property in ALL 4 templates.
After generating each template, diff its parameters against this matrix.

---

## Step 2: Resource Policy Lifecycle

For every control with `parameters_controlled[]` referencing `PutResourcePolicy`:
- Generate resource policies for EVERY applicable resource type
- Count distinct resource policy resources generated vs `resource_types` from PutResourcePolicy
- Document any gaps as KNOWN GAP

---

## IMPORTANT: Generation Order for Context Efficiency

All 4 formats are REQUIRED. To avoid running out of context before completing all formats,
generate in this order:

1. **CDK TypeScript** (Step 4) — single file, compact
2. **CloudFormation YAML** (Step 5) — single file, compact
3. **CDK for Terraform** (Step 6) — single file, compact
4. **Modular Terraform** (Step 3) — multi-file, largest

This ensures the 3 single-file formats are written first. If context pressure is high,
the Terraform modules can be generated with less verbose comments while still including
all required parameters and control ID annotations.

---

## Cross-Format Quality Rules (apply to ALL 4 formats)

These rules MUST be enforced in every template format — not just one:

1. **Authorizer field completeness**: Generate ALL accepted fields on authorizer configuration.
   Verify the exact set of accepted fields via `aws <service> <command> --generate-cli-skeleton`
   or the SDK model — do NOT assume fields exist based on documentation alone. Only generate
   variables/parameters for fields the API actually accepts. Common error: generating a field
   the API silently ignores (wasted variable) or missing a field the API requires.

2. **Enum value accuracy in IaC templates (Rule C3 — API-driven)**: When generating
   variables or parameters with enum constraints (CFN `AllowedValues`, TF variable
   `validation` blocks, CDK runtime checks, CDKTF variable defaults), use ONLY the exact
   values from `validated.json.api_surface.operations[].parameters[].enum`. This field
   is the single source of truth for the current service run — do NOT re-scrape AWS
   docs. Copy values verbatim. Common error: using `IAM` instead of `IAM_AUTH` or
   `CustomJWT` instead of `CUSTOM_JWT_AUTHORIZER` for authorizer type enums. Any
   enum-style literal list (`in [...]`, `AllowedValues: [...]`, `contains([...], var.x)`,
   `validation { condition = contains([...], var.x) }`) must match `parameters[].enum`
   exactly. Validator CHECK-14b enforces this at hook time across all 4 IaC formats —
   an enum-literal value not in any parameter's enum fails the hook.

   Min/max/pattern constraints come from the SAME source: `parameters[].min`,
   `parameters[].max`, `parameters[].pattern`. Do not invent bounds.

3. **VPC Endpoint Condition on Network-Scoped IAM Policies**: When a resource has VPCE-based
   network controls, the IAM execution role policy SHOULD include `aws:SourceVpce` condition
   when a VPC endpoint ID is provided. This applies to Terraform modules and CDK constructs.

4. **KMS key policy must grant all consuming services**: When a KMS CMK is used by multiple
   AWS services (e.g., Lambda env vars + CloudWatch Logs + SQS DLQ), the key policy MUST
   include a separate statement for EACH service principal. Common omission: creating a KMS
   key for Lambda but only granting `lambda.amazonaws.com` — then the CloudWatch Log Group
   creation fails with `AccessDeniedException`. Required grants:
   - `logs.{region}.amazonaws.com` for CloudWatch Logs encryption (with `kms:EncryptionContext:aws:logs:arn` condition)
   - `sqs.amazonaws.com` for SQS DLQ encryption (if DLQ uses the same key)
   - Account root `arn:aws:iam::{account}:root` for key administration
   This applies to ALL 4 IaC formats.

   **MANDATORY pre-write verification (applies to ALL 4 formats)**: Before finalizing any
   template that defines a KMS key, enumerate every resource in the template that references
   the key by `kms_key_id` / `KmsKeyId` / encryption-key ARN / `kmsMasterKeyId`. For each
   consumer, the key policy MUST contain a Statement with the matching service principal,
   the actions the service needs, AND the conditions AWS requires. Principal alone is not
   enough — AWS rejects calls that lack the correct action or encryption-context condition
   (e.g., Fargate `UpdateCluster` returns `InvalidParameterException: Insufficient key
   permissions provided to Fargate service principal` if the actions or conditions are
   wrong, even when the principal is `fargate.amazonaws.com`).

   | Consumer | Service principal | Required actions | Required conditions |
   |---|---|---|---|
   | `aws_cloudwatch_log_group` / `AWS::Logs::LogGroup` | `logs.{region}.amazonaws.com` | `kms:Encrypt`, `kms:Decrypt`, `kms:ReEncrypt*`, `kms:GenerateDataKey*`, `kms:DescribeKey` | `ArnLike kms:EncryptionContext:aws:logs:arn = arn:aws:logs:{region}:{account}:log-group:*` |
   | `aws_sqs_queue` / `AWS::SQS::Queue` | `sqs.amazonaws.com` | `kms:Encrypt`, `kms:Decrypt`, `kms:GenerateDataKey*` | `StringEquals aws:SourceAccount = {account}` |
   | `aws_sns_topic` / `AWS::SNS::Topic` | `sns.amazonaws.com` | `kms:Encrypt`, `kms:Decrypt`, `kms:GenerateDataKey*` | `StringEquals aws:SourceAccount = {account}` |
   | `aws_lambda_function` env vars | `lambda.amazonaws.com` | `kms:Decrypt` | `StringEquals aws:SourceAccount = {account}` |
   | `aws_ecs_cluster` ECS service use (managed storage) | `ecs.amazonaws.com` | `kms:Decrypt`, `kms:Encrypt`, `kms:DescribeKey`, `kms:GenerateDataKey`, `kms:CreateGrant` | `StringEquals aws:SourceAccount = {account}` |
   | `aws_ecs_cluster` Fargate ephemeral storage (stmt 1) | `fargate.amazonaws.com` | `kms:GenerateDataKeyWithoutPlaintext` | `StringEquals kms:EncryptionContext:aws:ecs:clusterAccount = {account} AND aws:ecs:clusterName = {cluster_name}` |
   | `aws_ecs_cluster` Fargate ephemeral storage (stmt 2) | `fargate.amazonaws.com` | `kms:CreateGrant` | `StringEquals` (both encryption-context keys above) AND `ForAllValues:StringEquals kms:GrantOperations = ["Decrypt"]` |
   | `aws_ecs_cluster` Fargate operator (stmt 3) | account root (`arn:aws:iam::{account}:root`) | `kms:DescribeKey` | none |

   Ref for Fargate ephemeral storage policy:
   https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-create-storage-key.html

   Build the grant list BEFORE writing the KMS resource; verify after writing by scanning the
   template for every consumer reference. If any consumer lacks a grant OR grants the wrong
   actions/conditions, the template is invalid — regenerate. Deterministic post-check:
   `tools/validate/validate_kms_consumers.py` (checks principal +
   required actions + required condition keys for each consumer).

5. **Control ID annotations**: Every resource property that implements a control MUST have
   an inline comment with the control ID (e.g., `# CTRL-ACC-PRV-001`).

6. **No unused variables/parameters**: Every variable declared in a TF `variables.tf` or CFN
   `Parameters` section MUST be referenced by at least one resource. After generating, scan
   for any variable not referenced in `main.tf` or any resource block — remove it. Common
   cause: a variable was planned for a resource that ended up using a different parameter name.

7. **Cross-format parameter parity**: After generating all 4 formats, verify that the same set
   of user-configurable parameters appears in each. The parameter coverage matrix from Step 1
   is the source of truth — every format must cover every parameter. Only include parameters
   that the API actually accepts (verified via SDK model).

8. **Launch-type / runtime constraints**: Some resource types have properties that AWS rejects
   based on a sibling property's value — for example, Fargate ECS task definitions reject
   `LinuxParameters.Capabilities.Add` and `Privileged`. Before emitting any resource whose
   behavior depends on a launch type, runtime mode, compatibility flag, or similar selector,
   check `data/launch-type-constraints.json` for forbidden properties.
   The check flow:
   - Look up the resource's CFN type (e.g., `AWS::ECS::TaskDefinition`) in the data file
   - If present, read the `match_property` and `match_value` (e.g., `RequiresCompatibilities` contains `FARGATE`)
   - If the template matches, omit every property listed under `forbidden_properties`
   - If the data file has no entry for the type, proceed — unknown resource types are
     not blocked, but deploy-time errors for that resource should be logged as a new
     entry for this data file

   Current entries (as of this skill version):
   - `ecs_fargate`: `AWS::ECS::TaskDefinition` with `RequiresCompatibilities: FARGATE`
     cannot set `ContainerDefinitions[*].LinuxParameters.Capabilities.Add` or
     `ContainerDefinitions[*].Privileged`. Fargate rejects both at `RegisterTaskDefinition`
     with `ClientException`.

   New services or new launch-type surprises should be added to the data file, not inlined
   here, so the skill stays service-agnostic.

9. **Tag naming canonical form — SINGLE case (PascalCase)**: Emit each operational tag
   EXACTLY ONCE using the PascalCase form: `Owner`, `CostCenter`, `Environment`,
   `DataClassification`.

   **Why single-case, not dual:** IAM treats tag keys as **case-insensitive** (e.g.,
   `Owner` and `owner` collide). Applying both forms on an `AWS::IAM::Role`,
   `AWS::IAM::Policy`, or any IAM resource fails at CreateRole/CreatePolicy with
   `InvalidInput: Duplicate tag keys found. Please note that Tag keys are case insensitive.`
   This blocks the stack mid-deploy and has to be rolled back. Because most compliant
   templates include at least one IAM role (the Lambda/EC2/etc. execution role), any
   dual-case pattern will fail the smoke-deploy-test step on the IAM resource.

   Lambda, S3, KMS, SQS, DynamoDB tags happen to be case-SENSITIVE and would accept
   dual-case, but emitting dual-case there while IAM rejects it creates an inconsistent
   template that only deploys when IAM resources are absent. Pick one case and stay
   consistent across every resource in the template.

   SCP/Config/OPA conditions downstream MUST align on the same canonical case. The
   canonical form is PascalCase (`Owner`, `CostCenter`, `Environment`,
   `DataClassification`) because AWS Config managed `required-tags` rule and Security
   Hub standards use PascalCase. If a customer convention uses lowercase-hyphenated,
   remap at the CI/CD layer, not in the template.

   **Terraform** (`main.tf` root `locals`):
   ```hcl
   locals {
     common_tags = {
       "Owner"              = var.owner
       "CostCenter"         = var.cost_center
       "Environment"        = var.environment
       "DataClassification" = var.data_classification
     }
   }
   ```

   **CDK TypeScript**: apply `Tags.of(resource).add("Owner", ownerParam)` once per
   canonical key — NEVER add a lowercase pair. Same for CloudFormation (Tags list with
   one entry per key) and CDKTF (same locals pattern).

   If `generate-preventive` changes the required-tag set, update this rule in lockstep.
   Both skills must agree on the canonical key list AND case.

---

## Step 3: Modular Terraform (`iac/modules/`)

### Architecture

One module per asset from `validated.json` `assets[]`:
```
iac/
├── main.tf           # Root module — wires child modules
├── variables.tf      # Root-level inputs
├── outputs.tf        # Re-exports from child modules
└── modules/
    ├── _shared/
    │   └── variables.tf
    ├── <asset-1>/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── <asset-N>/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf
```

### Module Derivation
1. Each `assets[]` entry → one module (kebab-case dir name)
2. Group controls by `resource_type` → controls for that CFN type go in that module
3. Cross-cutting controls → comment in every module

### Per-Module Contents
Each module wraps: primary resource + IAM role + KMS key + resource policy + schema gap resources.
Only pass arguments that the child module actually declares as variables. If a capability
(e.g., VPC endpoint, resource policy) is not applicable to a resource type, do NOT add
the argument to the root module block — even if other modules accept it.

### Terraform Provider Schema Check
**MANDATORY:** Before generating, call the `terraform` MCP `search_providers` tool with
the service name, then `get_provider_details` for each resource type. This determines:
- Which parameters are natively supported
- Valid enum values
- Attributes NOT on the TF schema (schema gaps)

### Schema Gap Handling
For every parameter missing from the TF provider schema:
1. Declare a `variable` in `variables.tf` with description noting the gap
2. Generate a `null_resource` with `local-exec` in `main.tf`:
   - Apply provisioner: `aws <service> update-<resource>` with the parameter
   - Destroy provisioner: reset the parameter (use `self.triggers.*` for ARN values)

### Coding Rules
- **Root ↔ module name consistency**: When the root `main.tf` passes arguments to a child module,
  the argument name MUST exactly match a `variable` name declared in that child module's
  `variables.tf`. After generating each module, re-read its `variables.tf` and verify every
  argument in the root `module {}` block uses the exact same name. Common mismatches:
  pluralization (`audiences` vs `audience`), abbreviations (`vpce_id` vs `vpc_endpoint_id`),
  and renames (`resource_policy_principals` vs `trusted_principal_arns`).
- All variables: `description` explaining the security control
- Sensitive variables: `sensitive = true`
- Optional parameters: `default = ""` + `count` on dependent resources
- **HCL block syntax**: A block that contains multiple arguments MUST place each on its
  own line. Terraform has two valid forms:
  ```
  # VALID — single-argument single-line:
  variable "x" { type = string }
  # VALID — multi-line (any number of arguments):
  variable "y" {
    type    = string
    default = ""
  }
  ```
  These are INVALID and reject at `terraform init`:
  ```
  # INVALID — space-separated, >1 argument on one line:
  variable "bad1" { type = string  default = "" }
  # INVALID — semicolon as separator (HCL uses newlines, never ;):
  variable "bad2" { type = string; default = "" }
  ```
  The error is `Invalid single-argument block definition` (for spaces) or `Invalid character`
  (for `;`). This applies to `variable`, `output`, `resource`, `module`, `locals` — every
  HCL block. When in doubt, use the multi-line form.
- **`count` / `for_each` MUST NOT reference known-after-apply values**: Terraform evaluates
  `count` and `for_each` at plan time, so the expression MUST resolve without running any
  resource. If a module gates a resource on whether an upstream resource exists, pair it
  with a STATIC boolean variable — never compare an ARN string:
    # WRONG — aws_kms_key.x.arn is known-after-apply at plan time:
    count = var.kms_key_arn != "" ? 1 : 0
    # CORRECT — static boolean the root flips in tfvars:
    variable "enable_feature" {
      type    = bool
      default = false
    }
    resource "null_resource" "x" {
      count = var.enable_feature ? 1 : 0
    }
  Terraform aborts `plan` with `Error: Invalid count argument ... the count value depends
  on resource attributes that cannot be determined until apply` whenever `count`/`for_each`
  references a variable that the root module wires to `aws_*.foo.arn`, `aws_iam_role.x.arn`,
  or any other resource output. The static-boolean pattern is also cleaner for destroy —
  flipping `enable_feature` to `false` produces a clean destruction plan.
- **Root variables MUST have defaults**: Every variable in the root `variables.tf` MUST have a
  `default` value (typically `""` for strings, `[]` for lists, `{}` for maps). Without defaults,
  `terraform plan` requires `-var` flags for every variable, making dry-run validation impossible.
  Child module variables inherit values from root module arguments and do NOT need defaults.
- `local` values for computed ARNs
- Destroy provisioners: access `self.triggers.*` NOT `var.*`, NOT `data.*`, NOT `local.*`.
  Store ALL values needed at destroy time in `triggers {}` — including `region` and `account_id`.
  At destroy time, `var`, `data`, and `local` references may not be available. Only
  `self.triggers.*` is guaranteed to resolve. Example:
  ```hcl
  triggers = {
    resource_arn = aws_resource.this.arn
    region       = data.aws_region.current.id
    account_id   = data.aws_caller_identity.current.account_id
  }
  provisioner "local-exec" {
    when    = destroy
    command = "aws <cmd> --region ${self.triggers.region}"
  }
  ```
- **Region data source**: Use `data.aws_region.current.id` NOT `data.aws_region.current.name`.
  The `name` attribute was deprecated in AWS provider v6.39.0 and replaced by `id`. Using
  `name` triggers deprecation warnings and will break in future provider versions. Apply this
  everywhere: triggers, locals, interpolations, and output values.
- `null_resource` triggers: include resource ID
- `environment` block on `local-exec`: all dynamic values (injection prevention)
- Export all resource ARNs as outputs
- Root module re-exports ALL child module outputs — every output defined in any child
  module's `outputs.tf` MUST have a corresponding re-export in the root `outputs.tf`.
  This includes ARNs, IDs, names, AND aliases (e.g., KMS key aliases). Missing re-exports
  break the module contract for consumers.

### Control ID Inline Comments
```hcl
resource "aws_<service>_<resource>" "this" {
  encryption_key_arn = local.effective_kms_arn      # CTRL-RES-PRV-001
  execution_role_arn = local.effective_role_arn     # CTRL-ACC-PRV-001
  tags               = var.tags                     # CTRL-ORG-PRO-001
}
```

### Logging target buckets (S3)

When the generated Terraform module creates an S3 bucket whose purpose is to receive
access logs from another bucket (e.g., `aws_s3_bucket.access_logs` inside an S3 bucket
module), tag it with `"bucket-role" = "log-target"`:

```hcl
resource "aws_s3_bucket" "access_logs" {
  bucket_prefix = "compliant-s3-logs-"
  tags = merge(var.common_tags, {
    purpose       = "s3-access-logs"
    "bucket-role" = "log-target"
  })
}
```

This tag is an EXEMPTION marker the OPA `CTRL-ACC-PRO-002` rule emitted by
`generate-preventive` respects (via its `is_log_target(tags)` predicate). Without this
tag, the compliant Terraform plan fails its own rego because every `aws_s3_bucket` would
be required to have an `aws_s3_bucket_logging` companion — which the log sink cannot
satisfy without recursion (it cannot log to itself).

The exemption tag name MUST match the rego rule exactly — if `generate-preventive`
changes the tag key or value, update this rule in lockstep. See
`generate-preventive/SKILL.md` "Access-log exemption tag contract" for the paired rule.

---

## Step 4: CDK TypeScript (`iac/cdk/compliant-resource.cdk.ts`)

**File layout requirements (REQUIRED for smoke-test validation)**:
- Source file MUST live at `iac/cdk/compliant-resource.cdk.ts` — NOT at `iac/compliant-resource.cdk.ts`
- The `cdk/` directory MUST also contain:
  - `package.json` with `aws-cdk-lib` and `constructs` as dependencies
  - `tsconfig.json` with `"include": ["*.ts"]` (or `["**/*.ts"]` if deeper nesting is needed) — NEVER `"../**/*.ts"` because that pulls sibling `iac/cdktf/` files into the CDK compile with the wrong peer-dep set
  - `tsconfig.json` `"exclude": ["node_modules"]`
- The validator `validate_deployable.py` runs `npm install` (or `npm ci` if a lockfile exists) inside `iac/cdk/` and then `npx tsc --noEmit`. Missing the file in the correct location, or pulling in sibling CDKTF sources, causes TS2307 module-resolution errors.

**MANDATORY:** Call `awsknowledge` MCP to check for native CFN resource type. If it exists,
prefer `CfnResource` over `AwsCustomResource`.

Use `aws-cdk-mcp-server` MCP for L2/L3 constructs.

### CDK Rules
1. `AwsCustomResource`: include BOTH `onCreate` AND `onUpdate`
2. `installLatestAwsSdk: false` on every `AwsCustomResource`
3. `PhysicalResourceId.of(<unique-stable-id>)` — never `fromResponse(...)` for creation
4. Optional props: `?` marker + conditional spread.
   CDK L2 `Props` interfaces (e.g. `lambda.FunctionProps`) are `readonly` — every
   field is immutable after assignment. Do NOT declare the props object first and
   mutate it inside `if (props.foo) { functionProps.vpc = ... }`: `tsc` rejects
   that with `TS2540 Cannot assign to 'X' because it is a read-only property`.
   Build each conditional block as its own object (typed `any` for the mutable
   staging variable is fine — the final spread into the typed Props preserves
   safety at construction time), then spread them into the final Props literal:
   ```ts
   const vpcConfig: any = {};
   if (props.createVpcConfig) {
     vpcConfig.vpc = vpc;
     vpcConfig.vpcSubnets = { subnets: [...] };
   }
   const functionProps: lambda.FunctionProps = {
     functionName,
     runtime,
     ...vpcConfig,   // conditional spread — not post-assignment mutation
   };
   ```
5. IAM policy scoping: specific ARN patterns, never `*`
6. One construct class per asset

### Tag PII Validation
Runtime `throw new Error(...)` checks in constructor for PII patterns.

### Authorizer Field Completeness
Generate ALL accepted authorizer configuration fields. Verify via `aws <service> <command>
--generate-cli-skeleton` — only include fields the API actually accepts. Do NOT assume field
names from documentation; some documented fields may not be accepted by the API yet.

### VPC Network Configuration
When a resource supports VPC mode, generate the VPC configuration properties that the API
accepts. Verify accepted fields via the SDK model — some services accept a network mode
selector at the top level but do NOT accept a nested configuration object for subnets and
security groups in the current API version. Check before generating.

---

## Step 5: CloudFormation YAML (`iac/compliant-resource.cfn.yaml`)

Use `awsiac` MCP for resource schema. If CFN type not registered, use custom resource.

### CFN Rules
1. **Optional parameters MUST have BOTH `Default: ""` AND a `Condition`**:
   - **CRITICAL — Parameter type for Fn::Equals**: ALL parameters referenced in `Fn::Equals`
     conditions MUST be `Type: String`, NOT `Type: CommaDelimitedList`. `Fn::Equals` requires
     two string operands — a `CommaDelimitedList` resolves to a list, causing a CloudFormation
     validation error: "every Fn::Equals object requires a list of 2 string parameters." If
     you need list behavior, accept a comma-separated `String` and use `Fn::Split` where needed.
   - **CRITICAL — Resource-level Condition must be a PLAIN STRING (cfn-lint E3001)**: The
     `Condition:` key on a CFN resource accepts ONLY a plain string — the name of a condition
     defined in the Conditions section. **NEVER** use `!Not`, `!And`, `!Or`, `!If`, `!Equals`,
     or ANY intrinsic function as the value of a resource-level `Condition:`. This is the #1
     cfn-lint failure in generated templates. Example of what NOT to do:
     ```yaml
     # WRONG — causes E3001
     MyRole:
       Type: AWS::IAM::Role
       Condition: !Not [HasRoleArn]
     ```
     Instead, define the inverse condition in the Conditions section and reference by name:
     ```yaml
     # CORRECT
     Conditions:
       HasRoleArn: !Not [!Equals [!Ref RoleArn, ""]]
       NeedsRole: !Equals [!Ref RoleArn, ""]   # inverse condition
     Resources:
       MyRole:
         Type: AWS::IAM::Role
         Condition: NeedsRole                    # plain string only
     ```
     For every resource that should only be created when a parameter is empty/absent, define
     a named inverse condition (e.g., `NeedsXyz`) alongside the positive `HasXyz` condition.
   - Define a `Condition` for each optional parameter: `HasXyz: !Not [!Equals [!Ref Xyz, ""]]`
   - Every resource property that uses the optional parameter MUST be wrapped in `!If [HasXyz, !Ref Xyz, !Ref "AWS::NoValue"]`
   - Parameters without Default+Condition will cause stack failures when empty strings are passed to APIs
   - **Common omission**: JWT fields, VPC fields, Permission Boundary ARN — check ALL optional parameters
   - **No unused conditions (cfn-lint W8001)**: Every condition defined in the `Conditions`
     section MUST be referenced by at least one resource `Condition:` key or `!If` function.
     After generating the template, scan for any condition not referenced — remove it. Common
     cause: defining `HasOrgId` or `HasVpcEndpointId` for a planned resource that ended up
     using a different gating mechanism.
   - **No unused parameters (cfn-lint W2001)**: Every parameter in the `Parameters` section
     MUST be referenced by at least one resource property, condition, or output. After generating,
     scan for unreferenced parameters and remove them. Common cause: defining parameters for
     conditions (OrgId, VpcEndpointId) that end up not being used by any resource.
   - **No redundant DependsOn (cfn-lint W3005)**: Do not add explicit `DependsOn` when the
     dependency is already established by a `!Ref`, `!GetAtt`, or `!Sub` reference. CloudFormation
     infers the dependency automatically. Common cause: adding `DependsOn: LogGroup` to a Lambda
     function that already references the log group via `!Ref LogGroup` in LoggingConfig.
2. **No Parameter/Resource name collisions (cfn-lint E3007)**: Parameter names and Resource
   logical IDs share the same namespace in CloudFormation. If a Parameter is named
   `RuntimeResourcePolicy`, you CANNOT also have a Resource with logical ID
   `RuntimeResourcePolicy` — this causes E3007 ("Resources and Parameters must not share
   name") and E3004 (circular dependency). Use distinct names: e.g., Parameter
   `RuntimeResourcePolicyDoc` vs Resource `RuntimeResourcePolicyResource`.
3. Cross-parameter validation: deployment warning comment
4. Tag validation: required tag warning in Description
5. Dependent actions: document in parameter Description
6. **Custom Resource Lambda for non-registered CFN types**: When a CFN resource type is NOT
   registered in the CloudFormation registry (checked via `awsiac` MCP), implement it as a
   `AWS::CloudFormation::CustomResource` backed by a `AWS::Lambda::Function`:
   - The Lambda handler MUST implement Create, Update, and Delete actions via SDK calls
   - Include the Lambda code inline (ZipFile) or reference an S3 key
   - The custom resource MUST send SUCCESS/FAILED to the pre-signed URL
   - Do NOT leave TODO/stub comments like "implement Lambda handler" — write the actual handler
   - The Lambda execution role needs IAM permissions for the specific API calls, including
     `TagResource` and `UntagResource` — most AWS Create* APIs that accept tags also require
     separate tag permissions. Always include these in the custom resource role policy.
   - **CRITICAL — Scope `Resource` per action group (Checkov CKV_AWS_109 / CKV_AWS_111)**:
     NEVER use `Resource: "*"` for the custom-resource role policy. Split the Statement list
     into one statement per AWS resource type and scope the `Resource` to its specific ARN
     pattern using `!Sub "arn:${AWS::Partition}:<service-arn-namespace>:${AWS::Region}:${AWS::AccountId}:<resource-type>/*"`.
     Notes:
     - Control-plane action namespaces (e.g., `<service>-control:*`) often operate on
       *data-plane* ARN namespaces (e.g., `arn:...:<service>:...`). Verify against the
       service-authorization reference before assuming the namespaces match.
     - `iam:PassRole` → scope `Resource` to `arn:${AWS::Partition}:iam::${AWS::AccountId}:role/*`
       plus `iam:PassedToService` condition.
     - `kms:*` actions → scope `Resource` to `arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:key/*`
       (the key policy still gates access).
     - Permissions-management actions (`PutResourcePolicy`, `DeleteResourcePolicy`) → scope to
       only the resource types in the stack that accept resource-based policies (CKV_AWS_109).
     - Write actions (Create/Update/Delete on any resource type) → must be scoped to the ARN
       pattern for *that* resource type, not shared across types (CKV_AWS_111).
   - **CRITICAL — 4096-byte response limit**: CloudFormation custom resource responses are
     limited to 4096 bytes total. The `send_response` helper MUST:
     (1) Truncate `Reason` to 1000 chars max
     (2) Limit `Data` values to 200 chars each
     (3) If total response > 4000 bytes, strip `Data` to `{}` to stay under limit
     (4) Include `print()` logging of response size for CloudWatch debug visibility
     Exceeding 4096 bytes causes "Response object is too long" and stack CREATE_FAILED.
   - **CRITICAL — boto3/CLI service name**: The boto3 client name and AWS CLI command name
     for the target service MUST match the official SDK service identifier. Before generating,
     verify with `aws <service-name> help` or check `botocore.loaders.Loader().list_available_services()`.
     Common error: inserting extra hyphens or using inconsistent suffixes across files.
     The SAME correct name must be used in ALL generated files:
     CFN custom resource handler, Terraform `local-exec` provisioners, CDKTF provisioners,
     CDK `AwsCustomResource` SDK calls, SSM runbook steps, and Lambda remediators.
   - **Lambda Layer for new services**: When the target service's boto3 model is not yet
     bundled in the Lambda Python runtime, the CFN template MUST include a `Boto3LayerArn`
     parameter and attach it as a Layer to the custom resource Lambda. Make this conditional
     (`HasBoto3Layer` condition) so the template works with or without the layer.
   - **CRITICAL — SDK model validation for custom resources**: Before generating the custom
     resource Lambda handler, read the SDK model (via `awsknowledge` or `terraform` MCP) to
     verify for EAC

…(truncated)
