Decisions

ADR-0015: Infrastructure Layer Model inside .infra/

Formalizes the bootstrap / shared / service layer model inside .infra/, each an isolated Terraform stack, and renames the CI-roles layer to bootstrap.

Proposed

Status: Proposed

Date: 2026-06-28

Deciders: Engineering Leadership

Refines: ADR-0004: Infrastructure Layout — the .infra/ convention stands; this ADR defines its internal structure.


Context

ADR-0004 established the .infra/ convention and described its contents as a single flat Terraform configuration (main.tf, variables.tf, backend.tf, modules/), offering three independent options for environment separation (workspaces, tfvars, or separate directories).

The services that have since implemented .infra/audit-service, enrich-service, transcript-service — did not stay flat. All three converged independently on a multi-stack layout, where .infra/ contains several directories that are each an isolated Terraform stack with its own remote state key, not submodules of one state:

DirectoryState key (ontopix-tfstate)Cadence
deployment-roles/services/<svc>/... (own key)once
shared/services/<svc>/shared/...rarely
service/services/<svc>/{ENV}-{API_VERSION}/...every deploy

This convergence was not documented. ADR-0004's flat model no longer describes reality, and the de-facto layering has no agreed-upon naming or membership rule. Two problems follow:

  1. The doc lies. A new service author reading infrastructure-layout.md builds a flat .infra/ that diverges from every existing service.
  2. deployment-roles/ is named for its content, not its role. It names what is inside today (IAM roles), not why the layer exists. That blocks the layer from absorbing other pre-deploy concerns (OIDC provider, root secrets) without another rename.

The layering is real and correct — it isolates blast radius (a pre teardown cannot touch prod or DNS) and resolves a chicken-and-egg dependency (the role CI uses to deploy cannot be managed by that same role). What is missing is a principle that says which stack a resource belongs in, and a name that expresses it.

Decision

Formalize a three-layer model inside .infra/, where each layer is an isolated Terraform stack, and rename the pre-deploy layer from deployment-roles/ to bootstrap/.

The layers

<service>/.infra/
├─ bootstrap/   # enables the other layers; applied once; cannot self-manage
├─ shared/      # cross-environment / cross-version; slow cadence, normal flow
├─ service/     # the service itself; state PER environment; applied every deploy
└─ modules/     # reusable Terraform; no state of its own

Layer-membership test

A resource belongs to a layer by two axes — temporal cardinality (how often it changes) and dependency direction (what depends on what):

LayerTest — a resource belongs here if…Examples
bootstrap/the other layers need it to function, it is applied once, and it cannot be created within the normal deploy flow (chicken-and-egg)CI OIDC roles (ci-plan, ci-deploy, ci-e2e); a GitHub OIDC provider; a root secret CI reads before any deploy
shared/it is transversal to environments/versions, changes rarely, but is managed within the normal flow with the deploy rolecustom domain, ACM certificate, Route53 record
service/it is the service, and is re-applied on every iteration, parameterized per environmentLambdas, Step Functions, DynamoDB, S3, API Gateway, alarms
modules/it is reusable code with no state, consumed by the layers abovethe lambda module

The decisive axis is dependency direction. The canonical bootstrap/ member is the ci-deploy role: CI uses it to apply service/, so it cannot be managed by service/ — it must exist before, created out-of-band (a human with admin credentials, once). The state backend in the central infra repo's bootstrap/ is the same shape: Terraform needs it to store state, so it cannot be managed by a backend that does not yet exist. The name bootstrap is therefore coherent both vertically (central infra and per-service use it for the same idea) and horizontally (across all services).

bootstrap/ is an umbrella, not just roles

bootstrap/ is defined by the membership test above — "once + enabling + not self-manageable" — not by its current contents. CI roles are the only members today, but the layer is the home for any future pre-deploy primitive (OIDC provider, root secret, etc.). This is the reason for the rename: deployment-roles/ could not host a non-role resource without misnaming itself.

The rename

deployment-roles/bootstrap/ in every service .infra/. The associated Taskfile namespace infra:roles:* becomes infra:bootstrap:*, and the stack's state key moves accordingly. This is a breaking change to state keys and CI task names; it is sequenced as a separate migration (see Consequences), not bundled with this decision.

Relationship to the central infra repository

The central infra repo keeps its top-level bootstrap/ (state-backend init via init.sh) and global/ — ADR-0004's exception is unchanged. This ADR governs only the internal structure of per-service .infra/. The shared semantics of "bootstrap = what must exist before everything else" now hold at both levels.

Consequences

Positive

  • The doc matches reality. A new service author builds the layout every existing service already uses.
  • Membership is decidable. The two-axis test answers "which stack does this go in?" without case-by-case debate.
  • The name expresses a principle, not a payload. bootstrap/ generalizes to future pre-deploy primitives with no further rename.
  • Blast radius stays isolated. Per-environment service/ state means a pre teardown cannot reach prod or the shared domain.

Negative

  • Breaking migration. Renaming deployment-roles/bootstrap/ moves a state key and renames CI tasks (infra:roles:*infra:bootstrap:*). Each service needs a coordinated terraform state move and a CI update.
  • Naming proximity to the central bootstrap/. Two bootstrap directories now exist (central repo + per-service). They share semantics but differ in content (state-backend script vs. Terraform stack); the shared meaning is intentional, but readers must hold both.

Migration

  • Documentation-first. This ADR and infrastructure-layout.md are updated now; no repository is modified by this decision.
  • The per-service rename (git mv, Taskfile namespace, terraform state move, AGENTS.md updates) is a separate, scheduled migration requiring per-service validation.
  • Audit-service additionally carries an unreferenced .infra/bootstrap/ (a cold-start placeholder.zip wired to nothing). It must be confirmed dead and removed before the rename reuses that directory name, to avoid collision.
  • New services SHOULD adopt the bootstrap/ / shared/ / service/ layout from the start.

Examples

Per-service .infra/ (target layout)

audit-service/.infra/
├─ bootstrap/          # was deployment-roles/ — CI OIDC roles, own state
│  ├─ main.tf          # ci-plan, ci-deploy, ci-e2e
│  ├─ backend.tf
│  └─ terraform.tfvars
├─ shared/             # domain + ACM + Route53, own state
│  ├─ dns.tf
│  └─ backend.tf
├─ service/            # the service, state per ENV (pre|prod) × API_VERSION
│  ├─ main.tf          # backend "s3" {} — key injected by Taskfile
│  ├─ pre.tfvars
│  └─ prod.tfvars
└─ modules/
   └─ lambda/

State-key allocation

ontopix-tfstate/
└─ services/audit-service/
   ├─ <bootstrap key>            # bootstrap/ (CI roles)
   ├─ shared/terraform.tfstate   # shared/ (domain/DNS)
   ├─ pre-v1/terraform.tfstate   # service/ — ENV=pre
   └─ prod-v1/terraform.tfstate  # service/ — ENV=prod

Taskfile namespace (target)

# was infra:roles:*
infra:bootstrap:init:
  desc: Initialize the bootstrap stack (CI roles)
  dir: .infra/bootstrap
infra:bootstrap:plan:
  desc: Plan the bootstrap stack
  dir: .infra/bootstrap
infra:bootstrap:apply:
  desc: Apply the bootstrap stack (requires human approval)
  dir: .infra/bootstrap

References