Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/contributor/recipe.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ the legacy version, is rejected. Profile-version metadata and recipe
artifacts are strictly decoded so an unknown field cannot silently disappear.

The core `ProfileValue` contract is closed to `advertiser`, `constraints`,
and `componentRefs{name,overrides}`. It rejects `valuesFile`, component
`readinessConstraints`, and `componentRefs{name,overrides}`. It rejects `valuesFile`, component
identity/deployment fields, root `overrides.enabled`, literal dotted keys,
and nested empty maps. The `advertiser` field accepts exactly one non-empty
value, `external` (validated against `pkg/allocpolicy`, the canonical
Expand Down Expand Up @@ -251,6 +251,12 @@ Resolution enforces these invariants:
collisions.
5. Evaluate selected profile constraints fail closed. A missing reading has a
distinct invalid-request diagnostic; other evaluator failures propagate.
A value's `readinessConstraints` are exempt from this step by design:
they name post-deployment properties (ADR-015 DD5) and route into
`spec.validation.readiness.constraints`, where the `aicr validate`
readiness pre-flight evaluates them fail closed. Names deduplicate
per phase — the same measurement path may carry a generation-time
pre-condition and a readiness-time post-deployment state.
6. Stamp the result `aicr.run/v1alpha3` and persist
`metadata.selectedProfile`. Its sorted `ownedPaths` is the
declaration-wide path union plus synthetic `enabled` for each referenced
Expand Down
59 changes: 52 additions & 7 deletions docs/design/015-recipe-configuration-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,9 @@ spec:
componentRefs:
- name: gcp-driver-installer
overrides:
install: false # every value assigns every union path
# every value assigns every union path; nested gate — see the
# amendment on operator-selfdriver below.
installer: {enabled: false}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: gpu-operator
overrides:
devicePlugin: {enabled: true}
Expand All @@ -262,7 +264,7 @@ spec:
componentRefs:
- name: gcp-driver-installer
overrides:
install: false
installer: {enabled: false}
- name: gpu-operator
overrides:
devicePlugin: {enabled: false}
Expand All @@ -277,7 +279,14 @@ spec:
componentRefs:
- name: gcp-driver-installer
overrides:
install: true # the chart-level gate
# Amended 2026-08-22: the gate is the nested installer.enabled,
# not the top-level `install` originally drawn here — top-level
# `install`/`enabled` are component-PRESENCE gates (IsEnabled),
# so a false default would make the component "not enabled in
# the surviving composition" and deadlock resolution for every
# value; root `overrides.enabled` is separately rejected in
# fragments. A nested key is an ordinary owned value path.
installer: {enabled: true}
Comment thread
yuanchen8911 marked this conversation as resolved.
- name: gpu-operator
overrides:
devicePlugin: {enabled: true}
Expand Down Expand Up @@ -511,7 +520,7 @@ to the surviving composition:
# digest, so ordering must be byte-stable
# Post-DD5 state shown; the initial recording is
# gpu-operator: [devicePlugin.enabled, enabled] only.
gcp-driver-installer: [enabled, install]
gcp-driver-installer: [enabled, installer.enabled]
gpu-operator: [devicePlugin.enabled, enabled]
```

Expand Down Expand Up @@ -1453,9 +1462,10 @@ recurrence — the shape the Problem section expects.
distinguishing signal is identified (Deferred Decision 5). The other
two values do not wait on it. The dormant component and the third value
land **together**, in one event: declaring the value later is an
ownership-surface expansion (`install` joins the union and the
installer's synthetic `enabled` joins `ownedPaths`), which is a
family-wide re-qualification and evidence re-signing event.
ownership-surface expansion (`installer.enabled` joins the union, so
every existing value gains an assignment for it — the sketch above
draws that end state), which is a family-wide re-qualification and
evidence re-signing event.

Any dcgm-exporter GPU-ID-mapping adjustment for `csp-managed` is an
external GKE behavior not verifiable from this repository. It is
Expand Down Expand Up @@ -1522,3 +1532,38 @@ work that resolves it.
absence, so the two values stay mutually distinguishable.
**Proposed: identify a durable signal during the value's adoption;
the `operator` and `csp-managed` values do not wait on it.**

*Amended 2026-08-24: mechanism only.* `ProfileValue` gains
`readinessConstraints` — same catalog-load validation as `constraints`
with per-phase name deduplication (the same measurement path may carry
a generation-time pre-condition and a readiness-time post-deployment
state), routed into `spec.validation.readiness.constraints` at
resolution and **never evaluated at generation time**. The
`aicr validate` readiness pre-flight evaluates them with the same
fail-closed exit as every other readiness gate.

The mechanism exists for values whose distinguishers are
deployment-created — where no generation-time reading can hold. Two
rules govern its use:

- **The self-falsifying pre-condition trap.** Generation-time
constraints are re-evaluated by the validate pre-flight, so a
pre-condition that the value's own success erases (e.g. "no NVIDIA
driver loaded" on a value whose operator installs the driver) must
never be declared as a generation constraint — it fails every
post-deployment validate on a correctly working cluster. Such state
belongs in `readinessConstraints`, asserted in its post-deployment
form.
- **Self-rendered readings do not qualify.** A reading the selected
bundle itself renders (e.g. deployed ClusterPolicy fields) is

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — PR-body OKE motivation contradicts the self-rendered-readings rule this PR adds

The PR-body Motivation names OKE's durable distinguishers as "the deployed ClusterPolicy readings (K8s.policy.driver.enabled, K8s.policy.devicePlugin.enabled)", but the ADR rule this same PR adds (this line) disqualifies exactly that: a reading the selected bundle itself renders ("e.g. deployed ClusterPolicy fields") is satisfied by construction under every value and cannot distinguish. Verified: K8s.policy.driver.enabled reads ClusterPolicy .spec (pkg/collector/k8s/policy.go:132), not .status — so it's purely self-rendered and can't fail-closed on a wrong-value selection (each value's own bundle renders the .spec it then reads back).

Blast radius: None on this PR (additive, unused, byte-identical goldens; the mechanism's valid leg — the self-falsifying-precondition trap, "no driver loaded" erased by success — justifies it independently, and the ADR text is internally consistent). It's a coherence gap in the justification and a trap for the follow-up OKE PR, which will hit this rule at catalog load.

Fix: In the PR body, repoint OKE's stated distinguisher at bundle-independent cluster state (a provisioning-set node label or provider property) — which the ADR carve-out and DD5.5 already prescribe. No ADR/code change needed.

satisfied by construction under every value — it is a useful
rendered-policy **drift check**, but it cannot serve as a value's
distinguishing constraint. Qualification requires cluster state
independent of the bundle's own output (provider properties, node
labels set at provisioning, externally-owned objects).

This PR resolves no GKE signal: the GKE family's DD5 question was
settled separately by value replacement (see the adoption-step
amendment), and its shipped values are generation-time
distinguishable. The mechanism's consumers are families whose values
are distinct cluster shapes with deployment-created distinguishers.
16 changes: 15 additions & 1 deletion docs/integrator/recipe-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,19 @@ identical to a sibling's — does not support the "validated against deployed
config" claim and must not be declared. The snippet above shows the declaration
shape only; it is not a declaration you should copy into an overlay.

**When the distinguishing signal only exists after deployment**, declare it
under the value's `readinessConstraints` instead of `constraints`. Both lists
get the same catalog-load validation (names deduplicate per list; the same
measurement path may appear in both, carrying a pre-condition at generation
and a post-deployment state at readiness), but
`readinessConstraints` are never evaluated at generation time — they route
into `spec.validation.readiness.constraints` and are evaluated fail-closed by
the `aicr validate` readiness pre-flight. Use this for properties the value's
own workload creates (e.g. a node label its DaemonSet applies after a
successful install — ADR-015 Deferred Decision 5), which by construction
cannot be present in the pre-deployment snapshot that generation-time
constraints are checked against.

**Constraint names must be measurement paths a supported snapshot producer
actually emits** — a collector, or a provider projection attached at the
snapshot orchestration layer (e.g. `K8s.aks-gpu-pools.gpu-driver` from
Expand All @@ -473,7 +486,8 @@ snapshot orchestration layer (e.g. `K8s.aks-gpu-pools.gpu-driver` from

**Paths are validated when recipe data is loaded, not when a snapshot is
evaluated.** Every constraint name in `spec.constraints`,
`spec.validation.readiness.constraints`, and `spec.profile.values.*.constraints`
`spec.validation.readiness.constraints`, `spec.profile.values.*.constraints`,
and `spec.profile.values.*.readinessConstraints`
is checked against the measurement catalog (`pkg/measurement/catalog.go`) as the
overlay, mixin, or base file is read. A path the catalog cannot address fails
the load with the file, the field, and — where there is a near match — a
Expand Down
4 changes: 4 additions & 0 deletions pkg/recipe/constraint_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ func validateSpecConstraintPaths(spec *RecipeMetadataSpec, source string) error
if err := validateConstraintPaths(spec.Profile.Values[name].Constraints, source, location); err != nil {
return err
}
location = fmt.Sprintf("%s.%s.readinessConstraints", locProfileConstraints, name)
if err := validateConstraintPaths(spec.Profile.Values[name].ReadinessConstraints, source, location); err != nil {
return err
}
}
}

Expand Down
65 changes: 47 additions & 18 deletions pkg/recipe/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,20 @@ type ProfileDeclaration struct {
// metadata.selectedProfile.advertiser and extends the dual-advertisement
// gates fail-closed. Any other value is rejected.
type ProfileValue struct {
Advertiser string `json:"advertiser,omitempty" yaml:"advertiser,omitempty"`
Constraints []Constraint `json:"constraints,omitempty" yaml:"constraints,omitempty"`
Advertiser string `json:"advertiser,omitempty" yaml:"advertiser,omitempty"`
Constraints []Constraint `json:"constraints,omitempty" yaml:"constraints,omitempty"`

// ReadinessConstraints are evaluated only by the aicr validate readiness
// pre-flight, never at generation time: applyEffectiveProfile routes them
// into spec.validation.readiness.constraints instead of spec.constraints.
// This is the home for a value's post-deployment distinguishing signals
// (ADR-015 Deferred Decision 5) — properties a correct deployment CREATES,
// such as a node label the value's own workload applies, which therefore
// cannot exist in the pre-deployment snapshot that generation-time
// constraints are evaluated against. Same fail-closed semantics as
// Constraints once the pre-flight runs; same catalog-load validation.
ReadinessConstraints []Constraint `json:"readinessConstraints,omitempty" yaml:"readinessConstraints,omitempty"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — No YAML round-trip test for the readinessConstraints struct tag

No test deserializes readinessConstraints from YAML, so a struct-tag typo isn't caught at this seam.

Blast radius: Largely mitigated already: profiled artifacts decode under KnownFields(true), so a typo'd tag would make a real readinessConstraints: key an unknown field → hard reject at load (fail-loud), not a silent drop.

Fix: Optional round-trip assertion on a ProfileValue with readinessConstraints:. Low priority.


ComponentRefs []ProfileComponentRef `json:"componentRefs,omitempty" yaml:"componentRefs,omitempty"`
}

