Terraform Module Testing and Decisions

Build a tested Terraform network module with CI gate and decision records.

Introduction

30 Second Summary

The US grid runs on 60 to 80 million distribution transformers. Many utilities design and order them to custom specifications, so one utility often cannot use another's spare. A network module is the same kind of part when it is rebuilt by whoever was on hand and the reasoning is never written down.

In this project, you will build a Terraform network module with a full offline test suite, a policy scan, and a CI (continuous integration: automated checks that run on every code push) gate. You will document every design decision so the next engineer who touches this module can review your reasoning instead of starting from zero.

What You'll Build

You will run five test assertions with no cloud account, push a bad commit and watch the gate turn red, push the fix and watch it turn green, then tag a release that a teammate can recover and run in one command.

By the end of this project, you'll have:

  • A tested network module whose assertions prove its subnet math, tag merging, and input rejection offline. No cloud credentials, no cost.
  • A CI gate in GitHub Actions that catches a known-bad commit (red) and passes on the fix (green), observable in your Actions history.
  • A set of decision records that name the alternative each tool choice beat, the tradeoff it accepted, and the trigger that would reverse it.
  • A closed Project board with every story done, every PR merged, a tagged release, and a one-command setup documented in the README.

Are there any prerequisites?

You need a GitHub account, Docker Desktop (or Podman) installed, and a flat-rate AI coding client you already use (Claude Code, Cursor, Codex CLI, or Gemini) or free local Ollama with Aider.

You should also have basic familiarity with HCL (HashiCorp Configuration Language, the declarative syntax Terraform uses) and terminal commands.

Before We Start

Before you dive in, lock in what you are about to build and why it matters. Every later decision in this project serves a named acceptance criterion, so the goal needs to be clear before you start.

Day-Zero Setup Gate

The devcontainer for this project carries the entire toolchain so your 45-minute build timer goes to the design, the tests, the fix, and the defense. This gate runs before the clock starts.

The approach is verify-first. Run each check command, and only take the install branch if the check fails.

In this step, get ready to:
  • Verify prerequisites and confirm Docker can run containers.
  • Create your GitHub organization and repo.
  • Launch the devcontainer, verify the toolchain, set up the Project board, and spot the seeded bug.
Verify your prerequisites

Your local machine only needs three things: a container engine, Git, and an editor with container support. Terraform, Checkov, and the GitHub Actions workflow are pre-staged inside the devcontainer and starter repo. Nothing is ever applied to a real cloud provider. Run each check below and only install if it fails.

Check your GitHub account
  • Visit github.com and confirm you are signed in.
  • If you do not have a GitHub account, create one at github.com/signup before continuing.
Check your container engine

The devcontainer needs a container engine to run. Check whether Docker (or Podman) is installed by running:

docker --version

✔️ I see a version number

Your container engine is ready. Move on to the next check.

ⓧ Command not found

Install Docker Desktop using the command for your operating system.

Mac

  • Install Docker Desktop via Homebrew by running:
brew install --cask docker
  • Open Docker Desktop from your Applications folder and wait for it to finish starting.
  • Run docker --version again to confirm it prints a version number.

Windows

  • Install Docker Desktop via winget by running:
winget install -e --id Docker.DockerDesktop
  • Open Docker Desktop from the Start menu and wait for it to finish starting.
  • Open a new terminal window and run docker --version to confirm it prints a version number.

Linux

  • Install Docker Engine using the official convenience script by running:
curl -fsSL https://get.docker.com | sh
  • Run docker --version to confirm it prints a version number.

Podman works too

If you prefer Podman, run podman --version instead. Both engines run the devcontainer image.

Check Git

Verify Git is installed by running:

git --version

✔️ I see a version number

Git is ready. Move on to the next check.

ⓧ Command not found

Install Git using the command for your operating system.

Mac

  • Install Git via Homebrew by running:
brew install git
  • Run git --version again to confirm it prints a version number.

Windows

  • Install Git via winget by running:
winget install --id Git.Git -e --source winget
  • Open a new terminal window and run git --version to confirm it prints a version number.

Linux

  • Install Git via apt by running:
sudo apt-get update && sudo apt-get install git
  • Run git --version again to confirm it prints a version number.
Check your code editor

You need one of these three editors to open the devcontainer. Pick the one you use and confirm it is installed.

VS Code

  • Check if VS Code is installed by running:
code --version
  • If you see a version number, you are all set. Skip ahead to cloning the template.
  • If you see command not found, install it using the instructions for your OS:

Mac

  • Install VS Code via Homebrew by running:
brew install --cask visual-studio-code
  • Run code --version again to confirm it prints a version number.

Windows

  • Install VS Code via winget by running:
winget install -e --id Microsoft.VisualStudioCode
  • Open a new terminal window and run code --version to confirm it prints a version number.

Linux

  • Install VS Code via apt by running:
sudo apt-get update && sudo apt-get install code
  • Run code --version again to confirm it prints a version number.

Cursor

  • Check if Cursor is installed by running:
cursor --version
  • If you see a version number, you are all set. Skip ahead to cloning the template.
  • If you see command not found, install it using the instructions for your OS:

Mac

  • Install Cursor via Homebrew by running:
brew install --cask cursor
  • Run cursor --version again to confirm it prints a version number.

Windows

  • Download the Cursor installer from cursor.com and run it.
  • Open a new terminal window and run cursor --version to confirm it prints a version number.

Linux

  • Download the Cursor installer from cursor.com and run it.
  • Run cursor --version to confirm it prints a version number.

Antigravity IDE

  • Check if Antigravity IDE is installed by running:
antigravity-ide --version
  • If you see a version number, you are all set. Skip ahead to cloning the template.
  • If you see command not found, install it using the instructions for your OS:

Mac

  • Install Antigravity IDE via Homebrew by running:
brew install antigravity-ide
  • Run antigravity-ide --version again to confirm it prints a version number.

Windows

  • Install Antigravity IDE via winget by running:
winget install Google.AntigravityIDE
  • Open a new terminal window and run antigravity-ide --version to confirm it prints a version number.

Linux

  • Download the Antigravity IDE installer from antigravity.google/download and run it.
  • Run antigravity-ide --version to confirm it prints a version number.
Create your GitHub organization

You only create a GitHub organization once. A repo in a personal account scatters your work across your profile, but an org groups everything into a single portfolio.

  • If you already have a GitHub org you want to use, skip ahead to confirming Docker is running.
  • If you need to create one: click your profile picture on GitHub, then click Settings.
  • In the Access section of the sidebar, click Organizations.
  • Click New organization and follow the prompts to create a free organization.

Why a GitHub organization?

Repos in a personal account pile up alongside forks, experiments, and half-finished side projects. An organization gives your portfolio work its own namespace: one URL, one GitHub Projects board, and one GitHub Actions quota shared across every project you build there.

Create your project folder
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running:
mkdir network-module
cd network-module
Confirm Docker is running

Docker being installed is not the same as Docker running. The devcontainer will not start if the Docker daemon is stopped.

  • Confirm Docker Desktop is running by running:
docker ps

✔️ I see an empty table or a list of containers

Docker Desktop is running and ready to host the devcontainer.

ⓧ Cannot connect to the Docker daemon

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Docker Desktop and press Enter to open it.
  • Wait for the whale icon in the system tray to stop animating.
  • Run docker ps again. You should now see the empty table.
Check the Dev Containers extension
  • Confirm the Dev Containers extension is installed by running the check for your editor:

VS Code

  • List your installed extensions by running:
code --list-extensions | grep -i devcontainers
  • If you see ms-vscode-remote.remote-containers in the output, the extension is installed.
  • If the output is empty, install the extension by running:
code --install-extension ms-vscode-remote.remote-containers
  • Run the code --list-extensions | grep -i devcontainers command again to confirm it now appears.

Cursor

  • List your installed extensions by running:
cursor --list-extensions | Select-String -Pattern "remote-containers"
  • If you see anysphere.remote-containers in the output, the extension is installed.
  • If the output is empty, install the extension by running:
cursor --install-extension ms-vscode-remote.remote-containers
  • Run the cursor --list-extensions | Select-String -Pattern "remote-containers" command again to confirm it now appears.

Antigravity IDE

  • List your installed extensions by running:
antigravity-ide --list-extensions | grep -i devcontainers
  • If you see ms-vscode-remote.remote-containers in the output, the extension is installed.
  • If the output is empty, install the extension by running:
antigravity-ide --install-extension ms-vscode-remote.remote-containers
  • Run the antigravity-ide --list-extensions | grep -i devcontainers command again to confirm it now appears.

Don't see the extension in the output?

Open the Extensions panel (Cmd+Shift+X on macOS, Ctrl+Shift+X on Windows), search for Dev Containers, and click Install. Reload the editor when prompted.

Help me install the Dev Containers extension

Check the Dev Container CLI

The devcontainer CLI reads a devcontainer.json file and builds or runs the container from your terminal. The troubleshooting path later in this step uses it as a fallback if your editor does not auto-detect the configuration.

  • Check if the Dev Container CLI is installed by running:
devcontainer --version

✔️ I see a version number

The Dev Container CLI is ready. Move on to opening the repo in your editor.

ⓧ Command not found

Install the Dev Container CLI using the instructions for your operating system.

Mac

  • Install the Dev Container CLI using the standalone install script by running:
curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh
  • Close and reopen your terminal.
  • Run devcontainer --version again to confirm it prints a version number.

Windows

  • Install the Dev Container CLI by running:
npm install -g @devcontainers/cli
  • Close and reopen your terminal.
  • Run devcontainer --version again to confirm it prints a version number.
Alternative: use the command palette

If you do not have npm installed, open the command palette with Ctrl+Shift+P (or Cmd+Shift+P on macOS), type Dev Containers: Install devcontainer CLI, and select the command. Close and reopen your terminal, then verify with devcontainer --version.

Linux

  • Install the Dev Container CLI using the standalone install script by running:
curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh
  • Close and reopen your terminal.
  • Run devcontainer --version again to confirm it prints a version number.
  • Open the network-module folder in your editor by running the command for your editor:

VS Code

code network-module

Cursor

cursor network-module

Antigravity IDE

antigravity-ide network-module
  • In the editor's file explorer sidebar, right-click in the empty space and select New Folder.
  • Name the folder .devcontainer.
  • Right-click the .devcontainer folder and select New File.
  • Name the file devcontainer.json.
  • Paste the following configuration into the file and save it:
{
  "name": "Terraform Network Module",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/devcontainers/features/terraform:1": {
      "version": "1.15"
    },
    "ghcr.io/devcontainers/features/github-cli:1": {},
    "ghcr.io/devcontainers/features/python:1": {
      "version": "3.12"
    }
  },
  "postCreateCommand": "pip install checkov && git config --global --add safe.directory /workspaces/network-module",
  "customizations": {
    "vscode": {
      "extensions": [
        "hashicorp.terraform"
      ]
    }
  }
}

What does this configuration do?

The image field pulls an official Ubuntu-based devcontainer as the foundation. The features section installs Terraform 1.15.x, the GitHub CLI, and Python 3.12. The postCreateCommand runs after the container is built and installs Checkov.

After saving the file, your editor detects the new devcontainer.json and shows a Reopen in Container notification in the bottom-right corner.

  • Make sure Docker Desktop is running before you accept the prompt.
  • Click Reopen in Container when the notification appears.
  • Wait for the container to build. The first build pulls the image and takes 1-2 minutes.

Don't see the Reopen in Container prompt?

If Docker Desktop is not running, the prompt will not appear. Open Docker Desktop from your system tray (Windows) or Applications folder (macOS) and wait for the whale icon to stop animating.

If you dismissed the notification or it never appeared, open the command palette with Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and type Dev Containers: Reopen in Container.

If the command palette option does not appear, confirm that the Dev Containers extension is installed (check the earlier step) and that your .devcontainer/devcontainer.json file is saved inside the network-module folder.

Help me get my devcontainer running

Verify the toolchain inside the devcontainer

Before you start the timer, confirm you are actually running inside the container and the toolchain resolves correctly.

  • Open a new terminal inside the editor by pressing Ctrl+` (Windows/Linux) or Cmd+` (macOS).
  • Prove you are inside the container (not your host machine) by running:
uname -a
  • You should see Linux followed by a kernel version. If you see PS C:\Users\... or a Windows-style prompt, your terminal is still on your local machine. Use the command palette (Ctrl+Shift+P or Cmd+Shift+P) and run Dev Containers: Reopen in Container again.
  • Confirm the tools resolve to container paths by running:
which terraform
which checkov
  • You should see paths like /usr/local/bin/terraform and /usr/local/bin/checkov. These Linux paths confirm the binaries live inside the container, not on your host.
  • Check the Terraform version by running:
terraform version
  • You should see version 1.7 or higher. Version 1.7 is the floor for provider mocking. The devcontainer ships 1.15.x, so any number at or above 1.7.0 confirms the feature is available.
  • Check the Checkov version by running:
checkov --version
  • You should see 3.3.x or higher.
Authenticate GitHub inside the container

The devcontainer is a fresh Linux environment. Your host machine's gh credentials do not carry over. Authenticate now so every git push and PR command works without errors later.

  • Authenticate the GitHub CLI inside the devcontainer by running:
gh auth login
  • Select GitHub.com, then HTTPS, then Yes to authenticate Git with your GitHub credentials, then Login with a web browser.
  • After the browser confirms authorization, return to your terminal.
  • Lock in the credential helper so git uses the container's gh for all pushes by running:
git config --global credential.helper '!gh auth git-credential'
  • Confirm authentication succeeded by running:
gh auth status
  • You should see your GitHub username and Logged in to github.com in the output.
  • Initialize a local Git repository by running:
git init -b main
Seed the starter module files

The Terraform standard module structure expects four files at the root: versions.tf, variables.tf, main.tf, and outputs.tf. You will also add a sealed answer key that you will score the AI against later.

  • In your editor's file explorer, right-click in the network-module root and select New File. Name it versions.tf and paste the following:
# Pin the Terraform version and declare the AWS provider
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}
  • Create variables.tf in the same folder and paste:
# CIDR block for the VPC — intentionally missing a validation block
variable "vpc_cidr" {
  description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
  type        = string
}

# Number of availability zones to spread subnets across
variable "az_count" {
  description = "Number of availability zones to use"
  type        = number
  default     = 3

  validation {
    condition     = var.az_count >= 1 && var.az_count <= 6
    error_message = "az_count must be between 1 and 6."
  }
}

# Tags applied to every resource the module creates
variable "tags" {
  description = "Tags to apply to all resources"
  type        = map(string)
  default     = {}
}
  • Create main.tf and paste:
# Create the VPC
resource "aws_vpc" "this" {
  cidr_block = var.vpc_cidr

  tags = merge(var.tags, {
    Name = "network-module-vpc"
  })
}

# Calculate how many bits to borrow for subnets
locals {
  newbits = ceil(log(var.az_count, 2))
}

# Create one subnet per availability zone
resource "aws_subnet" "this" {
  count      = var.az_count
  vpc_id     = aws_vpc.this.id
  cidr_block = cidrsubnet(var.vpc_cidr, local.newbits, count.index)

  tags = merge(var.tags, {
    Name = "network-module-subnet-${count.index}"
  })
}
  • Create outputs.tf and paste:
# Expose the VPC ID for downstream modules
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.this.id
}

# Expose subnet IDs as a list
output "subnet_ids" {
  description = "List of subnet IDs"
  value       = aws_subnet.this[*].id
}

# Expose the calculated subnet CIDR blocks
output "subnet_cidrs" {
  description = "List of subnet CIDR blocks"
  value       = aws_subnet.this[*].cidr_block
}
  • Create a docs folder inside network-module. Inside docs, create answer-key.md and paste:
# Sealed Answer Key — Do Not Edit

