# IaC Module Specification: [Module name]

> Specification for one reusable infrastructure-as-code module: purpose and scope, interface
> design, inputs and outputs, defaults and guardrails, security baseline, tagging contract,
> provider and tool compatibility, state and lifecycle rules, versioning with Semantic
> Versioning 2.0.0, tests including policy as code, usage examples, release, support and
> deprecation. Written for HCL modules used with OpenTofu or Terraform, with module structure
> aligned with the OpenTofu standard module structure and the native `test` command. Replace
> every `[bracketed]` value and every row marked *Example*. The worked example is a private
> object storage bucket module at Corvid Commerce, a fictional SaaS company.

<!--
Guidance: How to use this template
- One specification per module, stored next to the module code (for example as `SPEC.md` beside
  `README.md`). The README tells callers how to use the module; this document records the
  contract, the guardrails and the reasons behind them.
- Write sections 1 to 4 before the first line of code and review them with two consumer teams.
  The interface is the hardest part to change later, because every change to it is a release
  decision under section 10.
- Tool neutrality: write examples that work with both OpenTofu and Terraform unless you rely on
  a feature only one of them has; if you do, say so in section 8.
- Boundaries: this document covers one module. The paved road that combines modules into a
  service is the Platform Golden Path (`architecture/platform-golden-path`); account and network
  foundations are the Landing Zone Design (`architecture/landing-zone-design`); tag keys and
  values are owned by the Cost Allocation and Tagging Standard
  (`ops/cost-allocation-tagging-standard`); release numbering rules for the organisation are in
  the Versioning and Release Policy (`dev/versioning-release-policy`); the pipeline that runs
  the tests is in CI/CD Pipeline Design (`dev/ci-cd-pipeline-design`).
-->

## Document control

<!-- Guidance: Identify the module, its registry address and the version this specification describes. Update the specification in the same pull request as any interface change. -->

| Field | Value |
|---|---|
| Document ID | [IAC-SPEC-014] |
| Module name | [object-bucket] |
| Registry address | [registry.example.com/namespace/name/provider] |
| Source repository | [git URL] |
| Module version described | [2.3.0] |
| Module status | [Experimental / Supported / Deprecated / Retired] |
| Owning team | [@platform-infrastructure] |
| Version | [1.0] |
| Status | [Draft / In review / Approved] |
| Owner | [@module-maintainer] |
| Approvers/reviewers | [@platform-lead], [@cloud-security-architect], [@finops-lead], [@consumer-team-rep] |
| Last updated | YYYY-MM-DD |
| Classification | [Internal] |

### Approvals

| Role | Name | Decision | Date |
|---|---|---|---|
| Platform lead | [@name] | [Approve / Approve with changes / Reject] | YYYY-MM-DD |
| Cloud security architect | [@name] | [Decision] | YYYY-MM-DD |
| FinOps lead (tagging contract) | [@name] | [Decision] | YYYY-MM-DD |
| Consumer team representative | [@name] | [Decision] | YYYY-MM-DD |

### Revision history

| Version | Date | Author | Change summary |
|---|---|---|---|
| 0.1 | YYYY-MM-DD | [@name] | First draft for module 1.0.0 |
| 1.0 | YYYY-MM-DD | [@name] | *Example:* Approved; module 2.0.0 enforces encryption with customer-managed keys |
| 1.1 | YYYY-MM-DD | [@name] | *Example:* Module 2.3.0 adds `lifecycle_rules` input |

## Contents

<!-- Guidance: Keep this list aligned with the headings. -->

