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:
- 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 frompartner-registry.example.com. Verified live by the Sprint 022 (WS-7) customer onboarding (dossier line 82). - 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:
- Canonical —
release_orchestrator.components(Persistence migration009_component_version_registry.sql). Stores acomponent_json JSONBaggregate plus denormalised top-level columns (registry_integration_id TEXTetc.). Read/written byPostgresComponentVersionRegistryRepository. This is what the live WebApi/api/v1/componentssurface, sync-versions, deployment-bundle assembler, and UI all use. - Secondary —
release.components(Platform migration003_ReleaseManagement.sql). Singleregistry_integration_id UUIDFK, normalised columns. Present from earlier release-management code paths and joined by the olderrelease.releases+release.release_componentsqueries; does NOT participate in component CRUD or registry resolution today.
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:
- Read
env := request.environment(may be null for tenant-default sync). - If
envis non-null ANDcomponent.EnvironmentRegistryOverridescontains a case-insensitive key matchingenv:effectiveRegistryIntegrationId := override.registryIntegrationIdeffectiveImageRepository := override.imageRepository ?? component.ImageRepository
- Otherwise:
effectiveRegistryIntegrationId := component.RegistryIntegrationIdeffectiveImageRepository := component.ImageRepository
- Credentials for
effectiveRegistryIntegrationIdare resolved throughIRegistryCredentialResolver(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. - The sync service records
(effectiveRegistryIntegrationId, effectiveImageRepository)inversion.metadataso 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
- Zero-migration shipping. Override evolution is JSONB-only; no new column, no schema migration. Operators add or change overrides via
PATCH /api/v1/components/{id}and the round-trip persists. - Fits the existing aggregate. A component is read as one
SELECT component_jsonround-trip; the override is part of the same blob. No new join, no new query path. - Per-env auth comes for free. Because the override resolves to a different
registryIntegrationId, the existing credential-resolver chain handles per-env auth without any new surface. - UI fits the existing editor.
component-detail.component.tsalready has an override editor with specs; no new screen. - Extensible to fan-out additively. The mirror-list shape (
mirrors: [...]on each override entry) is a JSONB-only future change. No relational refactor.
Tradeoffs
- Override is invisible to the secondary
release.componentslayer. The relationalrelease.components.registry_integration_idcolumn carries only the default — env overrides live only in the canonical JSONB. Today no consumer reads the secondary layer for registry resolution, so this is fine. Any future code path that joinsrelease.componentsto derive a registry MUST be routed through the canonical layer first (or be considered a layer-boundary violation per the Sprint 027 RP-027-016 Bug B lesson on parallel persistence layers). - Override keys are operator-facing env-name strings, not env-id UUIDs. Consistent with how environments are surfaced through the rest of the API and UI; chosen because env-name strings are what operators type and what URLs and dashboards expose. The cost is a soft coupling: renaming an environment leaves the override key dangling. Operators must update the override map when renaming envs; the platform does not currently cascade.
- No relational integrity on
registryIntegrationIdinside the override map. A registry integration could be deleted while still referenced by an override; resolution would then fail at sync time, not creation time. Acceptable because the same is true for the top-levelregistryIntegrationId(also a TEXT column in the canonical layer); operators get a consistent error model.
Future work
- Multi-registry fan-out (Sprint 036 S036-002, deferred). Extends
ComponentRegistryOverrideDtowithmirrors: [{registryIntegrationId, imageRepository?, role?}]. Sync walks the chain in priority order; divergent digests fail closed; partial-auth and partial-404 emit structured warnings in version metadata. SeeSPRINT_20260528_036_*.md## Design (S036-001 outcome)for the algorithm and error codes. - Env-rename cascade. Optional future work — when an environment is renamed, optionally walk every component override map and update keys. Today operators do it manually.
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:
- Would have required a schema migration when the feature shipped. The JSONB-only path landed without one and matches the project’s preference for additive JSON-shape evolution over relational churn.
- Triples the round-trip cost on component reads (must JOIN the override table; today a single
SELECT component_jsonreturns everything). - Reintroduces the multi-store-per-aggregate consistency-drift risk that the Sprint 027 RP-027-016 Bug B lesson called out for
release_orchestrator.deployments— the canonical aggregate is one JSONB blob; splitting overrides into a sibling table would create the same drift surface for no behavioural gain. - The FK-integrity argument is weak: env names in the override are operator-facing strings, not env-id UUIDs. Even (B) would key on env-name string to be useful, defeating the integrity benefit.
© 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:
- Massively overshoots the actual use-cases. The operator-confirmed need is “this env pulls from registry A instead of registry B.” A strategy-chain abstraction is months of work for a one-line operator setting.
- Couples the environment record to registry-resolution concerns; environments today are infrastructure-shape (
order_index, freeze windows, required approvals) and not the right home for registry policy. - No operator has asked for it. The “policy chain” hypothetical is an invented requirement, not a real one.
References
docs/implplan/SPRINT_20260528_036_ReleaseOrchestrator_component_per_env_registry_override.md(S036-001 design outcome — captures the chosen pattern, rejected alternatives, fan-out extension shape, open questions)src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ComponentRegistryContracts.cs(DTO +ResolveImageRepository(env)/ResolveRegistryIntegrationId(env))src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ComponentVersionSyncService.cs(sync path consumer, lines 97–98)src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Endpoints/ComponentEndpoints.cs(create + update endpoints, lines 120–122, 172–174)src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Release/Models/Component.cs(domain recordEnvironmentRegistryOverrides+ComponentRegistryOverride)src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/009_component_version_registry.sql(canonical table)src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/003_ReleaseManagement.sql(secondary table, NOT used for override)src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.Integration.Tests/ComponentVersionRegistryTests.cs:402(round-trip assertion)src/Web/StellaOps.Web/src/app/features/release-orchestrator/components/component-detail.component.ts:424,536(UI editor)docs/modules/release-orchestrator/data-model/entities.mdline 132 (entity dossier)docs/modules/release-orchestrator/api/releases.mdline 261 (API dossier)- Sprint 022 (WS-7 customer onboarding) line 82 — anticipated this need
- Commit
ebb9a6ffdd(RP-027-009, Phase 2) — shipped the surface
