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.
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:
| Directory | State 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:
- The doc lies. A new service author reading
infrastructure-layout.mdbuilds a flat.infra/that diverges from every existing service. 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):
| Layer | Test — 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 role | custom domain, ACM certificate, Route53 record |
service/ | it is the service, and is re-applied on every iteration, parameterized per environment | Lambdas, Step Functions, DynamoDB, S3, API Gateway, alarms |
modules/ | it is reusable code with no state, consumed by the layers above | the 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 apreteardown cannot reachprodor 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 coordinatedterraform statemove and a CI update. - Naming proximity to the central
bootstrap/. Twobootstrapdirectories 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.mdare updated now; no repository is modified by this decision. - The per-service rename (
git mv, Taskfile namespace,terraform statemove, AGENTS.md updates) is a separate, scheduled migration requiring per-service validation. - Audit-service additionally carries an unreferenced
.infra/bootstrap/(a cold-startplaceholder.zipwired 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
- ADR-0004: Infrastructure Layout — the
.infra/convention this ADR refines - Infrastructure Layout Pattern — updated to document the layer model
- ADR-0011: Lambda Deploy Strategy — the code-vs-infra split the
service/layer assumes - Lambda Deploy Pattern — OIDC role tiers that live in
bootstrap/
ADR-0014: setup:* Namespace as First-Use Setup Standard
Replaces dev:setup and dev:install with a setup:* namespace that models the distinct lifecycle of first-use setup operations independently from the daily developer workflow.
Local Development Onboarding
The path from clone to a green local build and lint, including the AWS/CodeArtifact prerequisite for repositories that consume private @ontopix packages.