ADR-0012: Multi-environment Helm emitted as environments: list with nullable primary image_reference¶
Status: Accepted Date: 2026-05-12 Tags: schema · additive-extension · localv2-conformance · contract-shape Related: ADR-0004, ADR-0011
Context¶
localv2.md §5.1 A5 specifies the deployment slice with image_reference as a singleton field (one image reference per deployment). Real Helm repos frequently ship multi-environment value overlays — values.yaml + values-prod.yaml + values-staging.yaml + values-dev.yaml, each potentially overriding image.repository or image.tag to a different value. A singleton field cannot represent this without lying about reality.
The best-practices lens proposed emitting image_reference as a list. That violates localv2.md §5.1 A5's explicit singleton example and forces every Phase 2+ consumer to handle a shape change to a previously-singleton field. The critic (final-design.md §"Conflict-resolution table") framed this as [B] Risk #2: the singleton-vs-list disagreement is real and must be resolved.
The synthesizer's resolution (final-design.md "Components" #6): keep image_reference as a nullable singleton for the single-environment case, and add a new environments: list[EnvironmentEntry] field for the multi-environment case. Additive at the per-probe sub-schema; honors localv2.md's singleton shape; reflects reality.
Options considered¶
image_reference: list(the best-practices lens proposal). Reshape the existing field. Violateslocalv2.md §5.1 A5's singleton example; every downstream consumer breaks; the Phase 0 §2.3 "localv2.mdis source of truth" rule blocks it.image_reference: oneOf [string, list[string]]. Schema-level type union. Consumers must runtime-type-check every read; nightmare to evolve.- Drop multi-env support; emit only the first values file's image. Hides reality. Phase 3+ recipes get incomplete information.
image_reference: nullable singletonfor single-env case, additiveenvironments: list[EnvironmentEntry]for multi-env case. Honorslocalv2.md; reflects reality additively; downstream consumers handle one shape at a time.
Decision¶
The deployment sub-schema declares two fields:
image_reference: ImageRefBlock | null— the primary/single-environment image reference. Set forvalues.yaml-only repos.nullfor multi-environment repos where there's no canonical primary.environments: list[EnvironmentEntry]— one entry per detectedvalues-*.yaml. EachEnvironmentEntry:{name: str, image_reference: ImageRefBlock, ...}.
Detection rule:
- If only values.yaml is present → image_reference set; environments: [].
- If values.yaml AND values-<env>.yaml files are present → image_reference may be set from values.yaml (or null if no top-level image reference exists); environments lists every values-<env>.yaml.
- Single env emitted as environments of length 1 is allowed (canonical when there's no values.yaml baseline).
The EnvironmentEntry's name derives from the filename stem (values-prod.yaml → name: "prod", values-staging.yaml → name: "staging"). Each entry's image_reference is captured the same way as the primary.
Downstream Phase 3+ consumers must handle the list shape from day one (phase-arch-design.md "Integration with Phase 2 (next phase)" — explicit implicit guarantee). The "consumer contract" question — whether some consumers can treat environments as authoritative when present and ignore the primary — is recorded as final-design.md "Open questions" #6.
Tradeoffs¶
| Gain | Cost |
|---|---|
Honors localv2.md §5.1 A5's singleton shape — Phase 0 §2.3 conformance preserved |
Two fields where one might have sufficed; downstream consumers handle both |
| Multi-env repos report all environments accurately; Phase 3+ recipes can pick the right one (e.g., "use prod's image tag for the bump recipe") | The "what's THE image reference?" question has no single answer for multi-env repos; consumers handle it semantically |
| Additive at the per-probe sub-schema — ADR-0004 strictness preserved | The sub-schema declares both fields; a consumer that only reads image_reference silently misses environments |
| Composes with ADR-0011 — no Helm template rendering required to capture multi-env evidence; values-file parsing is sufficient | If a deployment's image is computed via helm template interpolation, neither the primary nor the environments list captures the resolved value — Phase 3+ renders if needed |
The name derivation from filename stem is deterministic and matches Helm-community convention |
Repos with non-standard naming (e.g., values.prod.yaml instead of values-prod.yaml) get name: "prod.yaml" — captured as a warning if format unrecognized |
Empty environments: [] for single-env repos is unambiguous; nullable image_reference distinguishes "no image found" from "no environments listed" |
Three states to handle (null primary + empty env / set primary + empty env / null primary + non-empty env) — documented in sub-schema comments |
| Sub-schema declares both shapes upfront — no future-breaking change when a single-env repo adopts multi-env structure | Sub-schema is more complex; tests/unit/probes/test_deployment.py covers all four shape permutations |
Consequences¶
src/codegenie/schema/probes/deployment.schema.jsondeclaresimage_referenceasnullableandenvironmentsas an array ofEnvironmentEntry-shaped objects. Both at root level;additionalProperties: falseenforces strictness (ADR-0004).DeploymentProbeenumeratesvalues*.yamlglob-matched paths, parses each withsafe_yaml.load(10 MB cap, depth 64), and assembles theenvironmentslist. The primary is read fromvalues.yamlif present.tests/unit/probes/test_deployment.pycovers: (a)values.yamlonly →image_referenceset,environments: []; (b)values.yaml+values-prod.yaml+values-staging.yaml→ primary + 2-entry environments; (c)values-prod.yamlonly (no baseline) →image_reference: null, 1-entry environments; (d)values-prod.yaml+values-staging.yaml+values-dev.yaml→ 3-entry environments.tests/unit/probes/test_deployment.pyalso covers the 12-environment case (Edge case #15) —additionalProperties: falsecontinues to bind on each entry.- Phase 3+ task-class consumers query
environmentsfirst if multi-env is in scope; fall back toimage_referencefor single-env. The convention is documented in the sub-schema's description field. - Phase 7's distroless migration is largely unaffected — it consumes
manifests.native_modules, notdeployment.image_reference. But it inherits the precedent for future multi-shape additive extensions. - The "open question" #6 (
final-design.md) — whether consumers can ignore the primary whenenvironmentsis non-empty — remains a Phase-3 consumer-contract decision; this ADR captures the data shape, not the consumer semantics.
Reversibility¶
Low. Folding environments back into image_reference: list is a breaking sub-schema change requiring envelope-major-version invalidation. Existing Phase 1 cached outputs continue to validate against the additive shape; reverting requires re-gathering every repo. The forward direction (additional per-environment fields under EnvironmentEntry) is symmetric and additive. The shape choice (additive list-alongside-singleton) is deliberately the most future-compatible of the four options.
Evidence / sources¶
../final-design.md "Components" #6 DeploymentProbe— multi-env-as-list design../final-design.md "Failure modes & recovery"row 13 — schema accepts both shapes; consumer contract test verifies handling../final-design.md "Open questions deferred to implementation" #6— consumer contract open question../phase-arch-design.md "Component design" #6 DeploymentProbe— interface specifics../phase-arch-design.md "Data model" DeploymentSlice— Python shape../phase-arch-design.md "Edge cases"row 15 — 12-environment case../../../localv2.md §5.1 A5— the singleton example this honors- ADR-0004 — per-probe sub-schema strictness this rides on
- ADR-0011 — no Helm rendering decision that bounds what this captures