ADR-009: Component per-environment registry override (JSONB-on-aggregate)

Status: Accepted (retroactive) Date: 2026-05-28 (decision shipped 2026-05 in commit ebb9a6ffdd, RP-027-009; captured here by Sprint 036 S036-001) Sprint: SPRINT_20260528_036_ReleaseOrchestrator_component_per_env_registry_override.md (S036-001) Related ADRs: ADR-004 (forward-only migrations), ADR-005 (single-operator multi-environment tenancy), ADR-006 (execution plugin framework) Related docs: docs/modules/release-orchestrator/data-model/entities.md (Component entity), docs/modules/release-orchestrator/api/releases.md (Components section)

This record lets a release component pull from a different registry per environment under the same tenant, carried as a JSONB-embedded override map on the canonical component aggregate — no relational migration. Read it before touching component registry resolution, the component editor, or any consumer that dereferences a component to a concrete registry and repository.

Context

A release component (an image identity tracked by the Release Orchestrator) carries a default registry_integration_id plus an image_repository. Real customer estates have two needs the single-FK shape cannot express:

  1. Per-environment registry override. Different environments under the same tenant pull from different registries — e.g. internal envs from registry.example.com, an external partner env from partner-registry.example.com. Verified live by the Sprint 022 (WS-7) customer onboarding (dossier line 82).
  2. Multi-registry fan-out / mirroring. Cache registries, DR registries, regional mirrors that all carry the same digest for the same (repository, tag). Hypothetical today; no operator has surfaced the need.

The Stella Ops component model lives in two parallel persistence layers:

The canonical layer is the place where registry-resolution decisions are made. Any solution must live there.

Decision

The component aggregate carries an optional per-environment registry override map as a typed property on the domain Component record and a Dictionary<string, ComponentRegistryOverrideDto> on the WebApi DTO, persisted inside the canonical component_json JSONB blob. The override is keyed by environment name (case-insensitive string match), and each entry carries the env-specific registryIntegrationId plus an optional env-specific imageRepository. Resolution falls back to the top-level component fields when the env is not in the map.

Multi-registry fan-out is out of scope for this ADR and the current shape. When and if it lands, it extends the same JSONB-embedded shape additively (a mirrors: [] field on each override entry) — no relational migration. The resolution algorithm + error semantics for fan-out are sketched in the S036-001 dossier outcome (docs/implplan/SPRINT_20260528_036_ReleaseOrchestrator_component_per_env_registry_override.md ## Design (S036-001 outcome)).

Shape (live)

// release_orchestrator.components.component_json (excerpt)
{
  "id": "c0c0c0c0-...",
  "name": "app-backend-domain-api",
  "imageRepository": "example-corp/app-backend/domain-api",
  "registryIntegrationId": "registry-primary-inline",      // default for envs not in the map
  "versioningStrategy": "branch-tag",
  "environmentRegistryOverrides": {                        // case-insensitive env-name keys
    "partner-qa": {
      "registryIntegrationId": "registry-partner-internal",
      "imageRepository": "partner/app-backend/domain-api" // optional; defaults to top-level
    }
  }
}

Resolution algorithm (env-override, shipped)

For every consumer that needs to dereference a component to a concrete registry + repository for a given environment:

  1. Read env := request.environment (may be null for tenant-default sync).
  2. If env is non-null AND component.EnvironmentRegistryOverrides contains a case-insensitive key matching env:
    • effectiveRegistryIntegrationId := override.registryIntegrationId
    • effectiveImageRepository := override.imageRepository ?? component.ImageRepository
  3. Otherwise:
    • effectiveRegistryIntegrationId := component.RegistryIntegrationId
    • effectiveImageRepository := component.ImageRepository
  4. Credentials for effectiveRegistryIntegrationId are resolved through IRegistryCredentialResolver (the Sprint 027 RP-027-005 + Sprint 029 FIX-002/003 chain). The env-override therefore gives a per-env auth realm “for free” — each registry integration carries its own credential backend.
  5. The sync service records (effectiveRegistryIntegrationId, effectiveImageRepository) in version.metadata so later release-authoring and deploy paths can faithfully re-derive the pull-source.

This is ComponentRegistryDto.ResolveImageRepository(env) and ComponentRegistryDto.ResolveRegistryIntegrationId(env) in src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ComponentRegistryContracts.cs (lines 30–52). Consumed by ComponentVersionSyncService.cs at lines 97–98.

Consequences

Positive

Tradeoffs

Future work

Alternatives Considered

(B) Relational component_environment_registry_overrides table

Rejected. Pros: ad-hoc SQL (“which components target registry X in env Y?”); foreign-key integrity on environment_id. Cons that disqualified it:

© Per-env registry_resolution_strategy enum on the environment + policy chain

Rejected. Pros: most flexible — could express “in production, always check Notary first, then registry, then DR.” Cons that disqualified it:

References