## Valid CIDR Inputs (must pass validation)

- `10.0.0.0/16` — standard private range, 3 AZs produce /18 subnets
- `172.16.0.0/12` — large private range, 2 AZs produce /13 subnets
- `192.168.0.0/24` — small private range, 2 AZs produce /25 subnets

## Invalid CIDR Inputs (must fail validation)

- `not-a-cidr` — not an IP address at all
- `10.0.0.0` — missing prefix length
- `10.0.0.0/33` — prefix length exceeds 32
- `256.0.0.0/16` — octet exceeds 255
- `10.0.1.0/16` — host bits set (not a network address)

## Subnet Arithmetic Assertions

| VPC CIDR | az_count | Expected Subnet CIDRs |
|----------|----------|----------------------|
| 10.0.0.0/16 | 3 | 10.0.0.0/18, 10.0.64.0/18, 10.0.128.0/18 |
| 172.16.0.0/12 | 2 | 172.16.0.0/13, 172.24.0.0/13 |

## What Mocks Cannot Prove

- Content validity (mock_provider accepts any string)
- Real subnet allocation conflicts
- Provider-side defaults not in config
- Actual network reachability

What is the Terraform standard module structure?

HashiCorp publishes a conventional file layout for reusable modules: versions.tf pins the Terraform and provider versions, variables.tf declares inputs, main.tf holds the resources, and outputs.tf exposes values for downstream consumers.

Following this structure enables registry publishing, automatic documentation generation, and team reuse without reading the source.

  • Confirm all files exist by running:
ls && ls docs/
  • You should see main.tf, outputs.tf, variables.tf, versions.tf, and the docs folder in the first listing, and answer-key.md in the second.
  • Initialize Terraform to confirm the provider resolves by running:
terraform init
  • You should see Terraform has been successfully initialized and a line mentioning hashicorp/aws with a version number.

Seeing a network error during terraform init?

The container needs internet access to download the AWS provider on first run. If you see a timeout or DNS error, confirm Docker Desktop has network access and try again. If the error persists, check that your firewall or VPN is not blocking registry.terraform.io.

Help me fix my terraform init network issue

  • Add a .gitignore to keep Terraform's local plugin cache out of version control. Create a .gitignore file in the network-module root and paste the following:
# Terraform plugin cache (contains provider binaries, often 800+ MB)
.terraform/

# Crash log files
crash.log
crash.*.log

# Override files (local developer overrides)
*.tfvars
!terraform.tfvars
override.tf
override.tf.json
*_override.tf
*_override.tf.json

Why exclude .terraform/?

Running terraform init downloads provider binaries into a local .terraform/ directory. The AWS provider alone is ~850 MB. GitHub rejects any file over 100 MB, so pushing .terraform/ breaks your push. The .terraform.lock.hcl file (which pins the exact provider version) is NOT in the ignore list and should be committed.

Commit and create the remote repo

You have files locally but no remote repo yet. This command commits everything, creates the GitHub repo in your org, and pushes in one shot.

  • Stage and commit all starter files by running:
git add .
git commit -m "seed: starter module, answer key, and devcontainer"
  • Create the GitHub repo and push your commit by running:
gh repo create [[ORG_NAME="your-org-name"]]/network-module --private --source=. --remote=origin --push
  • You should see output confirming the repo was created on GitHub and the local branch was pushed. This means main now exists on GitHub and you can branch off it.

Seeing 'error: src refspec main does not match any'?

This means you have no commits yet. Run git status to confirm you have files to commit. If the output shows untracked files, run the git add . and git commit commands above first, then try again.

help me push my starter files to main

Create the Project board

The board tracks every story (a slice of work a person can finish and close) from open to done.

  • On GitHub, click your profile picture, then click Organizations.
  • Click the name of your organization.
  • Under the organization name, click Projects.
  • Click New project.
  • Under Start from scratch, click Board.
  • Name the project Defensible network module.
  • Make sure Import items from repository is checked, showing Open issues from network-module.
  • Click Create project.
  • Create the five stories as GitHub Issues by running:
gh issue create --title "Design artifacts" --body "Requirements brief, ADRs, diagram, and plan"
gh issue create --title "Test matrix and AI scoring" --body "Write test cases and score the AI proposals against the sealed answer key"
gh issue create --title "Module fix and scan" --body "Fix the seeded bug and pass the Checkov scan"
gh issue create --title "Gate proof" --body "Wire the GitHub Actions gate and prove it catches a bad commit"
gh issue create --title "Readout, teach-back, and close" --body "Defend decisions, teach back, and close the repo"