Expand Down Expand Up @@ -205,23 +217,40 @@ func ValidateProfileDeclaration(decl *ProfileDeclaration) (map[string][]string,
// constraints already fail closed on an empty name or value
// (validateConstraintWarningSource); catalog load is the equivalent
// boundary for profile-contributed ones.
seenConstraints := make(map[string]struct{}, len(value.Constraints))
for _, constraint := range value.Constraints {
if constraint.Name == "" {
return nil, errors.New(errors.ErrCodeInvalidRequest,
fmt.Sprintf("profile %q value %q declares a constraint with no name", decl.Name, valueName))
}
if constraint.Value == "" {
return nil, errors.New(errors.ErrCodeInvalidRequest,
fmt.Sprintf("profile %q value %q constraint %q has no value",
decl.Name, valueName, constraint.Name))
}
if _, repeat := seenConstraints[constraint.Name]; repeat {
return nil, errors.New(errors.ErrCodeInvalidRequest,
fmt.Sprintf("profile %q value %q repeats constraint %q",
decl.Name, valueName, constraint.Name))
// Each list deduplicates independently: constraint names are
// measurement paths, and the same reading legitimately appears in
// both lists of one value with different expected states — the DD5
// pattern reads NodeTopology.gpu-nodes.label at generation (a pool
// pre-condition) AND at readiness (a post-deployment marker). The
// two lists evaluate in different phases with per-phase diagnostics,
// so cross-list reuse is unambiguous; a repeat WITHIN a list is two
// gates with one identity and stays rejected.
checkConstraints := func(constraints []Constraint, kind string) error {
seen := make(map[string]struct{}, len(constraints))
for _, constraint := range constraints {
if constraint.Name == "" {
return errors.New(errors.ErrCodeInvalidRequest,
fmt.Sprintf("profile %q value %q declares a %s with no name", decl.Name, valueName, kind))
}
if constraint.Value == "" {
return errors.New(errors.ErrCodeInvalidRequest,
fmt.Sprintf("profile %q value %q %s %q has no value",
decl.Name, valueName, kind, constraint.Name))
}
if _, repeat := seen[constraint.Name]; repeat {
return errors.New(errors.ErrCodeInvalidRequest,
fmt.Sprintf("profile %q value %q repeats constraint %q",
decl.Name, valueName, constraint.Name))
}
seen[constraint.Name] = struct{}{}
}
seenConstraints[constraint.Name] = struct{}{}
return nil
}
if err := checkConstraints(value.Constraints, "constraint"); err != nil {
return nil, err
}
if err := checkConstraints(value.ReadinessConstraints, "readiness constraint"); err != nil {
return nil, err
}

seenComponents := make(map[string]struct{}, len(value.ComponentRefs))
Expand Down
Loading
Loading