The `applications` object field was declared `policies` in variables.tf, but
the cms tfvars entry, the runbook (doc/runbooks/new-web-app/03-vault-platform.md),
the guidebook (vibe/guidebooks/tools/secrets-and-vso.md) and the module input
(modules/app_policy variable `ops_policies`) all use the name `ops_policies`.
Because Terraform silently drops unknown attributes when converting a value to
an object() type, cms's `ops_policies = ["factory__cf_r2_arcodange_tf"]` was
discarded and `each.value.policies` fell back to [] — so gitea_cicd_cms never
received the `factory__cf_r2_arcodange_tf` token policy (read on
kvv1/cloudflare/r2/arcodange-tf + kvv1/zoho/self_client, defined in
factory iac/cloudflare.tf). cms CI was missing its Cloudflare R2 Terraform-state
permissions.
Fix at the root: rename the schema field `policies` -> `ops_policies` (and its
single reference main.tf:82 `each.value.policies` -> `each.value.ops_policies`),
aligning the whole chain. This is lower-churn than renaming the tfvars key (the
chosen alternative would also have required fixing the runbook + guidebook, which
both already document `ops_policies`) and prevents the next app created from the
runbook from re-introducing the same silently-dropped key.
Behavioural change: gitea_cicd_cms gains `factory__cf_r2_arcodange_tf` in its
token_policies. No other app sets this field (all default []), so no other role
changes. Reviewer: confirm the R2 policy is the intended grant for cms CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A of the multi-environment evolution agreed in the erp repo design
thread. Both modules gain an optional env coordinate that defaults to
"prod"; by the elision rule, env=prod produces the existing single-env
derived names character-for-character, so every existing app's tofu plan
is a no-op.
app_roles (per-instance module — caller iterates over envs):
- variables.tf: add optional env = "prod"
- main.tf: compute local.instance via elision rule + local.owner_role
(snake-case <name>_<env>_role for the Postgres owner). The name/env/
database locals are grouped so fmt keeps the existing `name` alignment
(no whitespace churn on unchanged keys).
- main.tf: substitute local.name -> local.instance / local.owner_role in
the dynamic role name, k8s role name, SA bindings, token_policies
- outputs.tf: add env + instance outputs; kvv2_path_prefix derives from
local.instance (== local.name when env=prod → backwards-compat)
app_policy (per-repo module — accepts list of envs):
- variables.tf: add optional envs = ["prod"]
- main.tf: compute local.instances + local.non_prod_instances; remove the
now-dead bound_service_account_* alias locals (the allowed_parameter
blocks build their values from per_instance_sa_* maps instead)
- main.tf: kvv2 ops rules become dynamic blocks iterating local.instances
in the original order (data, delete, undelete, destroy, metadata), so a
prod-only app renders a byte-identical policy document
- main.tf: allowed_parameter for bound_service_account_* + token_policies
use comprehensions over local.instances (1-element → identical to old
static values for prod-only apps)
- main.tf: keep vault_policy.app (env=prod runtime policy) at its original
address; add vault_policy.app_non_prod via for_each over non_prod_instances
(empty set for prod-only apps → no new resources)
Top-level wiring:
- iac/variables.tf: add envs = optional(list(string), ["prod"]) to the
applications set(object) type
- iac/main.tf: pass envs = each.value.envs to app_policies
Verified: `tofu fmt -check` clean on all touched files, `tofu validate`
passes. Backwards-compat reasoning for the no-op plan is in the PR body.
Phase B (factory postgres iac + argocd + runbook docs) and Phase D
(erp iac/main.tf for_each + activate sandbox) follow in their own PRs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>