You should see five issues created (numbered #1 through #5).

What is a story?

A story is a slice of work a person can finish and close in one sitting. Each story will be worked on its own branch, merged through a PR (pull request: a proposed code change submitted for review before merging), and closed when the PR merges.

Identify the seeded bug and note the answer key

The module files you just created include an intentional defect you will fix later.

  • Look at the variables.tf you just pasted. Compare vpc_cidr to az_count.
  • Notice that az_count has a validation block, but vpc_cidr declares type = string with no validation at all.
variable "vpc_cidr" {
  description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
  type        = string
}

Why does this matter?

Without a validation block, the module accepts any string as a CIDR (slash notation for an address block, such as 10.0.0.0/24). A garbage value like "not-a-cidr" passes unchecked. This is the defect you will fix in Step 4.

  • The docs/answer-key.md you created earlier is the sealed edge-case list the AI will be scored against later. Do not edit it from this point forward.

Because you checked Import items from repository when creating the board, all five issues auto-appeared as cards. When a PR merges with Closes #N in the body, the issue closes and moves to Done automatically.

  • Switch to your browser and confirm your GitHub Projects board shows five open stories.

Your environment is locked and loaded. Next up, you will design before you build: writing the requirements brief, decision records, diagram, and plan before a single test is drafted.

Design Before Build

Your environment is locked in. Terraform and Checkov are verified, the board is live, and you know exactly where the seeded bug sits.

Now comes the design phase. The requirements brief, decision records, diagram, and plan all get committed before a single test is written.

In June 2024 the President's National Infrastructure Advisory Council reported that utilities design transformers to their own specifications and recommended standard designs to cut spare-parts complexity. A module shipped without reasoning recorded is that same object: custom-spec'd, unshared, rebuilt from scratch by the next team. Documentation is what turns a default into a design.

In this step, get ready to:
  • Draft a requirements brief with numbered acceptance criteria.
  • Write four Architecture Decision Records (ADRs) each naming an alternative and a tradeoff.
  • Commit a diagram and a 45-minute budgeted plan, then merge into main.
Create the branch and draft the requirements brief

Every story lives on its own branch, goes through a PR (pull request: a proposed code change submitted for review before merging), and closes its ticket on merge.

  • Create a new branch for Story 1 by running this command in your devcontainer terminal:
git checkout -b story/1-design

Seeing 'dubious ownership' error?

Inside the devcontainer, Git sometimes detects a mismatch between the host user who owns the folder and the vscode user running inside the container. Fix it by running:

git config --global --add safe.directory /workspaces/network-module

Then re-run the git checkout -b story/1-design command.

help me fix the dubious ownership error in my devcontainer

The requirements brief translates the product manager's ask into testable acceptance criteria. Each criterion is something you can prove true or false at the end of the build.

  • Draft the requirements brief by sending this prompt to your AI client:
Create docs/requirements-brief.md with five numbered acceptance criteria for a network module that:
1. Produces verified subnet CIDRs for 1 to 6 AZs.
2. Rejects a malformed CIDR so it fails closed.
3. Runs the full test suite with no cloud account.
4. Passes the policy scan with every exception named.
5. Catches a bad commit through the CI gate.

Review the draft the AI produces. Accept or reject each criterion. Your final file should follow this structure:

# Requirements Brief

Product ask: A provable network module with a defended tool choice.

## Acceptance Criteria

- AC-1: The module produces verified subnet CIDRs for 1 to 6 AZs.
- AC-2: A malformed CIDR fails closed (rejected at variable assignment).
- AC-3: The test suite runs with no cloud account.
- AC-4: The policy scan exits clean with every exception named.
- AC-5: The gate catches a bad commit.

What makes a good acceptance criterion?

Each criterion is binary: you can prove it true or false at the end of the build. "Works correctly" is not testable. "Produces verified subnet CIDRs for 1 to 6 AZs" is testable because you can run the assertions and see pass or fail.

  • Confirm the file exists by running:
ls docs/

You should see requirements-brief.md in the output.

Don't see the file?

If your AI client created the file in a different location, move it into docs/ with mv requirements-brief.md docs/. Make sure you are running commands from the repo root (the network-module folder).

help me find where my AI client saved the requirements brief

Draft four decision records

An Architecture Decision Record (ADR) names four things: the decision, the alternative it beat, the tradeoff accepted, and the trigger that would reverse it. Four decisions matter for this module.

  • Create the decisions directory by running:
mkdir -p docs/decisions
  • Draft the four ADR files by sending this prompt to your AI client:
Create four ADR files in docs/decisions/. Each file must contain exactly four sections: Decision, Alternative, Tradeoff, and Reversal Trigger.

adr-1.md — Terraform (BSL-1.1, IBM/HashiCorp) over OpenTofu (MPL-2.0, Linux Foundation). Both support mock_provider. Difference is license and governance. Reversal trigger: license change or governance shift.

adr-2.md — Mocked offline tests over LocalStack or a real sandbox account. Tradeoff: proves wiring and arithmetic, not integration. Reversal trigger: integration proof required.

adr-3.md — Idempotence not proven here. Mocks are deterministic, not stateful. Real proof needs terraform apply then terraform plan against real state. The test tool has no built-in no-changes check. Reversal trigger: account available.

adr-4.md — Checkov (Apache-2.0, 1000+ built-in checks, no account needed) over tfsec (deprecated) or Sentinel (paid HashiCorp product). Gate is zero-fail with named skips because severity filtering needs a Bridgecrew API key. Reversal trigger: Bridgecrew API key available.

Use this structure as a template for each file:

# ADR-1: Terraform Over OpenTofu

## Decision
Use Terraform 1.15.x (BSL-1.1, IBM/HashiCorp).

## Alternative
OpenTofu (MPL-2.0, Linux Foundation, current stable 1.12.5).

## Tradeoff
BSL-1.1 license and IBM ownership accepted.
Both support mock_provider. Difference is license and governance.

## Reversal Trigger
License change or governance shift that blocks the use case.

Why write the reversal trigger?

The reversal trigger names the condition under which you would undo this decision. Without it, the next engineer either blindly keeps it or blindly rewrites it. With it, they check one condition and know whether to proceed or revisit.

  • Review each ADR the AI produces. Confirm it has all four sections and the content matches the summaries above.
  • Verify all four files exist by running:
ls docs/decisions/

You should see adr-1.md, adr-2.md, adr-3.md, and adr-4.md listed.

Missing one or more ADR files?

Some AI clients create all files in a single prompt. Others need one prompt per file. If files are missing, prompt again for the specific ADR number and point the AI at the docs/decisions/ directory.

help me create the missing ADR files in docs/decisions/

Draw the diagram, write the plan, and merge

The diagram shows how the pieces connect. The plan budgets your remaining 45 minutes across six tasks.

  • Prompt your AI client to create docs/diagram.md with a text diagram showing the module inputs/outputs, the test file with mock_provider, the Checkov scan path, and the GitHub Actions gate. Here is the expected shape:
# Architecture Diagram

```
variables.tf          main.tf           outputs.tf
(vpc_cidr,     -->   (VPC, subnets,  -->  (vpc_id,
 az_list, tags)       security group)      subnet_cidrs)
       |                                      |
       v                                      v
tests/network.tftest.hcl  <-- mock_provider (no account)
       |
       v
checkov -d . --framework terraform  (static scan)
       |
       v
.github/workflows/  (Actions gate: test + scan on push)
```
  • Prompt your AI client to create docs/plan.md with six budgeted items. The total must sum to 45 minutes:
# Build Plan

| # | Task | Budget |
|---|------|--------|
| 1 | Brief and ADRs | 10 min |
| 2 | Diagram and plan | 5 min |
| 3 | Test matrix and AI scoring | 8 min |
| 4 | Module fix and scan | 7 min |
| 5 | Gate proof | 5 min |
| 6 | Readout, teach-back, review, close | 10 min |

**Total: 45 min**

Why budget to the minute?

A plan without time constraints expands to fill whatever time is available. Budgeting each task forces you to scope the work before you start. If a task takes longer than its budget, that is signal to cut scope or split the task, not to silently extend.

  • Verify all design docs are in place by running:
ls docs/ docs/decisions/

You should see requirements-brief.md, diagram.md, plan.md in docs/ and the four ADR files in docs/decisions/.

  • Stage and commit all design artifacts by running:
git add docs/
git commit -m "story/1: design artifacts (brief, ADRs, diagram, plan)"
  • Push the branch by running:
git push -u origin story/1-design

Seeing credential helper errors or 'not absolute' warnings?

Your host machine's git config can leak into the devcontainer, causing credential helper errors. On Windows you may see a path like gh.exe: not found. On macOS you may see osxkeychain errors. The safe.directory ... not absolute warning is harmless noise on any OS. Ignore it.

If the push fails or prints credential errors, fix it by re-running the auth setup from Step 1:

gh auth login
git config --global credential.helper '!gh auth git-credential'

After running these two commands, retry the push.

help me fix credential helper errors in my devcontainer

  • Open the PR by running:
gh pr create --title "Story 1: Design artifacts" --body "Brief, ADRs, diagram, plan" --base main

Seeing an error from gh pr create?

If you see To get started with GitHub CLI, please run: gh auth login — run gh auth login inside the devcontainer terminal and follow the prompts (GitHub.com, HTTPS, Yes, Login with a web browser). Then fix the credential helper:

git config --global credential.helper '!gh auth git-credential'

If you see No commits between main and story/1-design or Base sha can't be blank — main does not exist on the remote yet. Run this recovery sequence to create it and rebase your branch on top:

# Create an empty main branch as the PR base
git checkout --orphan main
git commit --allow-empty -m "chore: initialize repository"
git push -u origin main
gh repo edit --default-branch main

# Rebase story/1-design on top of main so the PR has a real diff
git checkout story/1-design
git rebase main
git push -u origin story/1-design --force-with-lease

After the rebase, retry the gh pr create command. It succeeds because main now exists and story/1-design is one commit ahead of it.

help me fix my gh pr create error inside the devcontainer

  • Merge the PR by running:
gh pr merge --merge --delete-branch

PR merge failed?

If gh pr merge fails with a permission error, confirm you are authenticated by running gh auth status. If the PR has merge conflicts, run git pull origin main on your branch first, resolve any conflicts, then push and try the merge again.

help me merge my design artifacts PR on GitHub

  • Switch back to main by running:
git checkout main
git pull
  • Go to your Project board in your GitHub organization.
  • Drag Story 1 (Design artifacts) to Done.

Before you check, predict: how many stories should still be open on the board?

You should see four open stories (2 through 5) and one closed story (1). The design phase is complete.

The design is committed and the first story is closed. Next up, you will draft the test matrix using your AI client and score its proposals against the sealed answer key.

Draft the Test Matrix and Score the AI

Your design is committed. The requirements brief, four ADRs, diagram, and minute plan are all in place. Now it is time to write the tests that prove the module works.

The tests/ directory is empty. You will use your AI client to draft the test matrix, then score its proposals against the pre-staged sealed answer key. One test case will expose the seeded bug: the module has no input validation, so a malformed CIDR passes through unchecked.

In this step, get ready to:
  • Draft a test file with five run blocks using your AI client and verify the CIDR arithmetic by hand.
  • Score the AI's adversarial proposals against the sealed answer key and record the results.
  • Run the tests and observe the designed obstacle: one case fails because the module lacks a validation block.
Create the branch and prompt your AI client

Each story lives on its own branch so the PR (pull request: a proposed code change submitted for review before merging) cleanly references one ticket.

  • Create the branch for story 2 by running this command:
git checkout -b story/2-test-matrix
  • In your AI client, send the following prompt (adjust wording to your tool):
Draft a test file for this network module with five run blocks using mock_provider.
The file should go in tests/network.tftest.hcl.
Include cases for: three AZs with a /16 VPC, two AZs with a /20 VPC,
tag merging, a multi-region alias, and a malformed CIDR rejection.
The malformed CIDR case (expect_failures) must be the last run block in the file.

Your AI will produce a draft. Before accepting it, you need to verify the CIDR arithmetic by hand.

How to verify the subnet math

The module uses cidrsubnet(prefix, newbits, netnum) where newbits = ceil(log(az_count, 2)). Work through two cases:

  • A /16 VPC with 3 AZs: ceil(log(3, 2)) = 2 newbits, so subnets are /18. First subnet = 10.0.0.0/18, second = 10.0.64.0/18.
  • A /20 VPC with 2 AZs: ceil(log(2, 2)) = 1 newbit, so subnets are /21. First subnet = 172.16.0.0/21, second = 172.16.8.0/21.

Compare every assertion the AI proposed against these hand-calculated values. Accept correct ones. Reject wrong ones.

  • Verify that the AI's Case 1 assertions match these exact values:
# Case 1: Three AZs with a /16 VPC
assert {
  condition     = output.subnet_cidrs[0] == "10.0.0.0/18"
  error_message = "First subnet CIDR should be 10.0.0.0/18, got ${output.subnet_cidrs[0]}"
}

assert {
  condition     = output.subnet_cidrs[1] == "10.0.64.0/18"
  error_message = "Second subnet CIDR should be 10.0.64.0/18, got ${output.subnet_cidrs[1]}"
}
  • Verify that the AI's Case 2 assertion matches this value for the second subnet (a /20 VPC with 2 AZs):
# Case 2: Two AZs with a /20 VPC
assert {
  condition     = output.subnet_cidrs[1] == "172.16.8.0/21"
  error_message = "Second subnet CIDR should be 172.16.8.0/21, got ${output.subnet_cidrs[1]}"
}

What does this verification prove?

AI clients can produce plausible-looking CIDR values that are arithmetically wrong. Hand-calculating the expected subnets before accepting the AI's output is how you catch those errors. The formula cidrsubnet("10.0.0.0/16", 2, 1) adds 2 bits to the prefix (making it /18), then selects the second block (index 1), which starts at 10.0.64.0.

💡 Valid CIDR math does not mean valid AWS VPC CIDR

AWS only allows VPC prefix lengths between /16 and /28. A CIDR like 172.16.0.0/12 is mathematically correct (the subnet arithmetic works), but the AWS provider rejects it at plan time with expected "cidr_block" to contain a network Value with between 16 and 28 significant bits.

If your AI proposes a test case with a prefix shorter than /16 or longer than /28, reject it. Even mock_provider enforces this schema check.

Score the AI's adversarial proposals

Now you flip the AI into a reviewer role and score its output against the sealed answer key that was pre-staged in the repo before you cloned it.

  • Send this second prompt to your AI client:
Act as a security reviewer. Propose failing test cases for this network module
that I have not already written.
  • Open docs/answer-key.md (the sealed edge-case list pre-staged in the repo).
  • Compare each AI proposal against the answer key. Classify every proposal as Kept (correct and added to the suite), Rejected (wrong or irrelevant), or Missed (answer-key cases the AI did not propose).
  • Calculate coverage: Kept / (Kept + Missed).
  • Record your results by creating docs/ai-scorecard.md with the counts and the coverage percentage.

Why does Missed = 0 raise a flag?

If the AI proposed every case in the answer key and you missed nothing, either the key was too easy or the proposals were rubber-stamped without review. The scoring formula rewards genuine edge-case discovery and penalizes missed cases. A run that keeps every case and adds nothing means the AI's work was accepted without scrutiny.

  • Confirm docs/ai-scorecard.md exists by running:
ls docs/ai-scorecard.md

You should see the file listed in the output.

File not listed?

Make sure you created the file inside the docs/ directory at the root of your repo, not inside a subdirectory. help me find where I accidentally saved ai-scorecard.md

Finalize the test file and run it

Your test file needs five run blocks in its final form. Three test valid inputs, one tests multi-region routing with a provider alias, and one tests that a malformed CIDR is rejected.

  • Confirm your tests/network.tftest.hcl starts with both mock provider declarations:
mock_provider "aws" {}

mock_provider "aws" {
  alias = "secondary"
}
  • Confirm your Case 4 uses the providers attribute to route through the secondary alias:
# Case 4: Multi-region alias
run "multi_region_alias" {
  command = plan

  providers = {
    aws = aws.secondary
  }

  variables {
    vpc_cidr           = "192.168.0.0/16"
    availability_zones = ["ap-southeast-1a"]
    tags               = { Region = "ap-southeast-1" }
  }

  assert {
    condition     = output.subnet_count == 1
    error_message = "Expected 1 subnet with secondary provider"
  }
}
  • Confirm your Case 5 uses expect_failures to assert that a malformed CIDR is rejected:
# Case 5: Malformed CIDR rejected (fail closed)
run "rejects_malformed_cidr" {
  command = plan

  variables {
    vpc_cidr           = "not-a-cidr"
    availability_zones = ["us-east-1a"]
    tags               = {}
  }

  expect_failures = [var.vpc_cidr]
}

What does expect_failures do?

The expect_failures attribute tells Terraform that the listed object should fail its custom condition. The test passes only if that object reports an issue. If the object does NOT fail, the test itself fails. This is how you test negative paths: you declare what should break, and Terraform confirms it broke.

💡 Why must Case 5 be last?

When a run block hits a provider or plan error (not a failed assertion, an actual error), Terraform skips every later run in the same file. Before the vpc_cidr validation block exists, Case 5 passes "not-a-cidr" all the way to the provider, which hard-errors on it. That error skips anything below.

By placing Case 5 last, there is nothing left to skip. Cases 1 through 4 use provider-legal CIDRs (/16 through /28), so they plan cleanly under mock_provider and their assertions run normally.

Before you run the tests, think about what will happen. Case 5 expects var.vpc_cidr to fail its validation. But look at variables.tf. Does vpc_cidr have a validation block?

  • Predict: do you think all five cases will pass, or will one fail? Why?
  • Run the test suite:
terraform test

You will see 4 passed, 1 failed with zero skipped runs. Cases 1 through 4 pass because mock_provider accepts their legal CIDRs. Case 5 fails because expect_failures references a validation that does not exist yet.

The rejects_malformed_cidr case fails. This is expected.

Case 5 declares expect_failures = [var.vpc_cidr]. Terraform looks for a custom validation condition on var.vpc_cidr and finds none. Because the expected failure never fires, Terraform reports the test as failed. The mock_provider (a provider mock that fakes cloud API responses so tests run with no account) silently accepted the garbage string "not-a-cidr" without complaint.

This is the designed obstacle. Without input validation, the module does not fail closed. The fix comes in the next step.

  • Stage your two new files, commit them to the branch, and push to GitHub by running these commands:
git add tests/ docs/
git commit -m "Add test matrix and AI scorecard (story 2)"
git push -u origin story/2-test-matrix

What should I see?

git add tests/ docs/ stages everything inside those two directories (catching any extra files your AI created). git commit saves them as a snapshot on your local branch. git push -u origin story/2-test-matrix uploads the branch to GitHub and sets the upstream tracking reference.

If you already committed these files earlier (e.g. your AI client auto-committed), you will see nothing added to commit and Everything up-to-date. That is fine. Your files are already on the remote branch. Skip straight to the PR.

  • Create the pull request from your branch into main by running:
gh pr create --title "Story 2: Test matrix and AI scoring" --body "Closes #2" --base main

You should see a URL printed to the terminal confirming the PR was created. Do not merge it yet. The PR title references story 2, and Closes #2 in the body links the PR to issue #2 on your Project board so it auto-closes when merged.

Why leave the PR open?

Story 2 is not done yet. The failing test proves work remains. You will merge this PR in the next step after adding the validation block that makes all five tests pass.

Leaving a PR open with a known-failing test is normal workflow. It signals to reviewers (and to your future self) that the branch is intentionally incomplete. The PR becomes the tracking surface for the remaining work.

✔️ Awesome, I've got everything!

Great. Confirm you have saved tests/network.tftest.hcl and docs/ai-scorecard.md, and that your PR is open on GitHub.

ⓧ I'd like to double check the full code

Your tests/network.tftest.hcl should contain these sections in order. Scroll up and confirm each is present:

  • Two mock_provider "aws" declarations (one default, one with alias = "secondary").
  • Case 1: run "basic_three_az" with 3 AZs, a /16 VPC, and assertions checking subnet count = 3, first subnet = 10.0.0.0/18, second subnet = 10.0.64.0/18.
  • Case 2: run "two_az_medium_vpc" with 2 AZs, a /20 VPC, and assertions checking subnet count = 2 and second subnet = 172.16.8.0/21.
  • Case 3: run "tags_are_merged" with caller tags Environment = "staging" and Team = "platform", asserting all three tags (including default ManagedBy = "terraform") appear.
  • Case 4: run "multi_region_alias" using providers = { aws = aws.secondary } and asserting subnet count = 1.
  • Case 5 (must be last): run "rejects_malformed_cidr" with vpc_cidr = "not-a-cidr" and expect_failures = [var.vpc_cidr].

You have exposed the gap. The module accepts garbage input because it has no validation block. In the next step, you will add the validation, make all five tests pass, and scan clean with Checkov.

Build the Module and Scan Clean

The failing test from the last step proves exactly what the module is missing. The mock accepted "not-a-cidr" without complaint because mocks do not validate content. Without input validation, a garbage string sails through to the provider (the plugin that talks to a cloud API) unchecked.

Adding a validation block makes the module reject bad input at variable assignment, before the provider ever sees it. That is the fail-closed principle in action. Then a Checkov scan proves no static misconfigurations remain.

In this step, get ready to:
  • Add the validation block that makes the module fail closed on bad input.
  • Run Checkov and resolve any findings to zero failed checks.
  • Merge both PRs and prove the gate catches the defect class (red then green).
Wire the GitHub Actions workflow

For merges into main to trigger CI runs, the workflow file must already exist on main. You will push it directly to main now, before any PRs merge, so that every subsequent merge (story/2, story/3) fires the gate.

  • Switch to main and create the workflow directory by running:
git checkout main
mkdir -p .github/workflows
  • Create .github/workflows/ci.yml and paste the following:
name: CI Gate

on:
  push:
    branches: [main]

jobs:
  test-and-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.15"

      - name: Init
        run: terraform init

      - name: Test
        run: terraform test

      - name: Install Checkov
        run: pip install checkov

      - name: Scan
        run: checkov -d . --framework terraform

What does this workflow do?

This workflow runs on every push to main. It initializes Terraform, runs terraform test (the full test matrix), and runs the Checkov scan. No AI step runs inside CI. The gate is deterministic and costs nothing beyond Actions minutes.

  • Commit and push the workflow to main by running:
git add .github/
git commit -m "ci: add test-and-scan gate workflow"
git push origin main
  • Return to your story/2-test-matrix branch by running:
git checkout story/2-test-matrix

The workflow now exists on main. Every future merge into main triggers it automatically. Your working tree is still clean, so no stash was needed.

Add the validation block to fail closed

The fix is a single validation block inside the vpc_cidr variable. It uses can(cidrhost(var.vpc_cidr, 0)) to reject any string that is not valid CIDR notation at variable assignment.

  • Create a new branch from your current story/2 branch by running:
git checkout -b story/3-fix-and-scan
  • Open variables.tf in your editor.
  • Find the vpc_cidr variable declaration. It currently looks like this:
variable "vpc_cidr" {
  description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
  type        = string
}
  • Replace that entire vpc_cidr block with the following version that includes the validation:
variable "vpc_cidr" {
  description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
  type        = string

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "vpc_cidr must be a valid CIDR block (e.g., 10.0.0.0/16)."
  }
}