1. [Purpose and scope](#1-purpose-and-scope)
2. [Interface design principles](#2-interface-design-principles)
3. [Inputs](#3-inputs)
4. [Outputs](#4-outputs)
5. [Defaults and guardrails](#5-defaults-and-guardrails)
6. [Security baseline](#6-security-baseline)
7. [Tagging contract](#7-tagging-contract)
8. [Compatibility and dependencies](#8-compatibility-and-dependencies)
9. [State, lifecycle and drift](#9-state-lifecycle-and-drift)
10. [Versioning and changelog](#10-versioning-and-changelog)
11. [Testing](#11-testing)
12. [Usage examples](#12-usage-examples)
13. [Release, support and deprecation](#13-release-support-and-deprecation)
14. [Glossary and references](#14-glossary-and-references)

## 1. Purpose and scope

<!-- Guidance: Say what the module creates, for whom, and what it deliberately does not do. Questions: which real need does it serve? Would a thin wrapper over one resource add value, or only indirection? Pitfall: a "kitchen sink" module with fifty inputs that tries to serve every case and secures none. -->

### 1.1 Purpose

[One paragraph: the capability this module provides and why a shared module is better than each team writing its own resources.]

*Example:* The `object-bucket` module creates a private object storage bucket that meets Corvid Commerce's security and cost allocation baseline by default: encrypted with a customer-managed key, public access blocked, versioning on, access logged, and tagged per the tagging standard. Product teams use it for application assets, exports and backups. Without it, teams copied resource blocks between repositories and drifted from the baseline (12 of 41 buckets failed the posture review in 2026-Q1).

### 1.2 Scope

| In scope | Out of scope |
|---|---|
| *Example:* One bucket with its encryption, versioning, public access block, ownership controls, lifecycle rules and access logging target | The KMS key itself (created by the `kms-key` module and passed in) |
| *Example:* A bucket policy that enforces TLS and denies uploads that request a different key | Cross-account access grants (use the `bucket-access-grant` module) |
| *Example:* Optional replication configuration to a second region | Static website hosting and public buckets (not supported by design) |
| [Item] | [Item] |

### 1.3 Consumers and placement

| Consumer | Use | Called from |
|---|---|---|
| *Example:* Product teams on the golden path "Java HTTP service" | Application asset storage | Service infrastructure repository |
| *Example:* Data platform team | Export landing buckets | Data platform stack |
| *Example:* Backup service | Backup vault buckets with object lock | Backup stack, through the `backup-vault` module |
| [Consumer] | [Use] | [Repository or stack] |

## 2. Interface design principles

<!-- Guidance: Record the rules that keep the interface small and stable. Questions: which values must callers decide, and which should the module decide for them? Pitfall: exposing every argument of the underlying resource, which turns the module into a second copy of the provider documentation and makes every provider change a breaking change. -->

The module interface follows these rules:

1. **IF-01** Expose only inputs that callers legitimately need to vary. Security-critical settings are not inputs (section 5).
2. **IF-02** Use typed inputs with `object` and `optional()` attributes for grouped settings instead of many loose variables.
3. **IF-03** Every input and output has a one- or two-sentence description; the README input and output tables are generated from them.
4. **IF-04** Validate inputs in the module with `validation` blocks and clear error messages, so mistakes fail at plan time.
5. **IF-05** Do not configure providers inside the module. Callers pass providers, including aliased providers for a second region.
6. **IF-06** Outputs expose identifiers and attributes that other modules need; never output secrets, and mark sensitive outputs.
7. **IF-07** Prefer composition: small modules that callers combine over deep chains of modules calling modules.
8. **IF-08** Naming: inputs and outputs use `snake_case`; booleans are phrased positively (`enable_replication`, not `disable_replication`).

### 2.1 Repository layout

The layout follows the standard module structure: README, main, variables and outputs files at the root, with examples and tests in their own folders.

```text
object-bucket/
  README.md            # usage, generated input and output tables
  SPEC.md              # this specification
  CHANGELOG.md         # Keep a Changelog format
  LICENSE
  main.tf              # resources
  variables.tf         # inputs (section 3)
  outputs.tf           # outputs (section 4)
  versions.tf          # required_version and required_providers (section 8)
  policy.tf            # bucket policy document
  examples/minimal/    # smallest valid call (section 12)
  examples/replicated/ # cross-region replication
  tests/               # *.tftest.hcl files (section 11)
  policy/tags.rego     # policy as code (section 11.3)
```

## 3. Inputs

<!-- Guidance: List every input with its type, default and validation. Questions: is the default safe? Is the input required only because you have not chosen a sensible default? Pitfall: optional inputs whose default is insecure, such as public access allowed unless set. -->

| ID | Input | Type | Required | Default | Validation | Description |
|---|---|---|---|---|---|---|
| IN-01 *(Example)* | `name` | `string` | Yes | None | 3 to 50 characters, lower case letters, digits and hyphens | Logical name; the module adds environment and a suffix to form the bucket name |
| IN-02 *(Example)* | `environment` | `string` | Yes | None | One of `dev`, `staging`, `prod` | Deployment environment; also used as a tag value |
| IN-03 *(Example)* | `kms_key_arn` | `string` | Yes | None | Must be a key ARN, not an alias | Customer-managed key for server-side encryption |
| IN-04 *(Example)* | `tags` | `map(string)` | Yes | None | Must contain the mandatory keys in section 7 | Caller tags, merged with module tags |
| IN-05 *(Example)* | `log_bucket_id` | `string` | Yes | None | Non-empty | Target bucket for server access logs |
| IN-06 *(Example)* | `lifecycle_rules` | `list(object({...}))` | No | `[]` | Expiration days at least 1; transition days lower than expiration | Object expiry and storage class transitions |
| IN-07 *(Example)* | `enable_replication` | `bool` | No | `false` | Requires IN-08 when `true` | Replicate objects to a second region |
| IN-08 *(Example)* | `replication` | `object({ destination_bucket_arn = string, kms_key_arn = string })` | No | `null` | Both attributes set when IN-07 is `true` | Replication target |
| IN-09 *(Example)* | `force_destroy` | `bool` | No | `false` | Must be `false` when `environment = "prod"` | Allow deletion of a non-empty bucket (test environments only) |
| [IN-NN] | [`input`] | [type] | [Yes / No] | [default] | [rule] | [description] |

Example of an input with validation, valid for both tools:

```hcl
variable "environment" {
  description = "Deployment environment. Used in the bucket name and as the environment tag."
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

```

Cross-variable rules (IN-09 depends on IN-02) are enforced with a `precondition` on the bucket resource, which keeps the rule next to the resource it protects and works on every supported tool version.

## 4. Outputs

<!-- Guidance: Output what callers and other modules need to connect to the resource, and nothing that exposes internals they might come to depend on. Pitfall: outputting a whole resource object, which makes every provider attribute part of your public contract. -->

| ID | Output | Type | Sensitive | Description | Typical consumer |
|---|---|---|---|---|---|
| OUT-01 *(Example)* | `bucket_id` | `string` | No | Bucket name | Application configuration |
| OUT-02 *(Example)* | `bucket_arn` | `string` | No | Bucket ARN | IAM policies, `bucket-access-grant` module |
| OUT-03 *(Example)* | `bucket_regional_domain_name` | `string` | No | Regional endpoint name | Content delivery configuration |
| OUT-04 *(Example)* | `replication_role_arn` | `string` | No | Role used for replication, `null` when disabled | Audit and access reviews |
| [OUT-NN] | [`output`] | [type] | [Yes / No] | [description] | [consumer] |

Removing an output or changing its type is a breaking change (section 10).

## 5. Defaults and guardrails

<!-- Guidance: Separate what the caller can change from what the module enforces. Questions: which settings, if changed, would breach the security or cost baseline? Who may approve an exception? Pitfall: guardrails enforced only by documentation, which callers can bypass by forking the module. -->

| ID | Setting | Behaviour | Can the caller change it? | Enforced by |
|---|---|---|---|---|
| GR-01 *(Example)* | Public access block | All four public access settings on | No | Hard-coded in the module; organisation policy as second layer |
| GR-02 *(Example)* | Encryption at rest | Customer-managed key from IN-03; bucket key enabled | Key: yes; turning encryption off: no | Hard-coded default SSE-KMS with the IN-03 key; bucket policy denies uploads that request a different key or SSE-S3 |
| GR-03 *(Example)* | TLS in transit | Bucket policy denies requests without secure transport | No | Bucket policy |
| GR-04 *(Example)* | Versioning | Enabled | No | Hard-coded |
| GR-05 *(Example)* | Object ownership | Bucket owner enforced (access control lists disabled) | No | Hard-coded |
| GR-06 *(Example)* | Access logging | Server access logs to IN-05 | Target: yes; disabling: no | Required input |
| GR-07 *(Example)* | Deletion protection | `force_destroy = false` enforced in prod; deletion of prod buckets also denied by an organisation policy (deny `DeleteBucket` outside the break-glass role) | `force_destroy` only outside prod | Precondition (IN-09); organisation policy; POL-05 on the plan |
| [GR-NN] | [Setting] | [Behaviour] | [Yes / No] | [Mechanism] |

Defence in depth: module guardrails make the right thing easy; landing zone controls (service control policies, organisation policies and detective rules in the Landing Zone Design) catch resources created outside the module. Do not rely on either layer alone.

Exceptions: a team that needs a setting the module forbids raises an exception request with the reason, the compensating control, an expiry date and approval from the cloud security architect. Approved exceptions are listed here:

| Exception ID | Team | Guardrail | Reason | Compensating control | Expiry | Approved by |
|---|---|---|---|---|---|---|
| EXC-01 *(Example)* | Data platform | GR-05 | A legacy partner integration still writes objects with access control lists | Partner limited to one prefix; access reviewed monthly; migration to a bucket policy grant planned | 2027-03-31 | [@cloud-security-architect] |
| [EXC-NN] | [Team] | [GR ID] | [Reason] | [Control] | YYYY-MM-DD | [@approver] |

## 6. Security baseline

<!-- Guidance: Trace each security requirement to a control in your control framework and to a test. Questions: which threats does the module mitigate? Which remain the caller's job? Pitfall: listing controls without a test, so regressions go unnoticed. -->

| ID | Requirement | Threat addressed | Control framework reference | Implemented by | Verified by |
|---|---|---|---|---|---|
| SEC-01 *(Example)* | No public read or write | Data exposure | [Internal control ID] | GR-01 | TST-03, POL-02 |
| SEC-02 *(Example)* | Encryption at rest with customer-managed key | Disclosure of stored data; key misuse | [Control ID] | GR-02 | TST-02, POL-03 |
| SEC-03 *(Example)* | Encryption in transit enforced | Interception | [Control ID] | GR-03 | TST-04 |
| SEC-04 *(Example)* | Recoverability of overwritten or deleted objects | Ransomware, operator error | [Control ID] | GR-04, GR-07 | TST-02, POL-05 |
| SEC-05 *(Example)* | Access is logged | Undetected misuse | [Control ID] | GR-06 | TST-05 |
| SEC-06 *(Example)* | Least privilege replication role | Privilege escalation | [Control ID] | Role scoped to source and destination buckets and keys | TST-06 |
| [SEC-NN] | [Requirement] | [Threat] | [Reference] | [GR ID] | [TST or POL ID] |

Caller responsibilities that the module cannot enforce: who receives read and write access (use least privilege grants), the classification of data stored, key rotation and key policy (owned by the key module), and secrets handling in applications (see Secrets Management Standard, `security/secrets-management-standard`). For the threat analysis behind this table, see the Threat Model (`security/threat-model`).

## 7. Tagging contract

<!-- Guidance: Make cost allocation automatic. Questions: which tags does the module require from the caller, which does it add itself, and which wins in a conflict? Pitfall: tags applied only to the main resource, leaving roles and replication resources unallocated. -->

The Cost Allocation and Tagging Standard (`ops/cost-allocation-tagging-standard`) defines the mandatory keys and allowed values. This module implements that standard; if they disagree, the standard wins. The module is fixed in the next release, which is MAJOR when it rejects tag sets that were valid before (section 10).

| Tag key (from the standard's tag dictionary) | Source | Rule in this module |
|---|---|---|
| `cost-center` | Caller (IN-04) | Required; validated against the pattern in the standard (*Example:* `cc-` and 4 digits) |
| `owner-team` | Caller (IN-04) | Required; team slug from the team directory, not a person |
| `service` | Caller (IN-04) | Required; service ID from the service catalogue |
| `environment` | Module, from IN-02 | Set by the module; a caller value is overwritten so the tag cannot disagree with the input. The module accepts `dev`, `staging` and `prod`, a subset of the standard's values |
| `data-classification` | Caller (IN-04) | Required, because a bucket is a data store |
| `managed-by` | Module | Fixed value `iac` |

Keys and values follow the standard's format rules (lower case letters, digits and hyphens), so the module does not add its version as a tag; the version is pinned in the caller's module block and found through usage tracking (13.2).

Contract rules:

1. **TAG-01** Every taggable resource the module creates MUST carry the merged tag set, including roles and replication resources.
2. **TAG-02** Merge order: provider default tags, then caller tags, then module-owned tags. Module-owned keys always win.
3. **TAG-03** A missing mandatory key MUST fail at plan time (input validation) and again in the policy check (POL-01).
4. **TAG-04** Resources that cannot be tagged are listed in the README so the FinOps team can allocate them by rule.

The periodic Cloud Cost Review (`ops/finops-cost-review`) reports tag compliance; resources created by this module SHOULD show 100%.

## 8. Compatibility and dependencies

<!-- Guidance: State which tool and provider versions the module supports and how you test them. Questions: what is the oldest version you test? When do you drop one? Pitfall: an open-ended provider constraint that lets a new major provider version break every caller on their next init. -->

| Dependency | Constraint | Tested versions *(Example, checked 2026-10-24)* | Notes |
|---|---|---|---|
| OpenTofu | `>= 1.8.0` | 1.13.2 and the oldest supported minor | `.tofu` files are not used, so the module stays readable by both tools |
| Terraform | `>= 1.8.0` | 1.17.0 and the oldest supported minor | Native tests need 1.6 or later; provider mocking needs 1.7 or later |
| Cloud provider plugin | `>= 6.0, < 7.0` | 6.70.0 | A new provider major version is adopted in a module major release |
| Other modules | None called | | Composition happens in the caller |

```hcl
terraform {
  required_version = ">= 1.8.0"

  required_providers {
    aws = {
      source                = "hashicorp/aws"
      version               = ">= 6.0, < 7.0"
      configuration_aliases = [aws.replica]
    }
  }
}
```

`configuration_aliases` declares that the module expects an aliased provider `aws.replica` for the replica region (IF-05). Every caller must then pass it, even with replication off (pass the default provider twice). If that burden is too high for most callers, split replication into its own module instead.

Support rule *(Example)*: test against the latest release and the oldest supported minor of each tool; raise the minimum only in a minor release with a changelog note, unless it forces callers to upgrade a tool major version, which is a major release. Features that exist in only one tool (for example OpenTofu state encryption, OpenTofu 1.12 `prevent_destroy` expressions, or features added to Terraform after the fork) are not used inside the module; callers may use them in their root configuration.

## 9. State, lifecycle and drift

<!-- Guidance: Protect callers from accidental replacement and data loss. Questions: which changes force replacement of a stateful resource? How do you rename resources inside the module without destroying them? Pitfall: refactoring resource addresses without `moved` blocks, which plans a destroy and re-create of a bucket full of data. -->

1. **LC-01** Resource addresses inside the module are part of the contract with every caller's state. Renaming or moving a resource MUST include a `moved` block, kept for at least one major version.
2. **LC-02** Changes that force replacement of a stateful resource are breaking changes and need a migration guide.
3. **LC-03** The module does not set `prevent_destroy`. In Terraform every `lifecycle` argument must be a literal inside the resource block, so a caller cannot set it on a resource in a called module, and a literal `true` would also block deletion in test environments. OpenTofu 1.12.0 and later accept a reference to an input variable, but the module stays tool-neutral (section 8). Production protection comes from GR-07 (no `force_destroy` in prod, an organisation policy that denies bucket deletion, POL-05 on every plan) and versioning (GR-04). A team that needs `prevent_destroy` uses a separate `object-bucket-protected` variant that sets it to `true`, published from the same repository and test suite.
4. **LC-04** Adopting existing resources uses `import` blocks in the caller's root module, documented in the README migration section.
5. **LC-05** Drift detection runs a scheduled plan per consuming stack (*Example:* daily); any drift on a guardrail attribute raises a security finding, other drift a ticket to the owning team.

*Example:* when 2.0.0 removed `count` from the bucket, a `moved` block from `aws_s3_bucket.this[0]` to `aws_s3_bucket.this` kept every caller's bucket in place.

## 10. Versioning and changelog

<!-- Guidance: Apply Semantic Versioning to the module interface and its behaviour. Questions: would an existing caller need to change code or accept a different result after upgrading? If yes, it is a major release. Pitfall: treating a changed default as a minor change when it alters infrastructure for every caller who did not set the input. -->

The module follows Semantic Versioning 2.0.0: MAJOR for incompatible changes, MINOR for backward-compatible additions, PATCH for backward-compatible fixes. Versions `0.y.z` are for initial development and are not used in production stacks. The organisation's wider rules are in the Versioning and Release Policy (`dev/versioning-release-policy`).

| Change | Release type | Reason |
|---|---|---|
| Remove or rename an input or output | MAJOR | Callers must change code |
| Make an optional input required | MAJOR | Existing calls fail |
| Change a default that alters created resources | MAJOR | Infrastructure changes without caller action |
| Tighten a validation so that previously valid values fail | MAJOR | Existing calls may fail |
| Change that forces replacement of a stateful resource | MAJOR | Data loss risk; needs migration guide |
| Raise the provider constraint to a new major version | MAJOR | Callers must upgrade the provider |
| Add an optional input with a default that changes nothing | MINOR | Backward compatible |
| Add an output | MINOR | Backward compatible |
| Add a new guardrail that only affects new resources and is required by the security baseline | MINOR with notice, or MAJOR if existing resources change | Decide per case; record the decision |
| Fix a bug, or change only documentation, without changing the interface or intended behaviour | PATCH | Backward compatible |

Changelog: `CHANGELOG.md` in Keep a Changelog format, one section per release with Added, Changed, Deprecated, Removed, Fixed and Security headings. Commit messages MAY follow Conventional Commits to automate release notes.

| Version | Date | Type | Summary |
|---|---|---|---|
| 2.0.0 *(Example)* | 2026-03-02 | MAJOR | `kms_key_arn` and `data-classification` tag required; default provider-managed encryption removed |
| 2.1.0 *(Example)* | 2026-05-11 | MINOR | Added optional replication (IN-07, IN-08) |
| 2.2.0 *(Example)* | 2026-07-20 | MINOR | Added module-owned `managed-by` tag (`data-classification` has been required since 2.0.0) |
| 2.3.0 *(Example)* | 2026-09-14 | MINOR | Added `lifecycle_rules` input (IN-06) |
| [x.y.z] | YYYY-MM-DD | [Type] | [Summary] |

Callers pin with a pessimistic constraint on the minor version, for example `version = "~> 2.3"`, which allows 2.3 and later 2.x releases but not 3.0.

## 11. Testing

<!-- Guidance: Define the test layers, what each proves and when it runs. Questions: which guardrails would silently disappear if someone edited main.tf? Which tests create real resources, and who pays and cleans up? Pitfall: only an integration test that takes 20 minutes, so contributors stop running it. -->

### 11.1 Test layers

| Layer | Tool | What it proves | Runs | Gate |
|---|---|---|---|---|
| Format and validate | `tofu fmt -check`, `tofu validate` (or the Terraform equivalents) | Syntax and internal consistency | Every commit | Blocking |
| Lint and static security scan | [Linter], [IaC security scanner] | Provider-specific mistakes, known misconfigurations | Every commit | Blocking on high findings |
| Unit tests (plan) | Native `test` command, `command = plan`, mock providers where needed | Inputs validate; guardrails present; tags merged | Every pull request | Blocking |
| Policy as code | Open Policy Agent with Conftest on the plan JSON | Organisation rules hold for the planned resources | Every pull request, and in every consuming pipeline | Blocking |
| Integration tests (apply) | Native `test` command, `command = apply`, in a sandbox account | Resources are created as specified and are destroyed afterwards | Before each release, nightly | Blocking for release |
| Example tests | Plan of each folder in `examples/` | Examples stay valid | Every pull request | Blocking |

### 11.2 Test cases

| ID | Test | Type | Expected result |
|---|---|---|---|
| TST-01 *(Example)* | Invalid `environment` value | Unit, `expect_failures` on the variable | Plan fails with the validation message |
| TST-02 *(Example)* | Default call | Unit | Encryption uses IN-03 key; versioning enabled |
| TST-03 *(Example)* | Default call | Unit | All four public access block settings true |
| TST-04 *(Example)* | Bucket policy | Unit | Policy contains a deny for non-TLS requests |
| TST-05 *(Example)* | Logging | Unit | Logging target equals IN-05 |
| TST-06 *(Example)* | Replication enabled | Integration | Object written to source appears in destination, encrypted with destination key |
| TST-07 *(Example)* | `force_destroy = true` with `environment = "prod"` | Unit, `expect_failures` on the bucket resource | Plan fails with the precondition message |
| TST-08 *(Example)* | Tags | Unit | Every taggable resource has the mandatory keys; `environment` equals IN-02 |
| [TST-NN] | [Test] | [Type] | [Expected] |

Example unit test file (`tests/defaults.tftest.hcl`), runnable with `tofu test` or `terraform test`:

```hcl
mock_provider "aws" {}

mock_provider "aws" {
  alias = "replica"
}

variables {
  name          = "assets"
  environment   = "dev"
  kms_key_arn   = "arn:aws:kms:eu-west-1:111122223333:key/00000000-0000-0000-0000-000000000000"
  log_bucket_id = "corvid-dev-access-logs"
  tags          = { cost-center = "cc-1042", owner-team = "kestrel", service = "checkout-api", data-classification = "internal" }
}

run "public_access_is_blocked" {
  command = plan

  assert {
    condition = alltrue([
      aws_s3_bucket_public_access_block.this.block_public_acls,
      aws_s3_bucket_public_access_block.this.block_public_policy,
      aws_s3_bucket_public_access_block.this.ignore_public_acls,
      aws_s3_bucket_public_access_block.this.restrict_public_buckets,
    ])
    error_message = "Public access block must be fully enabled."
  }
}

run "prod_rejects_force_destroy" {
  command = plan

  variables {
    environment   = "prod"
    force_destroy = true
  }

  expect_failures = [aws_s3_bucket.this]
}
```

### 11.3 Policy as code

Policies run against the JSON form of a saved plan (`tofu show -json plan.out` or `terraform show -json plan.out`) in this module's pipeline and in every consuming pipeline, so they also catch resources created outside the module.

| ID | Policy | Severity | Source rule |
|---|---|---|---|
| POL-01 *(Example)* | Mandatory tag keys present on every taggable resource | Deny | Tagging standard |
| POL-02 *(Example)* | No bucket without a full public access block | Deny | SEC-01 |
| POL-03 *(Example)* | No bucket encrypted only with a provider-managed key in prod | Deny | SEC-02 |
| POL-04 *(Example)* | Module source pinned to a version, not a branch | Warn | Section 10 |
| POL-05 *(Example)* | No `delete` action on a bucket in a prod plan without an approved change record | Deny | GR-07 |
| [POL-NN] | [Policy] | [Deny / Warn] | [Reference] |

Example policy POL-01 in Rego (OPA 1.x syntax). It reads `tags_all`, which includes provider default tags, covers only resource types listed as taggable, and also requires `data-classification` on data stores (section 7). If a tag value is unknown until apply, the rule reports it as missing; resolve such cases with known values in the plan:

```rego
package terraform.tags

universal := {"cost-center", "owner-team", "service", "environment"}

taggable := {"aws_s3_bucket", "aws_iam_role"}

data_store := {"aws_s3_bucket"}

required_for(t) := universal | {"data-classification"} if {
	t in data_store
} else := universal

deny contains msg if {
	rc := input.resource_changes[_]
	rc.mode == "managed"
	rc.type in taggable
	some action in rc.change.actions
	action in {"create", "update"}
	tags := object.get(rc.change.after, "tags_all", {})
	missing := required_for(rc.type) - {k | tags[k]}
	count(missing) > 0
	msg := sprintf("%s is missing required tags: %v", [rc.address, missing])
}
```

### 11.4 Acceptance criteria for a release

1. All blocking layers in 11.1 pass on the release commit.
2. Every guardrail in section 5 and every requirement in section 6 has at least one passing test or policy.
3. Integration tests leave no resources behind (sandbox inventory checked after the run).
4. The changelog entry and the release type match the table in section 10, confirmed by a second maintainer.
5. For a major release, a migration guide exists and was tested on one real consuming stack.

## 12. Usage examples

<!-- Guidance: Give callers copy-ready examples that use the registry address, not a relative path, because examples get copied into other repositories. Pitfall: examples that are not tested and stop working after the next release. -->

Minimal call (`examples/minimal`):

```hcl
module "assets_bucket" {
  source    = "registry.corvid.example/platform/object-bucket/aws"
  version   = "~> 2.3"
  providers = { aws = aws, aws.replica = aws } # no replication: pass the default twice (section 8)

  name          = "assets"
  environment   = "prod"
  kms_key_arn   = module.assets_key.key_arn
  log_bucket_id = data.terraform_remote_state.logging.outputs.access_log_bucket_id
  tags          = local.service_tags # mandatory keys from section 7, except environment
}
```

Replicated call (`examples/replicated`):

```hcl
module "exports_bucket" {
  source    = "registry.corvid.example/platform/object-bucket/aws"
  version   = "~> 2.3"
  providers = { aws = aws, aws.replica = aws.eu_central_1 }

  name               = "exports"
  environment        = "prod"
  kms_key_arn        = module.exports_key.key_arn
  log_bucket_id      = data.terraform_remote_state.logging.outputs.access_log_bucket_id
  tags               = local.service_tags
  enable_replication = true
  replication = {
    destination_bucket_arn = module.exports_replica_bucket.bucket_arn
    kms_key_arn            = module.exports_replica_key.key_arn
  }
}
```

Both examples are planned in CI on every pull request, and TST-06 applies the replicated one in the sandbox.

## 13. Release, support and deprecation

<!-- Guidance: Tell callers what support they get and how much warning they receive. Questions: how many major versions do you support at once? How do callers learn about a deprecation? Pitfall: deprecating a module with no replacement path and no date. -->

### 13.1 Release process

1. A maintainer opens a release pull request that updates `CHANGELOG.md` and this specification.
2. CI runs every layer in 11.1; a second maintainer approves.
3. The merge creates a signed tag `vX.Y.Z`, and the registry publishes the version.
4. The release note is posted in [channel] and, for a major release, consuming teams named in 1.3 are notified directly.

### 13.2 Support policy *(Example values)*

| Item | Commitment |
|---|---|
| Supported major versions | Current major and the previous major |
| Security fixes | Back-ported to the previous major for 6 months after a new major |
| Response to issues | Triage within 2 working days |
| Usage tracking | Registry download data and a scheduled search for module sources in all infrastructure repositories |

### 13.3 Deprecation

1. Announce deprecation of a version line or of the whole module with a reason, a replacement and a removal date at least [90] days ahead.
2. Mark inputs as deprecated in their description and emit a warning through a `check` block where useful, before removing them in the next major release.
3. Track remaining consumers weekly from usage tracking; contact owners directly in the last 30 days.
4. After the removal date, the version stays in the registry for reproducibility but receives no fixes; the module status becomes Retired.

```mermaid
stateDiagram-v2
  [*] --> Experimental
  Experimental --> Supported: first 1.0.0 release
  Supported --> Supported: minor and patch releases
  Supported --> Deprecated: replacement announced
  Deprecated --> Retired: removal date reached
  Retired --> [*]
```

## 14. Glossary and references

<!-- Guidance: Define the terms as this specification uses them and list sources with the version checked. -->

### 14.1 Glossary

| Term | Meaning in this specification |
|---|---|
| Module | A set of configuration files in one directory, called by other configurations with inputs and outputs |
| Root module | The configuration where commands are run; it calls child modules |
| Guardrail | A setting the module enforces and callers cannot override |
| Policy as code | Rules written in a policy language and evaluated automatically against planned changes |
| Plan JSON | Machine-readable form of a saved plan, used by policy tools |
| `moved` block | Declaration that a resource address changed, so state is updated instead of replaced |
| Pessimistic constraint | Version constraint `~>` that allows only the rightmost component to increase |
| Drift | Difference between the declared configuration and the real infrastructure |

### 14.2 References

| Reference | Version or date checked | Use |
|---|---|---|
| Semantic Versioning 2.0.0, https://semver.org/ | 2.0.0, checked 2026-10-24 | Release type rules (section 10) |
| OpenTofu documentation, standard module structure and `tofu test`, https://opentofu.org/docs/ | OpenTofu 1.13.2 (released 2026-10-21) | Repository layout, test files and blocks |
| Terraform documentation, tests, https://developer.hashicorp.com/terraform/language/tests | Terraform 1.17.0 (released 2026-10-21) | Test command versions (tests 1.6, mocks 1.7) |
| Open Policy Agent, https://www.openpolicyagent.org/ | OPA 1.21.4; Conftest 0.71.1 | Policy as code on plan JSON |
| Keep a Changelog, https://keepachangelog.com/ | 1.1.0 | Changelog format |
| Conventional Commits, https://www.conventionalcommits.org/ | 1.0.0 | Optional commit convention |
| Cost Allocation and Tagging Standard (`ops/cost-allocation-tagging-standard`) | [Version] | Tag keys and values |

## Standard and licence note

Based on Semantic Versioning 2.0.0 (semver.org, CC BY 3.0) for release rules, and structure aligned with the OpenTofu standard module structure and the native `test` command documented for OpenTofu and Terraform; the policy example uses Open Policy Agent (Apache-2.0). No vendor documentation text is reproduced; code samples are original examples. OpenTofu is a trademark of LF Projects, LLC; Terraform and HashiCorp, AWS and Amazon S3, and Open Policy Agent are trademarks of their owners; this template is not affiliated with or endorsed by them. This template is original text written for this collection. OpenTofu is licensed under MPL-2.0; Terraform 1.6 and later is under the Business Source License 1.1. Check the current official documentation and tool versions before use. Template licence: MIT.
