You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implement the mechanism specified in ADR-015 (docs/design/015-recipe-configuration-profiles.md, PR #1762): a single enumerated configuration profile per resolved recipe, selected at recipe generation, persisted in the hydrated artifact as a compact ownership record, gated behind a new recipe artifact apiVersion, and enforced at every recipe boundary.
Scope (per the final ADR — read it first; it is authoritative)
Profile declaration & resolution (pkg/recipe):
Singular spec.profile declaration (name, description, mandatory default naming a declared value — catalog-validated, values).
Closed per-value fragment schema — a value permits advertiser, constraints, and componentRefs only; each ref permits only name and overrides (every other ComponentRef field is rejected at catalog load). The admission criterion: a fragment field is permitted only when its effect is either representable in ownedPaths (value paths plus the synthetic enabled) or persisted verbatim in the artifact with no bundle-time override surface (constraints join the artifact's constraint set; advertiser is recorded in selectedProfile) — an effect the artifact cannot carry is an effect the lock cannot protect. Every other ComponentRef field fails both tests:
valuesFile — additionally rejected for a merge-semantics reason: a later valuesFilereplaces the earlier one rather than merging, so a profile could change effective values by removing a file — through paths that appear nowhere in the fragment and that no syntactic union could lock. Deferred design sketch, should a consumer ever require it: derive owned paths from each value's semantic before/after delta rather than from fragment syntax.
Fragments may reference only components enabled in the pre-profile resolved composition — membership validated at resolution step 4, against the post-filtering surviving composition, before fragment application, and declaration-wide: every component referenced by any declared value (not only the selected one) must be enabled, because ownedPaths is declaration-wide and a selection must never record lock paths for a component absent from its composition (a snapshot exclusion disabling any referenced component fails generation).
Union totality (catalog-validated): every declared value must assign every path in the declaration's fragment-derived, leaf-flattened override-path union, quantified before synthetic presence paths are added (equivalently: all declared values have identical leaf-flattened path sets); the synthetic per-component enabled path is part of ownedPaths but exempt from totality (fragments may not assign root enabled) — owned paths are never inherited from the baseline (supersession applies only the selected fragment, so an omitted owned path would let a pre-profile assignment survive the selection and be locked-and-attested as if qualified). A fragment override must not set the root enabled key (catalog-load rejection — overrides.enabled: false is component removal through the value map). Component addition/removal and ordering semantics are a future ADR amendment, not v1 capability. Overrides inherit ordinary recipe merge semantics including whole-list replacement.
Any RecipeMetadata carrying the new apiVersion is decoded strictly in full, and new-version metadata without spec.profile is rejected. Strict decoding is wired at the catalog/metadata-store loading layer (not a global serializer.Reader mode change) and keys solely on the new apiVersion. A typo of the profile key itself (spec.profie) cannot silently unprofile a catalog; misspelled keys inside the declaration are catalog-load errors. (Mechanism rationale: overlay metadata is decoded non-strictly today, so unknown YAML keys are silently dropped before any validation runs — which is why strictness must key on the artifact apiVersion, not on finding a profile; today the same typos yield, respectively, a silently unprofiled catalog (spec.profie) and a silently effect-less or constraint-less profile (a misspelled constraints or overrides key inside the declaration).)
Fragment override paths must not contain literal dotted map keys (rejected at catalog load — the flattened ownedPaths record cannot disambiguate them, so the lock/guard could miss escaped writes; escaped representation or parent-map promotion is a future amendment). The dotted-key restriction stays scoped to profile fragments. The concrete case is an annotation-style key such as example.com/owner: the flattened record cannot distinguish one literal dotted key from nested segments, so a naïvely segmented lock or install-time guard could miss an escaped write to the literal key; the amendment paths, precisely, are an escaped path representation or promotion of ownership to the nearest unambiguous parent map.
Nested empty-map assignments (feature: {}) are rejected at catalog load (no owned path is recorded, yet merge replaces a prior scalar/list — an unlockable effect: an effective, persisted change the ownership record cannot represent and the lock therefore cannot protect; the future amendment is promoting the empty map's containing path into ownedPaths).
Profile and value names are lexically constrained at catalog load (nonempty, [A-Za-z0-9._-], no =/whitespace — keeps the name=value wire form unambiguous). Names are overlay-scoped identifiers, not reserved keywords — validity is membership in the declaring overlay's enum; the grammar exists because the single name=value wire form is carried verbatim by the CLI flag, the GET parameter, and the POST envelope, and must stay unambiguous across all three. The one piece of reserved vocabulary is advertiser (only value external), rejected at every boundary when unknown.
There is deliberately no catalog-time ownership check against injection/mutation paths — a registry path is only a potential mutation target (selectors inject only when supplied), so owning one is legal and bundles safely until a conflicting flag or defaulting write fires; the bundle-time lock invariant (Override locking below) is the single authoritative check.
Composition-wide uniqueness enforced over the criteria-matched candidate set before snapshot filtering — dedup by declaration source (the same declaring overlay reached through multiple chains counts once; structurally identical but independently authored declarations still fail; mixins cannot declare profiles).
Resolution ordering — overlay/mixin exclude-and-record filtering preserved with one profile-specific exception: if a declaration existed pre-filter and no surviving chain carries it, generation fails with the excluding constraints' diagnostics (no silent base-configuration fallback).
Profile fragment applied at highest precedence after the surviving composition — the fragment authoritatively supersedes earlier assignments of its owned paths (base/overlay values files routinely assign them; a mechanical collision rejection would reject every declaring family), with conversion-time review of descendant/external specializations as a documented obligation, not a runtime check.
Profile-contributed constraints merge under the mixin collision rule (a name collision with a chain/mixin constraint rejects at resolution — constraints don't compose, matching mergeMixins; values of one declaration may reuse names across values), fail closed at generation, and are merged into the artifact's constraint set for aicr validate re-evaluation.
ownedPaths = union of profile-owned paths across all declared values, derived by leaf flattening (every non-map assignment contributes its dotted path; map values recurse), ordered pure-lexicographic per component (digest-included, byte-stable — do NOT reuse pkg/component/overrides.gosortedOverridePaths, which orders dot-depth-first (shallowest-first) and would yield different bytes). Component presence is the synthetic enabled path (no separate lockedComponents).
The GPU allocation policy: device-plugin production default with recipe-configured DRA opt-in #1327 policy closure is never persisted in RecipeResult artifacts — recomputed at every artifact boundary from the canonical descriptor. Rendered outputs are the exception: any pre-expansion-legal write (static override, injection, derived mutation, subset, or --dynamic export persisted operator-editable — Helm/Helmfile cluster-values.yaml, Flux values ConfigMap, argocd-helm root values/guard) can be baked into output that diverges or stays mutable at a newly locked path.
Descriptor-expansion remediation is outcome-based, not per-deployer: regenerate and re-attest every previously rendered profiled output that diverges or exposes install-time mutability at a newly locked path/presence — or conservatively all outputs affected by the new entry. Each expansion is also a re-qualification and evidence re-signing event for affected profile values (the closure is digest-absent by design, so expansion changes no recipe digest and old evidence still gate-matches despite predating the new selector).
Evidence currentness is an outcome invariant, scoped as a profile-only ADR-007 amendment — profile-bearing evidence is current only when both the recipe digest and the canonical-descriptor identity in the signed predicate match — the identity is the deterministic identity of the canonical-descriptor entries contributing to that recipe's effective closure (an empty contribution set yields the deterministic empty-set identity; an expansion not touching a recipe's closure leaves its identity matching); missing or mismatched identity is historical-only; unprofiled evidence keeps ADR-007's digest-only rule (existing predicates carry no identity — a global rule would strand legacy evidence or, as a wildcard, recreate the bypass). That scoped identity lives in the evidence payload, never in RecipeResult (a global revision would invalidate unaffected families; ADR-007 pointers are add-only, so re-signing alone cannot retire pre-expansion evidence).
Profile-bearing evidence rides a new predicate type, bidirectionally enforced: released verifiers hard-require PredicateTypeV1 (pkg/evidence/verifier/pointer.go:85, verify.go:246) and decode predicates non-strictly, so an additive identity field would be silently ignored (fail-open) — a new type fails closed on released verifiers for free. New verifiers reject v1-predicate evidence for a profile-bearing recipe (accepting both would let a new profiled artifact pair with freshly signed v1 evidence, bypassing descriptor identity); unprofiled evidence keeps the v1 predicate. Add the "Amended by ADR-015" banner to ADR-007 on acceptance (alongside the ADR-011 banner).
TestGrid predicate support: tools/testgrid-publish/bundle.goloadPredicate (line ~143) rejects every non-empty predicate type except attestation.PredicateTypeV1 (an empty predicateType is accepted) — since profile-bearing evidence rides the new predicate type, the tool must accept the new type or profiled evidence can never publish. The profile value must join a path-forming key segment in the pkg/evidence/project corroboration tree: it keys results by recipe coordinate + signer + runID (synthesize.gorunDir, os.RemoveAll+rename replace semantics, and CI ingest passes one FIRST_PARTY_RUN_ID per workflow run) — all identical across two values of one family — so metadata- or display-label-only placement leaves one value's results silently overwriting another's, and Meta today carries no profile field; whether the segment lands in the coordinate or the run key is the residual product decision. tools/testgrid-publish needs no path change — its build ID is digest-bound (buildid.go) and profile selection changes the bundle digest, so per-value results already land at distinct GCS paths; any profile name/value display metadata is a product decision. The evidence gate stays digest-authoritative either way.
Repo evidence gate per-value digests: .github/scripts/recipe-evidence-check.sh (~line 446) computes one selection-less aicr evidence digest -r <overlay> per slug — under this ADR that hydrates the declared default, so committed evidence for any non-default value would permanently report as digest-drifted. The gate must recompute each pointer's expected digest from that pointer's recorded selection (the new predicate type carries the profile identity); precomputing digests for every declared value is permitted only as a selection → digest map — each pointer compares against the digest for its own recorded selection, never against "matches any current value".
Full inventory of hard-coded v1 predicate sites (every site must handle the new profile predicate type; verifiers reject v1 evidence for profiled recipes, producers emit the new type for them): predicate producers pkg/evidence/attestation/predicate.go:117,166; publisher gate pkg/evidence/attestation/publish.go:236; pointer producer pkg/evidence/attestation/pointer.go:61; signature verifier pkg/evidence/verifier/signature.go:248; pointer verifier pkg/evidence/verifier/pointer.go:85; envelope verifier pkg/evidence/verifier/verify.go:246; TestGrid tools/testgrid-publish/bundle.go:143 (above). The umbrella deliverable covers these; the inventory exists to reduce omission risk.
Strict decode at every full-artifact byte boundary for v1alpha3 RecipeResult — files AND /v2/bundle POST bodies: unknown fields, duplicate/trailing JSON documents, and a malformed selectedProfile subtree fail closed (an ownedPath typo silently dropped by non-strict JSON would strip every lock from an AKS-shaped profile while the version cross-check still passes; "machine-generated" is not a trust boundary on a public HTTP endpoint — which is why strictness applies to /v2/bundle POST bodies exactly as to files, not only to hand-edited artifacts). Decode strictness cannot protect typed Go callers, so the shared gate also structurally validates the selectedProfile subtree (required fields present and well-formed).
Projection-decoder boundary inventory — every recipe-byte consumer first gates on kind/apiVersion; consumers materializing v1alpha3 use the shared strict full-artifact decoder; lightweight projections must not silently discard profile identity. Concrete decoders: pkg/evidence/project/synthesize.goreadRecipeView (a non-strict two-field projection of the bundle recipe.yaml with no version gate; caller tools/evidence-project/main.go) and tools/testgrid-publish/bundle.goparseCriteria/recipeFile (same shape). Both must header-gate and, for v1alpha3, decode via the shared strict decoder before projecting.
Descriptor expansion is a minimum-binary-version cut-over for affected profiled families: expansion strengthens artifacts only when processed by binaries at or above the recorded minimum — an older profile-aware binary keeps computing the old closure, producing under-locked output, and honoring its old descriptor identity. Operational rollout boundary (documented per expansion), not an enforcement mechanism.
Acceptance: profiled evidence with matching identity is current; profiled evidence with missing or mismatched identity is historical-only; unprofiled evidence remains current on digest match alone.
GPU allocation policy: device-plugin production default with recipe-configured DRA opt-in #1327 amendment (dependency-neutral package + pkg/validator/v1): optional advertiser: external marker (only value; operator ownership read from effective values, never declared/inferred); external advertiser counted in the exactly-one invariant with both dual-advertisement gates extended fail-closed; resolved policy value unchanged (device-plugin-extended-resource); closure trigger = advertiser: external or explicit ownership of a non-synthetic policy-selector path (the synthetic enabled presence path never triggers it; locks follow ownership — profiles that do not own advertisement leave allocation-policy keys on today's WARN semantics).
One shared gate:
Canonical policy descriptor + tuple-coherence evaluator in a dependency-neutral package (placement: under pkg/recipe or a dedicated policy package — recipe finalization cannot import pkg/bundler, which already imports recipe).
This retires a duplication: today the policy path vocabulary exists twice — gpuAllocationPolicyPaths, private to pkg/bundler, and an independent copy in pkg/validator/v1/allocation_policy.go. The import cycle is why finalization cannot reuse the bundler's map, hence the dependency-neutral home.
One canonical descriptor and one shared policy evaluator (advertiser vocabulary + tuple-coherence rules), consumed by all four: profile finalization, PrepareAndValidate, bundler enforcement, and validation-time policy resolution — so generated, disk-loaded, POSTed, and direct-bundler recipes all fail on an incoherent tuple before any output is written.
A copied map or a second evaluator would let a future policy key or advertiser value silently reopen the bypass this amendment closes.
The descriptor is append-only while any supported artifact may reference an entry: the closure is recomputed, not persisted, so removing/renaming a selector path would silently unlock it on older authentic recipes. Removal requires a deprecated tombstone for the support window or ending support — an apiVersion bump alone does not permit it, since ADR-011 transition windows keep the prior version accepted.
The apiVersion/selectedProfile cross-check is added to the existingRecipeResult.PrepareAndValidate (pkg/recipe/metadata.go:533; already called by the loader, adoptRecipe, and DefaultBundler.Make at bundler.go:237) so file loading, HTTP adoption, and direct bundler callers share one implementation.
The tuple-coherence check additionally requires a context-aware form (PrepareAndValidateWithContext(ctx)) that hydrates the effective values of the advertiser components and of every component contributing to the effective lock set, through the recipe's bound provider — today the method never hydrates values, and the conflicting toggle typically lives in a component values file, not the artifact.
Hydration and tuple evaluation are gated on the artifact carrying selectedProfile (skipped entirely for non-profile artifacts — no new I/O or failure modes on legacy paths). The hydrating gate also rejects a profile-bearing artifact whose effective configuration is blocked at any locked path (incoherent ownership record — see the identity relation under Override locking).
All four raw-artifact boundaries (file loader, adoptRecipe, DefaultBundler.Make, mirror.Lister.Discover) invoke the context-aware form; the ctx-less PrepareAndValidate remains the non-hydrating shape + version/profile gate. The mirror path must additionally be wired to call it (mirror.Lister.Discover does not today) before the shared lock validator.
Pre-output lock invariant (authoritative) — compute the effective lock set (ownedPaths + recomputed closure) once per invocation; before any output is rendered or written (bundle and mirror): (1) state — the effective candidate configuration must equal the hydrated selected recipe at every locked path and locked component-presence state; (2) mutability — no supported output surface may expose a locked path or presence state as an install-time parameter (--dynamic export of a locked path fails even with the bundle-time value unchanged).
Writer-proof by construction — covers user overrides in all forms, the bundlers/WithBundlers filter, registry-driven injections (system/accelerated node selector + toleration paths, workload selector, workload-gate taint, node count, storage class), bundler-derived defaulting (DRA chart-version pod-annotation, agentgateway allowedSourceRanges), registry drift, and future mutation sources.
Locked-path identity is a three-valued observation — present (canonical bytes), cleanly absent, or blocked (a non-map ancestor makes the leaf untraversable) — evaluated on the effective post-merge candidate, with bytes from serializer.MarshalYAMLDeterministic (the digest serializer) per present value. Identity requires the same observation on both sides: an ancestor replacement like --set-json <c>:operator=null or a scalar diverges at every locked path beneath it, even though a naive leaf lookup reports "absent" for both sides.
Blocked is load-bearing: the ancestor replacement destroys the entire subtree beneath it, and a naive per-leaf lookup reporting "absent" on both candidate and recipe sides would pass absent==absent; under the three-valued observation the non-map ancestor makes every locked leaf beneath it blocked on the candidate side, and blocked never matches the recipe side, so every locked path beneath the replacement diverges.
A recipe-side blocked observation is invalid — a profile-bearing recipe blocked at any locked path is rejected at generation and at the hydration gate, so blocked is exclusively a candidate-side divergence (no blocked-equals-blocked pass; at generation the constructible case is a closure-locked path under a scalar baseline ancestor — union totality plus map-over-scalar merge replacement means a catalog-valid selected value heals every union path it assigns; recipe-side union-path blockage arises only from a malformed/forged raw artifact or a direct typed caller, caught at the hydration gate).
Absence and explicit null are distinct when the null persists: a leaf-scalar null is stored and diverges (it strips chart defaults); a map-shaped null deletes the key, leaving recipe-identical absence, and passes — no writer-level null-token rule (the samenull token both passes and fails depending on where merge semantics leave it; only the post-merge effective candidate can be judged, and any token-level null rule would get one of the two outcomes wrong). Coercions serializing identically pass; numerically equal values diverge exactly when their bytes differ (float64(1000000) → 1e+06 vs 1000000) — byte-level, never mathematical; numeric canonicalization is deliberately not attempted (equality is byte identity of the per-value canonical bytes from the digest serializer — deterministic, never mathematical).
The closure contributes lock entries only for components enabled in the selected recipe (absent or declared-but-disabled components lock nothing). Bundle and mirror share canonicalization (recipe-bound alias/component-key resolution), not merely validator code — with two canonicalizations, the same input could produce different verdicts at the two boundaries. Per-surface matcher checks (exact/parent/child + registry-alias) remain early diagnostics only, attributing a divergence to the causing flag.
Generation-side mutators never write locked paths — on a profile-bearing composition, the shipped snapshot-driven driver auto-override (applyGPUDriverAutoOverride, only-false driver.enabled injection, defined in pkg/client/v1/gpu_driver_state.go, called from aicr.go) skips paths in the effective lock set and logs the skip (a generation-time write to an owned path would bake divergence into the artifact itself, unreachable by the pre-output invariant). Non-profile compositions keep today's behavior.
enabled pseudo-path semantics — reject explicit enabled-state divergence, and reject a bundlers/WithBundlers subset omitting any component contributing a path to the effective lock set — ownedPaths or the recomputed closure (the invariant cannot evaluate locked paths absent from output — sufficient alone; the attestation-incoherence rationale applies to declaration-named components only, since the closure is never persisted in selectedProfile; on GKE the closure locks nvidia-dra-driver-gpu, inherited from base by every recipe). Subsets omitting unrelated components keep POST /v1/bundle: documented 'bundlers' query param is silently ignored #1531 satisfied-externally semantics; re-enabling a recipe-disabled component stays rejected.
mirror.Lister.Discover runs the shared gate (PrepareAndValidateWithContext) first against a defensive copy (discovery must not mutate its input; Discover output unchanged for well-formed recipes), then the same shared lock validator (CLI and direct callers).
argocd-helm install-time guard (deployer parity is a requirement: conversion is family-wide, so profiled argocd-helm stays supported, never rejected) — profiled argocd-helm bundles emit a template-time guard that fails rendering on any install-time input structurally intersecting a locked path: the locked key, an ancestor supplied as a non-map replacement (the root chart merges via mustMergeOverwrite, so a parent-shaped --set-json replaces the subtree without the leaf existing), or a descendant — mirroring the bundler's exact/parent/child matching.
Why the guard exists (mechanics): the argocd-helm root chart deliberately makes every component value overridable at helm install time — --dynamic only pre-populates root values, and no declaration is needed to override (pkg/bundler/deployer/argocdhelm). An installer could therefore change an owned path without touching the attested bundle, and rejecting --dynamic on owned paths is moot for this deployer. No final candidate values exist at install time to compare, so the guard is the install-time equivalent of the invariant's mutability condition.
Locked paths cannot be --dynamic, so they are absent from root values by construction; the guard rejects structural presence, never compares values. It is derived from the alias-expanded effective lock set keyed by each component's canonical root override key, and its failure message names the locked path.
It is the only lock emission — no values.schema.json lock constraints (schema validation is skippable via --skip-schema-validation while templates always render; the existing deployer.* schema is unchanged); the guard is the enforcement — encoding the lock a second time into the schema would duplicate the matcher semantics for zero enforcement weight.
Artifact compatibility gate:
New recipe artifact apiVersion aicr.run/v1alpha3 for RecipeMetadata and RecipeResult only (snapshots/configs stay on aicr.run/v1alpha2).
RecipeCriteria stays on the legacy version: today RecipeCriteriaAPIVersion aliases RecipeAPIVersion and criteria readers require exact equality at criteria.go:935, so a naive alias bump would reject existing criteria files and /v1 POST bodies. The version bump must therefore de-alias the constant — pin RecipeCriteriaAPIVersion to the legacy version independently of RecipeAPIVersion. Profile selection stays outside RecipeCriteria via the v2 envelope.
New binaries accept legacy (non-profile) + new (profiled); snapshots/configs unchanged. Cross-checks fail closed both directions (legacy or empty apiVersion carrying selectedProfile; new version without it). Explicit kind-scoped amendment to ADR-011 (reciprocal banner on acceptance).
Catalog-load version gate — new binaries enforce the declaring overlay's apiVersion at catalog load (the loader today checks kind only, never apiVersion, pkg/recipe/metadata_store.go). Profile-declaring --data catalogs are declared incompatible with older binaries (including rollback). The cross-check is bidirectional (a declaring overlay must carry the new version; a new-version overlay must declare); only non-declaring overlays with a legacy, empty, or unknown apiVersion keep today's kind-only loading, pending the Deferred Decision 4 follow-up.
Released file loaders need no retrofit — released binaries' file loaders already reject the new version through the existing IsSupportedAPIVersion gate (pkg/recipe/loader.go and the snapshot loader are that gate's only production callers), so the file boundary fails closed for free. The REST, SDK, and catalog boundaries cannot be retrofitted and get the individual treatments above (versioned endpoints, documented SDK limitation, catalog-load version gate).
REST:
Profile operations only on /v2/recipe, /v2/query, /v2/bundle; released servers 404. The released /v1 handler mechanics that force this:
/v1/bundle: pkg/server/bundle_handler.go decodes the POST body directly into a RecipeResult and adopts it via adoptRecipe → PrepareAndValidate with no apiVersion or kind check — a released server accepts a new-version profiled result, silently drops selectedProfile (non-strict decode), and permits every override the profile forbids.
/v1/recipe (and /v1/query, which resolves through the same client path): ParseCriteriaFromRequest parses only the query parameters it knows, so ?profile=... is silently dropped and the stock recipe returned.
Fail-open in both cases — released servers 404 the /v2 paths, which is the fail-closed contract.
Selection (GET parameter or POST envelope, kept outside RecipeCriteria) exists only on /v2/recipe and /v2/query — /v2/bundle is POST transport + validation of an already-selected RecipeResult, carrying no selection.
New servers' /v1/recipe and /v1/query reject explicit profile input — a profile GET parameter or top-level POST field — regardless of composition (ErrCodeInvalidRequest; today it is silently dropped), and reject when the resolved composition declares a profile (an ordinary criteria request with no profile parameter in sight); /v1/bundle rejects profile-bearing artifacts.
Passing newer artifacts to released raw-RecipeResult consumers (AdoptRecipe, DefaultBundler.Make, mirror lister, or a released server's /v1/bundle) is a documented unsupported limitation.
Selection plumbing & discovery:
Selection surfaces — every surface carries the same field: CLI --profile, config file (already strict — fail-closed for free), pkg/client/v1 option, /v2 endpoints, criteria-based aicr query and aicr mirror list; snapshot-based generation accepts the same selection.
aicr query resolves from criteria and exposes the resolved metadata.selectedProfile through --selector. Direct overlay hydration (aicr bundle -r <overlay.yaml>) exposes no selection surface and applies the declared default, by design.
Wire contracts are explicit deliverables — only the v1 contract exists today at api/aicr/v1/server.yaml, and the OpenAPI contract update (v2 paths, envelope, profile parameter, selectedProfile response schema) is a line item of the same deliverable:
/v2/recipe and /v2/query: GET selection and POST envelope ({criteria: {...}, profile: "name=value"} — the profile field is a single CLI-form string, per the ADR's normative envelope); strict request parsing (unknown query parameters or envelope fields rejected — a ?profie= typo must not silently select the default); selectedProfile in responses.
/v2/bundle: transport-only — POST of an already-selected RecipeResult, validate its recorded selection, return the bundle archive (no selection field, no selectedProfile response).
A compact catalog projection on CatalogEntry (profile name, description, default, value names — it carries no profile info today, pkg/client/v1/types.go) exposing the effective declaration after inheritance/co-match resolution: reuse the pre-filter declaration-resolution helper — ListCatalog projects raw overlays with equality filtering, so a criteria-filtered listing excludes the declaring ancestor and a naive local-block copy would show inheriting leaves as unprofiled.
Docs: docs/user/cli-reference.md, docs/user/api-reference.md, docs/user/air-gap-mirror.md (migrate --set guidance when AKS adopts), docs/integrator/recipe-development.md, docs/contributor/recipe.md (naming vocabulary — to be written); on ADR acceptance, add the reciprocal "Amended by ADR-015" banner to docs/design/011-artifact-apiversion-policy.md.
The ADR lists five deferred decisions with proposed defaults — none changes the mechanism or gates the ADR's acceptance; each is routed here or to the consumer work that resolves it. The outcomes below are the ADR's proposals, not final decisions. Record the final dispositions here when the ADR is accepted.
Item 1 lands in this implementation once confirmed: constraint-diagnostics distinction (proposed: distinguishable "reading unavailable" diagnostic).
Item 4 — the general catalog-compatibility contract follow-up is filed as Define a general catalog-compatibility contract for external --data catalogs (ADR-015 DD4) #1812 (it can land alongside this issue's first stage); the contract itself stays out of this issue's scope. Candidate mechanism to evaluate on that follow-up (also recorded on Define a general catalog-compatibility contract for external --data catalogs (ADR-015 DD4) #1812): stamping profile-declaring overlays with a new kind — old binaries skip unknown kinds and inheritance-chain resolution then fails loudly (recipe %q not found (referenced in inheritance chain)), turning the rollback scenario from silent-unspecialized into a hard error with zero changes to released binaries. Open questions: whether criteria matching could resolve a sibling composition before the chain error, and the dual-kind acceptance cost in new binaries.
Consumers (separate follow-ups, gated on this mechanism)
GKE gpuStack: [operator (default), csp-managed, operator-selfdriver] — first consumer; gated on validate: fail-closed check for GKE device-plugin ownership conflict #1755 (node-set label constraint form, a new reading/evaluator capability); the upstream GKE citations (opt-out label semantics, managed-plugin behavior, any dcgm GPU-ID mapping) are recorded in that consumer PR per the ADR's adoption step 2. Staged declaration: the initial conversion declares only operator + csp-managed, with only the gpu-operator componentRef (initial ownedPaths: gpu-operator: [devicePlugin.enabled, enabled]). The operator-selfdriver value additionally requires the new values-gated gcp-driver-installer public registry component (ordering edge to gpu-operator declared in overlay componentRefs, not the fragment; chart authored so install: false renders zero resources; installer internally idempotent — skips when a healthy driver is loaded — as defense-in-depth only, selection stays explicit) and is declared only once its durable ownership-mode distinguishing signal is identified (ADR Deferred Decision 5) — the other two values do not wait on it. The gcp-driver-installer registry entry, its fragment refs in all values (install: false in the siblings), and the third value land together at that DD5 landing event — an ownership-surface expansion and a family-wide re-qualification. Post-DD5, operator asserts the durable marker's absence so it stays distinguishable from operator-selfdriver (symmetric constraints, never one-sided).
AKS gpuStack: [operator (default), driver-only] — gated on identifying/documenting the authoritative node-pool-mode validation signal (ADR Deferred Decision 3). When that signal is identified it lands as symmetric constraints on both values — operator asserts the opposite of driver-only — never one-sided (one-sided constraints break the distinguishing-signal guarantee). Migrates documented --set guidance including air-gap mirror.
Converting an existing family is three events at once:
A qualification event: regenerated + re-signed evidence for each declared value and a documented cut-over (see ADR Consequences). Reversion is the mirror image: legacy apiVersion returns, digests change again; restoring pre-conversion digests is not guaranteed — an exact byte-level restoration does restore them, and the prior evidence then attests identical bytes; otherwise evidence is regenerated.
A client-migration event: from the first profiled generation, new servers reject the family on /v1/recipe//v1/query (and its new artifacts on /v1/bundle) even for plain criteria requests, so every /v1 REST consumer of the family must move to /v2 at cut-over.
A rollout requirement (a mixed old/new fleet is inherently nondeterministic for these requests): homogeneous server + catalog versions behind an endpoint, an atomic/blue-green traffic switch, or version-segregated routing — old or unconverted instances must not share the endpoint during cut-over. Each cut-over documents which of the three strategies it uses.
Acceptance criteria
Table tests exercise every invalid advertiser tuple through all five recipe paths — generated, disk-loaded, /v2/bundle-adopted, direct-bundler (DefaultBundler.Make), and mirror lister — and all existing DRA allocation-policy gates continue to pass unchanged. Tuple coherence catches a conflicting advertiser toggle that lives only in a component values file (not in the artifact) — this requires the hydrating PrepareAndValidateWithContext.
A GKE-shaped external-advertiser happy path (invalid-tuple tests alone would pass an implementation that rejects every advertiser: external): default operator and explicit csp-managed selections both resolve; csp-managed disables the operator plugin with DRA disabled and the coherence gate accepts the tuple; policy resolution returns the existing device-plugin-extended-resource; profile constraints persist in the hydrated artifact and are re-evaluated by aicr validate; bundle and mirror succeed without overrides while locked overrides fail.
Pre-filter uniqueness and declaration survival: duplicate-declaration detection yields the same outcome for the same criteria with and without a snapshot; a snapshot that excludes every declaring chain fails generation with the excluding constraints' diagnostics (no unprofiled base fallback); dedup-by-source covered by a test reaching the same declaring overlay through two chains, and a structurally-identical-but-independent duplicate still fails.
argocd-helm install-time guard: rendering a profiled argocd-helm bundle fails when an install-time value is present at a locked path — independent of --skip-schema-validation (no lock rides the schema); presence detection is by key existence, not Helm truthiness, with explicit cases proving falsy values still fail (--set <component>.devicePlugin.enabled=false, an empty list/map at a locked path) and structural-intersection cases: an ancestor supplied as a non-map replacement (--set-json <component>.devicePlugin=false) and a descendant of a locked path both fail; unrelated install-time overrides still render; non-profiled argocd-helm bundles are byte-identical to today.
Catalog-load version matrix (RecipeMetadata): legacy/empty-apiVersion non-profile overlays accepted; legacy/empty-apiVersion profile-declaring overlays rejected; new-version profile-declaring overlays accepted; new-version overlays without a declaration rejected; a spec.profie typo on a new-version overlay is an unknown-key error (strict decode), not a silently unprofiled catalog; a fragment override path with a literal dotted map key is rejected.
Closure trigger, both directions: advertiser: external triggers the recomputed closure; explicit ownership of a non-synthetic policy-selector path triggers it; the synthetic enabled presence path alone does not; an AKS-shaped driver/toolkit-only profile retains today's static-override WARN on allocation-policy keys — locks follow ownership, and an implementation that applies the closure to every profile must fail these negative cases.
Non-REST selection surfaces, end-to-end: CLI --profile reaches the shared resolution helper on recipe generation, snapshot-filtered generation, criteria-based aicr query, and criteria-based aicr mirror list (a flag accepted but dropped before resolution silently selects the default and produces a wrong mirror inventory — the failure mode to pin); config-file selection works with CLI-over-config precedence; the pkg/client/v1 request option selects equivalently.
Generation-side mutator subordination: on a snapshot generation of a profile-bearing AKS composition with a preinstalled driver, the driver auto-override does not write driver.enabled (locked) and logs the skip; the same snapshot against a non-profile composition keeps today's auto-override behavior.
Constraint collision: a profile value constraint colliding by name with a chain/mixin constraint fails resolution; two values of one declaration sharing a constraint name resolve fine individually.
Recipe-side validity (blocked locked path) — two cases: generation: a composition whose closure-locked path sits under a scalar baseline ancestor (e.g. advertiser: external with a scalar at the DRA component's resources) fails at resolution — a catalog-valid selected value cannot leave a union-locked path blocked (totality plus map-over-scalar merge replacement heals every union path it assigns); raw artifact: feeding a malformed/forged profile-bearing artifact whose recorded ownedPaths disagrees with its overrides (a recipe-side union-owned path under a scalar ancestor) to any raw-artifact boundary (file load, adopt, DefaultBundler.Make, mirror) fails at the hydration gate — blocked never appears recipe-side in a valid artifact.
Values-gated conditional installation (operator-selfdriver pattern — post-DD5; the initial GKE conversion declares only operator + csp-managed with the gpu-operator componentRef alone): once the DD5 landing event adds the installer, gcp-driver-installer.install is an owned, locked path across all three GKE values, and every value assigns it explicitly (install: false in operator/csp-managed — union totality; relying on the baseline default is a catalog-load error); the component renders nothing when install: false; a subset omitting gcp-driver-installer on a profiled GKE recipe fails for any selection (it contributes lock paths declaration-wide); the component set is identical across all three values; a snapshot excluding the installer fails generation even for an operator selection (declaration-wide membership).
Existing v1 criteria documents (files and /v1 POST bodies) remain accepted unchanged; snapshot and AICRConfig version behavior unchanged.
GET/POST selection equivalence on /v2/recipe and /v2/query, plus the complete v1/v2 accept/reject matrix (including unknown-parameter strictness: a ?profie= typo and an unknown envelope field both reject; and explicit profile input on /v1 — GET parameter or top-level POST field — rejects regardless of composition), covered by handler tests; aicr recipe list shows the catalog profile projection including inherited declarations under criteria filtering (e.g. an --accelerator h100 listing shows the leaves' effective gpuStack declaration inherited from gke-cos).
A recipe without a profile declaration behaves byte-identically to today (digest included, legacy apiVersion retained); exactly six non-profile behavior changes, all fail-closed tightenings on inputs invalid under the ADR: mirror.Lister.Discover gaining the shared context-aware validation gate (no rejection for well-formed recipes), raw-artifact boundaries rejecting unknown-apiVersion artifacts that today pass unchecked, legacy/empty-version artifacts carrying selectedProfile newly rejected (today's non-strict decode silently drops the field), legacy/empty-version profile-declaring overlays newly rejected at catalog load (today the kind-only loader silently yields an unprofiled catalog), new-version overlays without a declaration newly rejected at catalog load (today accepted kind-only), and /v1 requests carrying explicit profile input newly rejected (today the parameter is silently dropped); the coherence hydration performs zero I/O for artifacts without selectedProfile (assert no provider/values-file access on legacy load/adopt/bundle paths).
Fail-closed on:
duplicate declarations in a composition (pre-filter);
a declared value violating union totality (catalog load);
snapshot filtering removing every declaring chain, or disabling a component referenced by any declared value (declaration-wide membership);
unknown profile/value; selection against a declaration-less composition; missing/invalid default;
a fragment using a disallowed ComponentRef field; a nested empty-map assignment in a fragment override; a profile or value name violating the identifier grammar (=, whitespace, empty);
a misspelled key inside spec.profile (strict decode — e.g. constrains: is a catalog-load error, not a silently constraint-less profile);
pre-output invariant violation — state divergence at a locked path/presence from any source (override, filter, injection, bundler-derived defaulting such as agentgateway allowedSourceRanges when the owned path is unset; covers registry drift), or a --dynamic export of a locked path (mutability condition, even with the value unchanged) — while a redundant write landing exactly on the qualified value passes (outcome semantics);
incoherent advertiser tuple; a profile-bearing artifact whose effective configuration is blocked at any locked path (generation and hydration gate);
legacy/empty-apiVersion artifact carrying selectedProfile (and new-version without it);
a fragment override setting the root enabled key; a bundlers subset omitting a component contributing to the effective lock set (the invariant cannot evaluate absent locked paths);
install-time input structurally intersecting a locked path (argocd-helm);
a /v1 request that explicitly supplies profile input or whose resolved composition declares a profile.
Subset semantics: a bundlers/WithBundlers subset omitting an unrelated component passes on a profiled recipe (POST /v1/bundle: documented 'bundlers' query param is silently ignored #1531 satisfied-externally semantics preserved); a subset omitting a component in the effective lock set fails — cover both a declaration-owned component (gpu-operator) and a closure-locked one (a GKE subset omitting nvidia-dra-driver-gpu); an explicit enabled-state divergence fails; restating the recipe's own state passes when the restatement serializes identically to the canonical value (--set coercions; float64(3) vs int64(3) both serialize 3 and pass); a numerically equal restatement whose bytes differ (--set-json1000000 decoding to float64 → 1e+06) fails closed; --set-json <c>:operator.runtimeClass=null (leaf-scalar null, persists) against a recipe leaving that locked path absent fails closed (it strips the chart's runtimeClass default — the AKS driver-only union-lock case), while the map-shaped spelling --set-json <c>:operator='{"runtimeClass":null}' (delete semantics, effective absence) passes, and the ancestor-replacement spellings --set-json <c>:operator=null / <c>:operator='"broken"' (non-map ancestor → leaf blocked) fail closed at every locked path beneath — all outcomes from the three-valued effective-candidate identity, no null-token rule.
ownedPaths ordering is lexicographic and byte-stable across regeneration (digest determinism).
Delivery
This issue is the umbrella; deliver via staged child PRs: (1) foundation/version gate, (2) resolution + recording, (3) override enforcement (bundle + mirror + argocd-helm install-time guard), (4) public API/CLI/discovery/docs. No profile consumer ships until all four land.
Summary
Implement the mechanism specified in ADR-015 (
docs/design/015-recipe-configuration-profiles.md, PR #1762): a single enumerated configuration profile per resolved recipe, selected at recipe generation, persisted in the hydrated artifact as a compact ownership record, gated behind a new recipe artifact apiVersion, and enforced at every recipe boundary.Scope (per the final ADR — read it first; it is authoritative)
pkg/recipe):spec.profiledeclaration (name,description, mandatorydefaultnaming a declared value — catalog-validated,values).advertiser,constraints, andcomponentRefsonly; each ref permits onlynameandoverrides(every otherComponentReffield is rejected at catalog load). The admission criterion: a fragment field is permitted only when its effect is either representable inownedPaths(value paths plus the syntheticenabled) or persisted verbatim in the artifact with no bundle-time override surface (constraintsjoin the artifact's constraint set;advertiseris recorded inselectedProfile) — an effect the artifact cannot carry is an effect the lock cannot protect. Every otherComponentReffield fails both tests:valuesFile— additionally rejected for a merge-semantics reason: a latervaluesFilereplaces the earlier one rather than merging, so a profile could change effective values by removing a file — through paths that appear nowhere in the fragment and that no syntactic union could lock. Deferred design sketch, should a consumer ever require it: derive owned paths from each value's semantic before/after delta rather than from fragment syntax.namespace;patches; health-check / cleanup fields.manifestFiles/preManifestFiles(raw-manifest injection) — sharesvaluesFile's unrepresentable-effect hazard.ownedPathsis declaration-wide and a selection must never record lock paths for a component absent from its composition (a snapshot exclusion disabling any referenced component fails generation).enabledpath is part ofownedPathsbut exempt from totality (fragments may not assign rootenabled) — owned paths are never inherited from the baseline (supersession applies only the selected fragment, so an omitted owned path would let a pre-profile assignment survive the selection and be locked-and-attested as if qualified). A fragment override must not set the rootenabledkey (catalog-load rejection —overrides.enabled: falseis component removal through the value map). Component addition/removal and ordering semantics are a future ADR amendment, not v1 capability. Overrides inherit ordinary recipe merge semantics including whole-list replacement.RecipeMetadatacarrying the new apiVersion is decoded strictly in full, and new-version metadata withoutspec.profileis rejected. Strict decoding is wired at the catalog/metadata-store loading layer (not a globalserializer.Readermode change) and keys solely on the new apiVersion. A typo of the profile key itself (spec.profie) cannot silently unprofile a catalog; misspelled keys inside the declaration are catalog-load errors. (Mechanism rationale: overlay metadata is decoded non-strictly today, so unknown YAML keys are silently dropped before any validation runs — which is why strictness must key on the artifact apiVersion, not on finding a profile; today the same typos yield, respectively, a silently unprofiled catalog (spec.profie) and a silently effect-less or constraint-less profile (a misspelledconstraintsoroverrideskey inside the declaration).)ownedPathsrecord cannot disambiguate them, so the lock/guard could miss escaped writes; escaped representation or parent-map promotion is a future amendment). The dotted-key restriction stays scoped to profile fragments. The concrete case is an annotation-style key such asexample.com/owner: the flattened record cannot distinguish one literal dotted key from nested segments, so a naïvely segmented lock or install-time guard could miss an escaped write to the literal key; the amendment paths, precisely, are an escaped path representation or promotion of ownership to the nearest unambiguous parent map.feature: {}) are rejected at catalog load (no owned path is recorded, yet merge replaces a prior scalar/list — an unlockable effect: an effective, persisted change the ownership record cannot represent and the lock therefore cannot protect; the future amendment is promoting the empty map's containing path intoownedPaths).[A-Za-z0-9._-], no=/whitespace — keeps thename=valuewire form unambiguous). Names are overlay-scoped identifiers, not reserved keywords — validity is membership in the declaring overlay's enum; the grammar exists because the singlename=valuewire form is carried verbatim by the CLI flag, the GET parameter, and the POST envelope, and must stay unambiguous across all three. The one piece of reserved vocabulary isadvertiser(only valueexternal), rejected at every boundary when unknown.mergeMixins; values of one declaration may reuse names across values), fail closed at generation, and are merged into the artifact's constraint set foraicr validatere-evaluation.metadata.selectedProfile{name, value, advertiser?: external, ownedPaths}.ownedPaths= union of profile-owned paths across all declared values, derived by leaf flattening (every non-map assignment contributes its dotted path; map values recurse), ordered pure-lexicographic per component (digest-included, byte-stable — do NOT reusepkg/component/overrides.gosortedOverridePaths, which orders dot-depth-first (shallowest-first) and would yield different bytes). Component presence is the syntheticenabledpath (no separate lockedComponents).RecipeResultartifacts — recomputed at every artifact boundary from the canonical descriptor. Rendered outputs are the exception: any pre-expansion-legal write (static override, injection, derived mutation, subset, or--dynamicexport persisted operator-editable — Helm/Helmfilecluster-values.yaml, Flux values ConfigMap, argocd-helm root values/guard) can be baked into output that diverges or stays mutable at a newly locked path.RecipeResult(a global revision would invalidate unaffected families; ADR-007 pointers are add-only, so re-signing alone cannot retire pre-expansion evidence).PredicateTypeV1(pkg/evidence/verifier/pointer.go:85,verify.go:246) and decode predicates non-strictly, so an additive identity field would be silently ignored (fail-open) — a new type fails closed on released verifiers for free. New verifiers reject v1-predicate evidence for a profile-bearing recipe (accepting both would let a new profiled artifact pair with freshly signed v1 evidence, bypassing descriptor identity); unprofiled evidence keeps the v1 predicate. Add the "Amended by ADR-015" banner to ADR-007 on acceptance (alongside the ADR-011 banner).tools/testgrid-publish/bundle.goloadPredicate(line ~143) rejects every non-empty predicate type exceptattestation.PredicateTypeV1(an empty predicateType is accepted) — since profile-bearing evidence rides the new predicate type, the tool must accept the new type or profiled evidence can never publish. The profile value must join a path-forming key segment in thepkg/evidence/projectcorroboration tree: it keys results by recipe coordinate + signer + runID (synthesize.gorunDir,os.RemoveAll+rename replace semantics, and CI ingest passes oneFIRST_PARTY_RUN_IDper workflow run) — all identical across two values of one family — so metadata- or display-label-only placement leaves one value's results silently overwriting another's, andMetatoday carries no profile field; whether the segment lands in the coordinate or the run key is the residual product decision.tools/testgrid-publishneeds no path change — its build ID is digest-bound (buildid.go) and profile selection changes the bundle digest, so per-value results already land at distinct GCS paths; any profile name/value display metadata is a product decision. The evidence gate stays digest-authoritative either way..github/scripts/recipe-evidence-check.sh(~line 446) computes one selection-lessaicr evidence digest -r <overlay>per slug — under this ADR that hydrates the declared default, so committed evidence for any non-default value would permanently report as digest-drifted. The gate must recompute each pointer's expected digest from that pointer's recorded selection (the new predicate type carries the profile identity); precomputing digests for every declared value is permitted only as a selection → digest map — each pointer compares against the digest for its own recorded selection, never against "matches any current value".pkg/evidence/attestation/predicate.go:117,166; publisher gatepkg/evidence/attestation/publish.go:236; pointer producerpkg/evidence/attestation/pointer.go:61; signature verifierpkg/evidence/verifier/signature.go:248; pointer verifierpkg/evidence/verifier/pointer.go:85; envelope verifierpkg/evidence/verifier/verify.go:246; TestGridtools/testgrid-publish/bundle.go:143(above). The umbrella deliverable covers these; the inventory exists to reduce omission risk.RecipeResult— files AND/v2/bundlePOST bodies: unknown fields, duplicate/trailing JSON documents, and a malformedselectedProfilesubtree fail closed (anownedPathtypo silently dropped by non-strict JSON would strip every lock from an AKS-shaped profile while the version cross-check still passes; "machine-generated" is not a trust boundary on a public HTTP endpoint — which is why strictness applies to/v2/bundlePOST bodies exactly as to files, not only to hand-edited artifacts). Decode strictness cannot protect typed Go callers, so the shared gate also structurally validates theselectedProfilesubtree (required fields present and well-formed).pkg/evidence/project/synthesize.goreadRecipeView(a non-strict two-field projection of the bundlerecipe.yamlwith no version gate; callertools/evidence-project/main.go) andtools/testgrid-publish/bundle.goparseCriteria/recipeFile(same shape). Both must header-gate and, for v1alpha3, decode via the shared strict decoder before projecting.pkg/validator/v1): optionaladvertiser: externalmarker (only value; operator ownership read from effective values, never declared/inferred); external advertiser counted in the exactly-one invariant with both dual-advertisement gates extended fail-closed; resolved policy value unchanged (device-plugin-extended-resource); closure trigger =advertiser: externalor explicit ownership of a non-synthetic policy-selector path (the syntheticenabledpresence path never triggers it; locks follow ownership — profiles that do not own advertisement leave allocation-policy keys on today's WARN semantics).pkg/recipeor a dedicated policy package — recipe finalization cannot importpkg/bundler, which already imports recipe).gpuAllocationPolicyPaths, private topkg/bundler, and an independent copy inpkg/validator/v1/allocation_policy.go. The import cycle is why finalization cannot reuse the bundler's map, hence the dependency-neutral home.PrepareAndValidate, bundler enforcement, and validation-time policy resolution — so generated, disk-loaded, POSTed, and direct-bundler recipes all fail on an incoherent tuple before any output is written.selectedProfilecross-check is added to the existingRecipeResult.PrepareAndValidate(pkg/recipe/metadata.go:533; already called by the loader,adoptRecipe, andDefaultBundler.Makeatbundler.go:237) so file loading, HTTP adoption, and direct bundler callers share one implementation.PrepareAndValidateWithContext(ctx)) that hydrates the effective values of the advertiser components and of every component contributing to the effective lock set, through the recipe's bound provider — today the method never hydrates values, and the conflicting toggle typically lives in a component values file, not the artifact.selectedProfile(skipped entirely for non-profile artifacts — no new I/O or failure modes on legacy paths). The hydrating gate also rejects a profile-bearing artifact whose effective configuration is blocked at any locked path (incoherent ownership record — see the identity relation under Override locking).adoptRecipe,DefaultBundler.Make,mirror.Lister.Discover) invoke the context-aware form; the ctx-lessPrepareAndValidateremains the non-hydrating shape + version/profile gate. The mirror path must additionally be wired to call it (mirror.Lister.Discoverdoes not today) before the shared lock validator.pkg/bundler+pkg/mirror+pkg/bundler/deployer/argocdhelm):ownedPaths+ recomputed closure) once per invocation; before any output is rendered or written (bundle and mirror): (1) state — the effective candidate configuration must equal the hydrated selected recipe at every locked path and locked component-presence state; (2) mutability — no supported output surface may expose a locked path or presence state as an install-time parameter (--dynamicexport of a locked path fails even with the bundle-time value unchanged).bundlers/WithBundlersfilter, registry-driven injections (system/accelerated node selector + toleration paths, workload selector, workload-gate taint, node count, storage class), bundler-derived defaulting (DRA chart-version pod-annotation, agentgatewayallowedSourceRanges), registry drift, and future mutation sources.serializer.MarshalYAMLDeterministic(the digest serializer) per present value. Identity requires the same observation on both sides: an ancestor replacement like--set-json <c>:operator=nullor a scalar diverges at every locked path beneath it, even though a naive leaf lookup reports "absent" for both sides.nullare distinct when the null persists: a leaf-scalarnullis stored and diverges (it strips chart defaults); a map-shapednulldeletes the key, leaving recipe-identical absence, and passes — no writer-level null-token rule (the samenulltoken both passes and fails depending on where merge semantics leave it; only the post-merge effective candidate can be judged, and any token-level null rule would get one of the two outcomes wrong). Coercions serializing identically pass; numerically equal values diverge exactly when their bytes differ (float64(1000000)→1e+06vs1000000) — byte-level, never mathematical; numeric canonicalization is deliberately not attempted (equality is byte identity of the per-value canonical bytes from the digest serializer — deterministic, never mathematical).applyGPUDriverAutoOverride, only-falsedriver.enabledinjection, defined inpkg/client/v1/gpu_driver_state.go, called fromaicr.go) skips paths in the effective lock set and logs the skip (a generation-time write to an owned path would bake divergence into the artifact itself, unreachable by the pre-output invariant). Non-profile compositions keep today's behavior.enabledpseudo-path semantics — reject explicitenabled-state divergence, and reject abundlers/WithBundlerssubset omitting any component contributing a path to the effective lock set —ownedPathsor the recomputed closure (the invariant cannot evaluate locked paths absent from output — sufficient alone; the attestation-incoherence rationale applies to declaration-named components only, since the closure is never persisted inselectedProfile; on GKE the closure locksnvidia-dra-driver-gpu, inherited from base by every recipe). Subsets omitting unrelated components keep POST /v1/bundle: documented 'bundlers' query param is silently ignored #1531 satisfied-externally semantics; re-enabling a recipe-disabled component stays rejected.mirror.Lister.Discoverruns the shared gate (PrepareAndValidateWithContext) first against a defensive copy (discovery must not mutate its input;Discoveroutput unchanged for well-formed recipes), then the same shared lock validator (CLI and direct callers).mustMergeOverwrite, so a parent-shaped--set-jsonreplaces the subtree without the leaf existing), or a descendant — mirroring the bundler's exact/parent/child matching.helm installtime —--dynamiconly pre-populates root values, and no declaration is needed to override (pkg/bundler/deployer/argocdhelm). An installer could therefore change an owned path without touching the attested bundle, and rejecting--dynamicon owned paths is moot for this deployer. No final candidate values exist at install time to compare, so the guard is the install-time equivalent of the invariant's mutability condition.--dynamic, so they are absent from root values by construction; the guard rejects structural presence, never compares values. It is derived from the alias-expanded effective lock set keyed by each component's canonical root override key, and its failure message names the locked path.values.schema.jsonlock constraints (schema validation is skippable via--skip-schema-validationwhile templates always render; the existingdeployer.*schema is unchanged); the guard is the enforcement — encoding the lock a second time into the schema would duplicate the matcher semantics for zero enforcement weight.aicr.run/v1alpha3forRecipeMetadataandRecipeResultonly (snapshots/configs stay onaicr.run/v1alpha2).RecipeCriteriastays on the legacy version: todayRecipeCriteriaAPIVersionaliasesRecipeAPIVersionand criteria readers require exact equality atcriteria.go:935, so a naive alias bump would reject existing criteria files and/v1POST bodies. The version bump must therefore de-alias the constant — pinRecipeCriteriaAPIVersionto the legacy version independently ofRecipeAPIVersion. Profile selection stays outsideRecipeCriteriavia the v2 envelope.selectedProfile; new version without it). Explicit kind-scoped amendment to ADR-011 (reciprocal banner on acceptance).kindonly, neverapiVersion,pkg/recipe/metadata_store.go). Profile-declaring--datacatalogs are declared incompatible with older binaries (including rollback). The cross-check is bidirectional (a declaring overlay must carry the new version; a new-version overlay must declare); only non-declaring overlays with a legacy, empty, or unknown apiVersion keep today's kind-only loading, pending the Deferred Decision 4 follow-up.IsSupportedAPIVersiongate (pkg/recipe/loader.goand the snapshot loader are that gate's only production callers), so the file boundary fails closed for free. The REST, SDK, and catalog boundaries cannot be retrofitted and get the individual treatments above (versioned endpoints, documented SDK limitation, catalog-load version gate)./v2/recipe,/v2/query,/v2/bundle; released servers 404. The released/v1handler mechanics that force this:/v1/bundle:pkg/server/bundle_handler.godecodes the POST body directly into aRecipeResultand adopts it viaadoptRecipe→PrepareAndValidatewith no apiVersion or kind check — a released server accepts a new-version profiled result, silently dropsselectedProfile(non-strict decode), and permits every override the profile forbids./v1/recipe(and/v1/query, which resolves through the same client path):ParseCriteriaFromRequestparses only the query parameters it knows, so?profile=...is silently dropped and the stock recipe returned./v2paths, which is the fail-closed contract.RecipeCriteria) exists only on/v2/recipeand/v2/query—/v2/bundleis POST transport + validation of an already-selectedRecipeResult, carrying no selection./v1/recipeand/v1/queryreject explicit profile input — aprofileGET parameter or top-level POST field — regardless of composition (ErrCodeInvalidRequest; today it is silently dropped), and reject when the resolved composition declares a profile (an ordinary criteria request with noprofileparameter in sight);/v1/bundlerejects profile-bearing artifacts.RecipeResultconsumers (AdoptRecipe,DefaultBundler.Make, mirror lister, or a released server's/v1/bundle) is a documented unsupported limitation.--profile, config file (already strict — fail-closed for free),pkg/client/v1option,/v2endpoints, criteria-basedaicr queryandaicr mirror list; snapshot-based generation accepts the same selection.aicr queryresolves from criteria and exposes the resolvedmetadata.selectedProfilethrough--selector. Direct overlay hydration (aicr bundle -r <overlay.yaml>) exposes no selection surface and applies the declared default, by design.api/aicr/v1/server.yaml, and the OpenAPI contract update (v2 paths, envelope,profileparameter,selectedProfileresponse schema) is a line item of the same deliverable:/v2/recipeand/v2/query: GET selection and POST envelope ({criteria: {...}, profile: "name=value"}— theprofilefield is a single CLI-form string, per the ADR's normative envelope); strict request parsing (unknown query parameters or envelope fields rejected — a?profie=typo must not silently select the default);selectedProfilein responses./v2/bundle: transport-only — POST of an already-selectedRecipeResult, validate its recorded selection, return the bundle archive (no selection field, noselectedProfileresponse).CatalogEntry(profile name, description, default, value names — it carries no profile info today,pkg/client/v1/types.go) exposing the effective declaration after inheritance/co-match resolution: reuse the pre-filter declaration-resolution helper —ListCatalogprojects raw overlays with equality filtering, so a criteria-filtered listing excludes the declaring ancestor and a naive local-block copy would show inheriting leaves as unprofiled.docs/user/cli-reference.md,docs/user/api-reference.md,docs/user/air-gap-mirror.md(migrate--setguidance when AKS adopts),docs/integrator/recipe-development.md,docs/contributor/recipe.md(naming vocabulary — to be written); on ADR acceptance, add the reciprocal "Amended by ADR-015" banner todocs/design/011-artifact-apiversion-policy.md.Deferred decisions (ADR "Deferred Adoption Decisions" section)
The ADR lists five deferred decisions with proposed defaults — none changes the mechanism or gates the ADR's acceptance; each is routed here or to the consumer work that resolves it. The outcomes below are the ADR's proposals, not final decisions. Record the final dispositions here when the ADR is accepted.
operator-selfdriverdurable ownership-mode distinguishing signal) gate the consumers, not this mechanism.kind— old binaries skip unknown kinds and inheritance-chain resolution then fails loudly (recipe %q not found (referenced in inheritance chain)), turning the rollback scenario from silent-unspecialized into a hard error with zero changes to released binaries. Open questions: whether criteria matching could resolve a sibling composition before the chain error, and the dual-kind acceptance cost in new binaries.Consumers (separate follow-ups, gated on this mechanism)
gpuStack: [operator (default), csp-managed, operator-selfdriver]— first consumer; gated on validate: fail-closed check for GKE device-plugin ownership conflict #1755 (node-set label constraint form, a new reading/evaluator capability); the upstream GKE citations (opt-out label semantics, managed-plugin behavior, any dcgm GPU-ID mapping) are recorded in that consumer PR per the ADR's adoption step 2. Staged declaration: the initial conversion declares onlyoperator+csp-managed, with only thegpu-operatorcomponentRef (initialownedPaths:gpu-operator: [devicePlugin.enabled, enabled]). Theoperator-selfdrivervalue additionally requires the new values-gatedgcp-driver-installerpublic registry component (ordering edge to gpu-operator declared in overlaycomponentRefs, not the fragment; chart authored soinstall: falserenders zero resources; installer internally idempotent — skips when a healthy driver is loaded — as defense-in-depth only, selection stays explicit) and is declared only once its durable ownership-mode distinguishing signal is identified (ADR Deferred Decision 5) — the other two values do not wait on it. Thegcp-driver-installerregistry entry, its fragment refs in all values (install: falsein the siblings), and the third value land together at that DD5 landing event — an ownership-surface expansion and a family-wide re-qualification. Post-DD5,operatorasserts the durable marker's absence so it stays distinguishable fromoperator-selfdriver(symmetric constraints, never one-sided).gpuStack: [operator (default), driver-only]— gated on identifying/documenting the authoritative node-pool-mode validation signal (ADR Deferred Decision 3). When that signal is identified it lands as symmetric constraints on both values —operatorasserts the opposite ofdriver-only— never one-sided (one-sided constraints break the distinguishing-signal guarantee). Migrates documented--setguidance including air-gap mirror./v1/recipe//v1/query(and its new artifacts on/v1/bundle) even for plain criteria requests, so every/v1REST consumer of the family must move to/v2at cut-over.Acceptance criteria
/v2/bundle-adopted, direct-bundler (DefaultBundler.Make), and mirror lister — and all existing DRA allocation-policy gates continue to pass unchanged. Tuple coherence catches a conflicting advertiser toggle that lives only in a component values file (not in the artifact) — this requires the hydratingPrepareAndValidateWithContext.advertiser: external): defaultoperatorand explicitcsp-managedselections both resolve;csp-manageddisables the operator plugin with DRA disabled and the coherence gate accepts the tuple; policy resolution returns the existingdevice-plugin-extended-resource; profile constraints persist in the hydrated artifact and are re-evaluated byaicr validate; bundle and mirror succeed without overrides while locked overrides fail.--skip-schema-validation(no lock rides the schema); presence detection is by key existence, not Helm truthiness, with explicit cases proving falsy values still fail (--set <component>.devicePlugin.enabled=false, an empty list/map at a locked path) and structural-intersection cases: an ancestor supplied as a non-map replacement (--set-json <component>.devicePlugin=false) and a descendant of a locked path both fail; unrelated install-time overrides still render; non-profiled argocd-helm bundles are byte-identical to today.RecipeMetadata): legacy/empty-apiVersion non-profile overlays accepted; legacy/empty-apiVersion profile-declaring overlays rejected; new-version profile-declaring overlays accepted; new-version overlays without a declaration rejected; aspec.profietypo on a new-version overlay is an unknown-key error (strict decode), not a silently unprofiled catalog; a fragment override path with a literal dotted map key is rejected.advertiser: externaltriggers the recomputed closure; explicit ownership of a non-synthetic policy-selector path triggers it; the syntheticenabledpresence path alone does not; an AKS-shaped driver/toolkit-only profile retains today's static-override WARN on allocation-policy keys — locks follow ownership, and an implementation that applies the closure to every profile must fail these negative cases.--profilereaches the shared resolution helper on recipe generation, snapshot-filtered generation, criteria-basedaicr query, and criteria-basedaicr mirror list(a flag accepted but dropped before resolution silently selects the default and produces a wrong mirror inventory — the failure mode to pin); config-file selection works with CLI-over-config precedence; thepkg/client/v1request option selects equivalently.driver.enabled(locked) and logs the skip; the same snapshot against a non-profile composition keeps today's auto-override behavior.advertiser: externalwith a scalar at the DRA component'sresources) fails at resolution — a catalog-valid selected value cannot leave a union-locked path blocked (totality plus map-over-scalar merge replacement heals every union path it assigns); raw artifact: feeding a malformed/forged profile-bearing artifact whose recordedownedPathsdisagrees with its overrides (a recipe-side union-owned path under a scalar ancestor) to any raw-artifact boundary (file load, adopt,DefaultBundler.Make, mirror) fails at the hydration gate — blocked never appears recipe-side in a valid artifact.operator-selfdriverpattern — post-DD5; the initial GKE conversion declares onlyoperator+csp-managedwith thegpu-operatorcomponentRef alone): once the DD5 landing event adds the installer,gcp-driver-installer.installis an owned, locked path across all three GKE values, and every value assigns it explicitly (install: falseinoperator/csp-managed— union totality; relying on the baseline default is a catalog-load error); the component renders nothing wheninstall: false; a subset omittinggcp-driver-installeron a profiled GKE recipe fails for any selection (it contributes lock paths declaration-wide); the component set is identical across all three values; a snapshot excluding the installer fails generation even for anoperatorselection (declaration-wide membership)./v1POST bodies) remain accepted unchanged; snapshot andAICRConfigversion behavior unchanged./v2/recipeand/v2/query, plus the complete v1/v2 accept/reject matrix (including unknown-parameter strictness: a?profie=typo and an unknown envelope field both reject; and explicit profile input on/v1— GET parameter or top-level POST field — rejects regardless of composition), covered by handler tests;aicr recipe listshows the catalog profile projection including inherited declarations under criteria filtering (e.g. an--accelerator h100listing shows the leaves' effectivegpuStackdeclaration inherited fromgke-cos).mirror.Lister.Discovergaining the shared context-aware validation gate (no rejection for well-formed recipes), raw-artifact boundaries rejecting unknown-apiVersion artifacts that today pass unchecked, legacy/empty-version artifacts carryingselectedProfilenewly rejected (today's non-strict decode silently drops the field), legacy/empty-version profile-declaring overlays newly rejected at catalog load (today the kind-only loader silently yields an unprofiled catalog), new-version overlays without a declaration newly rejected at catalog load (today accepted kind-only), and/v1requests carrying explicit profile input newly rejected (today the parameter is silently dropped); the coherence hydration performs zero I/O for artifacts withoutselectedProfile(assert no provider/values-file access on legacy load/adopt/bundle paths).default;ComponentReffield; a nested empty-map assignment in a fragment override; a profile or value name violating the identifier grammar (=, whitespace, empty);spec.profile(strict decode — e.g.constrains:is a catalog-load error, not a silently constraint-less profile);allowedSourceRangeswhen the owned path is unset; covers registry drift), or a--dynamicexport of a locked path (mutability condition, even with the value unchanged) — while a redundant write landing exactly on the qualified value passes (outcome semantics);selectedProfile(and new-version without it);enabledkey; abundlerssubset omitting a component contributing to the effective lock set (the invariant cannot evaluate absent locked paths);/v1request that explicitly supplies profile input or whose resolved composition declares a profile.bundlers/WithBundlerssubset omitting an unrelated component passes on a profiled recipe (POST /v1/bundle: documented 'bundlers' query param is silently ignored #1531 satisfied-externally semantics preserved); a subset omitting a component in the effective lock set fails — cover both a declaration-owned component (gpu-operator) and a closure-locked one (a GKE subset omittingnvidia-dra-driver-gpu); an explicitenabled-state divergence fails; restating the recipe's own state passes when the restatement serializes identically to the canonical value (--setcoercions;float64(3)vsint64(3)both serialize3and pass); a numerically equal restatement whose bytes differ (--set-json1000000decoding tofloat64→1e+06) fails closed;--set-json <c>:operator.runtimeClass=null(leaf-scalar null, persists) against a recipe leaving that locked path absent fails closed (it strips the chart'sruntimeClassdefault — the AKSdriver-onlyunion-lock case), while the map-shaped spelling--set-json <c>:operator='{"runtimeClass":null}'(delete semantics, effective absence) passes, and the ancestor-replacement spellings--set-json <c>:operator=null/<c>:operator='"broken"'(non-map ancestor → leaf blocked) fail closed at every locked path beneath — all outcomes from the three-valued effective-candidate identity, no null-token rule.ownedPathsordering is lexicographic and byte-stable across regeneration (digest determinism).Delivery
This issue is the umbrella; deliver via staged child PRs: (1) foundation/version gate, (2) resolution + recording, (3) override enforcement (bundle + mirror + argocd-helm install-time guard), (4) public API/CLI/discovery/docs. No profile consumer ships until all four land.
References
docs/design/015-recipe-configuration-profiles.md(PR docs(design): ADR-015 recipe-declared configuration profiles #1762)