What does this validation do?

can(cidrhost(var.vpc_cidr, 0)) tries to compute a host address from the given CIDR. If the string is not valid CIDR notation, cidrhost errors out. The can() wrapper catches the error and returns false, which fails the condition.

This validation fires at variable assignment, before the provider or any resource logic runs. A malformed CIDR stops the run immediately instead of passing the bad value downstream.

  • Save variables.tf.

Before you run the tests, predict: will all five cases pass now, or will something else break?

  • Run the test suite to confirm the fix by running:
terraform test

You should see Success! 5 passed, 0 failed. The expect_failures = [var.vpc_cidr] directive in Case 5 catches the validation error on "not-a-cidr" and counts it as a pass. The module now fails closed on bad input.

Still seeing a failure on Case 5?

  • Confirm the validation block is nested inside the variable "vpc_cidr" block, not after its closing brace.
  • Check that the condition uses can(cidrhost(var.vpc_cidr, 0)) exactly. A typo in the function name or missing var. prefix causes a parse error instead of a validation failure.
  • help me debug my Terraform validation block for vpc_cidr

🙋‍♀️ Seeing a failure on a case other than Case 5?

If a run fails with expected "cidr_block" to contain a network Value with between 16 and 28 significant bits, your AI generated a VPC CIDR with a prefix shorter than /16 (e.g. 172.16.0.0/12). The CIDR is mathematically valid (your variable validation passes it), but the AWS provider schema rejects VPC CIDRs outside /16 to /28.

Fix: change that run's vpc_cidr to a prefix within /16 to /28, then update its assertions to match the new subnet math. For example, replace 172.16.0.0/12 with 172.16.0.0/20 and recalculate subnets using cidrsubnet(prefix, ceil(log(az_count, 2)), netnum).

help me fix my test case that uses an illegal VPC CIDR prefix

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

variable "vpc_cidr" {
  description = "CIDR block for the VPC (e.g., 10.0.0.0/16)"
  type        = string

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "vpc_cidr must be a valid CIDR block (e.g., 10.0.0.0/16)."
  }
}

variable "availability_zones" {
  description = "List of availability zone names to spread subnets across"
  type        = list(string)

  validation {
    condition     = length(var.availability_zones) > 0
    error_message = "At least one availability zone is required."
  }
}

variable "tags" {
  description = "Additional tags to merge with module defaults"
  type        = map(string)
  default     = {}
}
Run Checkov and resolve findings

The tests prove the module's logic is correct. Now a static analysis scan proves there are no security misconfigurations in the Terraform configuration itself.

  • Run the Checkov scan against your module directory by running:
checkov -d . --framework terraform

You should see Passed checks: 3, Failed checks: 2. The three subnet checks (CKV_AWS_130) pass. Two checks fail on the VPC resource: CKV2_AWS_12 (default security group restricts all traffic) and CKV2_AWS_11 (VPC flow logging enabled).

What do these failures mean?

CKV2_AWS_12 wants an aws_default_security_group resource that locks down the VPC's default security group to zero ingress and zero egress rules. CKV2_AWS_11 wants an aws_flow_log resource attached to the VPC.

Both are valid hardening steps for a production VPC, but they are out of scope for this module. This module creates a VPC and subnets. Default SG lockdown and flow logging belong to the consuming root module or a dedicated security module that wraps this one.

Per ADR-4, the gate is zero failed checks with every exception named by its check ID. These two checks are out of scope, so you skip them with a documented reason in a .checkov.yaml file.

  • Create a .checkov.yaml file in the project root by pasting the following:
# Named skips for checks outside this module's scope.
# Each skip must be argued per ADR-4.
skip-check:
  - CKV2_AWS_12  # Default SG lockdown is the consuming root module's responsibility
  - CKV2_AWS_11  # Flow logging is the consuming root module's responsibility

What does this file do?

Checkov auto-discovers .checkov.yaml in the scanned directory. Checks listed under skip-check are excluded from the run entirely. The inline YAML comments serve as the audit trail for why each skip exists.

  • Save .checkov.yaml.
  • Re-run the scan to confirm the gate is clean by running:
checkov -d . --framework terraform

You should now see Passed checks: 3, Failed checks: 0, Skipped checks: 0. The two out-of-scope checks no longer run, and every remaining check passes. The gate is clean.

Still seeing a failed check?

  • Confirm the YAML key is skip-check (with a dash). Using skip_checks or skip_check (underscore) causes Checkov to ignore the file silently.
  • Confirm .checkov.yaml is in the same directory you pass to -d. If you run checkov -d . from the repo root, the file must be in the repo root.
  • Check for typos in the check IDs. They must be exactly CKV2_AWS_12 and CKV2_AWS_11 (note the 2 after CKV).
  • help me fix my .checkov.yaml so Checkov skips the right checks
Merge both PRs and prove the gate catches the defect

You now have two PRs ready: story/2 (the tests, with the failing case) and story/3 (the fix). Merging them in order produces two distinct Actions runs that prove the gate works.

  • Commit and push your changes on story/3-fix-and-scan by running:
git add -A
git commit -m "Add validation block to vpc_cidr, scan clean"
git push origin story/3-fix-and-scan
  • Create the story/3 PR by running:
gh pr create --base main --head story/3-fix-and-scan \
  --title "Story 3: Add validation block, scan clean" \
  --body "Closes story 3 - adds validation block and clean Checkov scan"

With the story/3 PR queued, merge story/2 first. This adds the test matrix to main without the validation fix.

  • Merge the story/2 PR (the test matrix) into main by running:
gh pr merge story/2-test-matrix --merge

Before you check the result, predict: will this Actions run pass or fail? Remember that main now has the test expecting a validation error, but no validation block exists on main yet.

The Actions run shows red. The rejects_malformed_cidr test fails because main has no validation block. This is exactly the gap your story/3 branch fixes.

  • Merge the story/3 PR by running:
gh pr merge story/3-fix-and-scan --merge

The new run shows green. All five tests pass and Checkov reports zero failed checks.

What does red-then-green prove?

Two commits in the Actions history demonstrate the gate catches the exact defect class: a missing input validation that lets bad data pass unchecked. The red run proves the gate detects the problem. The green run proves the fix resolves it. Together they satisfy acceptance criterion AC-5.

  • On your Project board, move stories 2 and 3 to Done. You should see stories 1, 2, and 3 closed with stories 4 and 5 still open.

Actions run not appearing?

  • Confirm the workflow file exists at .github/workflows/ci.yml on main. If you skipped the earlier substep or the push failed, the file is not on main and merges will not trigger any run.
  • Private repos on the free plan have 2,000 Actions minutes per month. If you have exceeded this, the run will be queued.
  • help me debug my GitHub Actions workflow

The module now rejects bad input at the gate, the test suite proves it offline, and the policy scan confirms no static misconfigurations. Next up, you will document the gate proof and deliver the teach-back that defends each design decision.

Wire the Gate, Defend the Decisions, and Close the Repo

Defend the Decisions and Close the Repo

Your module passes all five tests, scans clean, and the GitHub Actions history already tells the story: red on the story/2 merge (missing validation), green on the story/3 merge (validation added). That pair of commits IS the gate proof.

Now you document what the gate proves, defend each design decision in a teach-back, and close the repo so the next engineer can run it in one command. Delivery means every ticket done, every branch merged, and the reasoning written down.

In this step, get ready to:
  • Document the gate proof with the red and green commit SHAs.
  • Draft the two-audience readout, teach-back, and after-action review.
  • Close all stories, tag the release, and record elapsed time.
Document the gate proof

The gate proof is not a test result. It is two observable commits in your Actions history that prove the CI pipeline catches the exact defect class your module was designed to reject.

  • Create the story/4 branch by running this command:
git checkout -b story/4-gate-proof
  • In your GitHub repo, click the Actions tab.
  • Find the red (failed) workflow run from when you merged story/2. Click it and copy the commit SHA from the run header.
  • Find the green (passed) workflow run from when you merged story/3. Copy that commit SHA as well.
  • Create docs/gate-proof.md and record both SHAs along with what the gate proves.

Your gate proof document should state three things:

  • The test matrix catches a missing validation block offline (no cloud account needed).
  • The CI pipeline enforces it on every push.
  • No AI step runs inside CI (no metered cost, no API key in secrets).

Why document the gate proof separately?

The SHAs are evidence. Anyone reviewing this module later can click through to those two runs and see the pipeline catch the defect, then pass once the fix lands. The document connects the dots between the test, the CI run, and the design decision it enforces.

  • Push your branch and open a PR by running these commands:
git add docs/gate-proof.md
git commit -m "docs: add gate proof with red/green SHAs"
git push origin story/4-gate-proof
  • Open a PR from story/4-gate-proof into main and merge it by running these commands:
gh pr create --base main --title "docs: gate proof with red/green SHAs" --body ""
gh pr merge --merge --delete-branch
  • On your Project board, move story 4 to Done.

Your board now shows stories 1 through 4 closed. One story remains.

  • Confirm the workflow triggered by the story/4 merge passed by running this command:
gh run list --limit 1

You should see a completed status with a green success conclusion. This run processes docs only, so the test and scan steps still pass.

PR merge blocked or story won't close?

If the Actions workflow fails on this PR, check that you are on the correct branch and that your commit includes docs/gate-proof.md. The test and scan steps should still pass because you did not change any module code.

If the story does not auto-close, move it manually on the board. Why won't my GitHub Project story close automatically?

Draft the readout, teach-back, and after-action review

The final story bundles the communication deliverables: a two-audience readout, a teach-back page, and an after-action review. Your AI client drafts these. You review, accept, or reject each one.

  • Switch back to main and create the story/5 branch by running these commands:
git checkout main
git pull origin main
git checkout -b story/5-readout-close

The two-audience readout addresses two groups with different questions. Prompt your AI client to draft it:

  • Engineering leadership gets: the module boundary, the test matrix, and the two failure modes designed out (an output nobody checked, a bad input that passes unchecked).
  • Board and stakeholders get: the tool-decision table (one row per ADR with decision, alternative, and tradeoff), the running cost ($0 because tests never touch a real provider), and the claim that the next network rebuild is now a review instead of a rewrite.
  • Prompt your AI client to draft the readout by using this prompt:
Draft a two-audience readout for my Terraform module project.

## Audience 1: Engineering Leadership

Cover:
- The module boundary (what the module encapsulates and what it does not).
- The test matrix (list each test, what it asserts, what failure it prevents).
- The two failure modes designed out:
  1. An output nobody checked (an output declared but never validated by a test).
  2. A bad input that passes unchecked (a variable with no validation block).

## Audience 2: Board and Stakeholders

Cover:
- A tool-decision table: one row per ADR with columns for Decision, Alternative, and Tradeoff.
- The running cost: $0 because tests use mock_provider and never touch a real cloud provider.
- The claim: the next network rebuild is now a review (update variables, re-run tests) instead of a rewrite.

Keep each section to one page. Use bullet points. Do not overstate what mock_provider tests prove.
  • Use your AI client to draft the readout. Review the draft: accept lines that accurately represent your module, reject anything that overstates what the tests prove.
  • Save the readout in your docs/ folder (for example, docs/readout.md).

What should your readout prompt cover?

A good readout prompt names both audiences explicitly and tells the AI exactly what each group cares about. Engineering leadership wants proof the module catches real defects (test matrix, failure modes). Stakeholders want cost and decision justification (tool-decision table, $0 cost, rebuild-vs-rewrite claim).

If your prompt is vague ("write a summary of my project"), the AI will pad it with generic claims. The more specific you are about what each section must contain, the less you have to reject.

Next, the teach-back page. This is the document that defends each decision out loud.

  • Prompt your AI client to draft docs/teach-back.md with a tool-decision table: one row per ADR listing the decision, the alternative it beat, and the tradeoff accepted.
  • Below the table, include a defense of each key design decision. For each ADR, write why this choice and not the alternative.
  • Review the AI's draft against your four ADRs in docs/decisions/. Accept or reject each row.
  • Use this prompt to draft the teach-back:
Draft docs/teach-back.md for my Terraform module project.

## Tool-Decision Table

Create a markdown table with one row per ADR from docs/decisions/. Columns:
| Decision | Alternative It Beat | Tradeoff Accepted |

Populate from my four ADRs.

## Decision Defense

Below the table, write a defense paragraph for each ADR.
For each one, answer: why this choice and not the alternative?
Ground each defense in a concrete project constraint (cost, test speed, team size, or module scope).

Do not invent ADRs I have not written. Only use decisions documented in docs/decisions/.

What should your teach-back prompt cover?

The prompt should reference your actual ADRs in docs/decisions/ and ask for a table with three columns: Decision, Alternative It Beat, and Tradeoff Accepted. One row per ADR, no extras.

The defense section should answer "why this and not that" for each row. If the AI invents an ADR you never wrote, reject it. If it softens a tradeoff ("minor overhead" when you accepted "no integration coverage"), tighten it.

💡 What belongs in the teach-back?

The teach-back is not a copy of the ADRs. It is the version you would explain out loud to a teammate who asks "why did you pick Terraform over OpenTofu?" Each row should be defensible in one sentence.

Finally, the after-action review. This answers four questions honestly:

  • What was meant to happen (the plan).
  • What actually happened (the reality).
  • Why the gap (what surprised you or took longer).
  • The one thing you would change next time.
  • Draft the after-action review with your AI client. Be honest about what deviated from docs/plan.md.
  • Save it as a file in your docs/ folder (for example, docs/after-action.md).
  • Commit all three documents and push by running these commands:
git add docs/
git commit -m "docs: readout, teach-back, and after-action review"
git push origin story/5-readout-close
  • Confirm the push succeeded by checking the terminal output. You should see a remote URL for the new branch.

AI draft includes claims your tests don't support?

The AI may claim the module proves idempotence or integration behavior. It does not. Your tests use mock_provider with command = plan. They prove wiring and arithmetic, not integration. Reject any line that overstates this boundary.

My AI is generating claims about my module that go beyond what the tests prove. How do I scope the readout accurately?

Close the board, tag the release, and record elapsed time

Closing a repo means every PR merged, every ticket done, the release tagged, and the elapsed time written down. A teammate should be able to clone, open the devcontainer, and run the tests in one command.

  • Open a PR from story/5-readout-close into main and merge it by running these commands:
gh pr create --base main --title "docs: readout, teach-back, and after-action" --body ""
gh pr merge --merge --delete-branch
  • On your Project board, move story 5 to Done.

Your board should now be clear: one epic done, all five stories closed.

  • Pull the latest main and tag the release by running these commands:
git checkout main
git pull origin main
git tag v1.0.0
git push origin v1.0.0

What does tagging a release do?

A git tag pins a specific commit as a named release. Anyone can now check out v1.0.0 and get the exact state of the module that passed all tests and scans. The tag is immutable. If you need to change the module later, the next release is v1.1.0 or v2.0.0.

  • Record your actual elapsed time. Create docs/timing.md and write how long the build took from the moment you started the timer (after the day-zero setup gate). Use this structure:
  • Create README.md in the repo root with the next-engineer one-liner. The command must clone, cd into the repo, start the devcontainer, then exec terraform init && terraform test inside it. Paste the following:
# Timing Log

Plan budget (`docs/plan.md`): 45 minutes.

Actual elapsed: ___ minutes (from timer start after day-zero gate).

Delta: ___ minutes over/under.
# Network Module

Provable Terraform VPC/subnet module with offline `mock_provider` tests,
Checkov named skips, and a CI gate on `main`.

## Prerequisites

- Docker (or Podman)
- [Dev Container CLI](https://github.com/devcontainers/cli)

## Quick Start (next engineer)

```bash
git clone https://github.com/ORG_NAME/network-module.git
cd network-module
devcontainer up
devcontainer exec bash -c "terraform init && terraform test"
```
  • Commit docs/timing.md (and any README update) directly to main by running these commands:
git add docs/timing.md README.md
git commit -m "docs: record elapsed time and confirm README"
git push origin main

Before you verify, predict: if you check your Actions tab right now, how many green runs should you see since you merged story/3?

  • Return to your repo's Actions tab. Confirm you see the original red run (story/2) followed by green runs (story/3, story/4, story/5 merges).
  • Click the Tags section in your repo (under Releases in the right sidebar or via the Code tab). Confirm v1.0.0 appears.
  • Check your Project board. It should be empty: one epic done, five stories closed, nothing in progress.

Tag not showing up in GitHub?

Make sure you ran both git tag v1.0.0 and git push origin v1.0.0. Creating a tag locally does not push it to the remote. Without the push, GitHub does not know the tag exists.

My git tag exists locally but I can't see it on GitHub. How do I push tags?

Secret mission

Add Boundary Validation and a Sixth Test Case

Your module handles 1 to 3 AZs, but what happens when someone passes 6? Or 20? Right now, the subnet math silently computes a result that may exhaust the address space. Add a maximum-AZ validation that fails closed, write a sixth test case that proves the boundary, and update your ADR to document the limit.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally inside a devcontainer with mocked tests. No cloud resources (AWS, Azure, GCP) were provisioned. The only external resource is a private GitHub repository consuming Actions minutes from your free plan's 2,000-minute monthly allowance.

Resources you used:

  • Private GitHub repository (includes Actions workflow runs).
  • GitHub Project board (org-level, separate from the repo).
  • Local repository clone on your Desktop.
  • Running devcontainer (Docker container).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the module soon.

  • The repo stays private in your org. Actions workflows only consume minutes when triggered by a push, so an idle repo costs nothing.
  • The Project board remains accessible from your org's Projects tab.
  • The devcontainer stops automatically when you close your editor. Reopen the folder to restart it.
  • Your local clone stays on your Desktop for future work.

Pause - I'll come back to this later

Archive the repository so it becomes read-only. No workflows can trigger, so zero Actions minutes are consumed while archived.

  • In your repository on GitHub, click Settings.
  • Scroll down to the Danger Zone section.
  • Click Archive this repository and confirm.
  • The repo becomes read-only. You can unarchive it from the same location when you return.
  • Stop the devcontainer by closing your editor. The Docker container stops automatically.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete the GitHub Project board: open your org's Projects tab, open the board, click the three-dot menu, select Settings, scroll to the bottom, and click Delete project.
  • Delete the repository: in the repo on GitHub, go to Settings > General > Danger Zone and click Delete this repository. Confirm by typing the repo name.
  • Remove the local clone by running this in your terminal:
cd ~/Desktop
rm -rf network-module
  • Remove the stopped devcontainer image (optional, frees disk space) by running:
docker system prune

Confirm the prompt with y to remove stopped containers and unused images.

That's a Wrap!

That's a Wrap!

Nice work. You just built, tested, and defended a production-grade Terraform network module with no cloud account, no cost, and every design decision recorded.

You've learned how to:

  • Build and test a Terraform network module in the standard module structure, proving its subnet CIDR arithmetic, tag merging, multi-alias routing, and input rejection offline using mock_provider with no credentials and no cost.
  • Design before building: a requirements brief with numbered acceptance criteria, four ADRs each naming the alternative and tradeoff, a diagram, and a budgeted plan committed before the first test was written.
  • Prove a CI gate catches a seeded defect (red then green in GitHub Actions history), deliver through a Project board with every story closed, and defend each decision in a teach-back with a two-audience readout and an after-action review.

Ready to quiz yourself?