Part of #983.
Summary
RecipeMetadataSpec.Merge (in pkg/recipe/metadata.go, around line 490) aliases overlay validation phases into the merge result by pointer:
s.Validation.Readiness = other.Validation.Readiness
s.Validation.Deployment = other.Validation.Deployment
s.Validation.Performance = other.Validation.Performance
s.Validation.Conformance = other.Validation.Conformance
other.Validation lives inside the cached MetadataStore that the package's sync.Once-backed loader populates once per process. Every call to BuildRecipeResult for an overlay that contributes a validation phase aliases that same cached pointer into its result. Any caller mutating result.Validation.Readiness.Constraints[i], result.Validation.Performance.NodeSelection.ExcludeNodes, or any other nested slice/map under a phase corrupts the cached overlay for every other concurrent caller of the same overlay.
Reproduction (conceptual)
The hazard is dormant because no current in-tree caller mutates a result's validation in-place. It surfaces immediately if:
- Two concurrent
BuildRecipeResult calls produce results that both include the same overlay's Validation.Readiness.
- One caller appends to
result.Validation.Readiness.Constraints.
- The second caller observes the appended constraint in its own result.
A targeted unit test that explicitly mutates a returned phase and then re-loads the same overlay via the store will catch this.
Why it matters now
This is documented as an anti-pattern in .claude/CLAUDE.md:
Deep-copy helper that recurses into maps but copies []any by reference — recurse into both map[string]any and []any; scalars fall through the default branch by value. Slice aliasing leaks mutations across overlay merges.
The cache is currently process-local and the bug is invisible. As soon as we expose more API surface that returns structured validation data to in-process consumers (see the parent), the assumption that nobody mutates phase data breaks silently and across goroutines. Fix it now while the contract is still clean.
Proposed change
Introduce three private clone helpers in pkg/recipe/metadata.go:
func cloneValidationConfig(in *ValidationConfig) *ValidationConfig
func cloneValidationPhase(in *ValidationPhase) *ValidationPhase
func cloneNodeSelection(in *NodeSelection) *NodeSelection
Each must:
- Return nil when given nil (so they can stand in directly for pointer assignments).
- Deep-copy every slice (
Constraints, Checks, ExcludeNodes) and map (NodeSelection.Selector) so the clone shares no backing storage with the input.
- Be updated alongside any future field added to
ValidationConfig, ValidationPhase, or NodeSelection. Add a comment on each helper noting this contract.
cloneNodeSelection in particular must clone both Selector (map) and ExcludeNodes (slice). The most common form of this bug is a clone that copies one and forgets the other.
Rewrite the four overlay-precedence branches in Merge to call the clone helpers instead of aliasing.
Acceptance
- New table-driven test in
pkg/recipe/metadata_test.go:
- For each of the four phases (Readiness/Deployment/Performance/Conformance), mutate the merged result's
Constraints, Checks, and NodeSelection.ExcludeNodes / Selector, then re-load the underlying overlay via the store and assert it is unchanged.
- Repeat with two concurrent
BuildRecipeResult calls (use -race) to demonstrate independence.
make test -race ./pkg/recipe/... clean.
- Grep
pkg/recipe/metadata.go for s.Validation.X = other.Validation.X patterns and confirm none remain.
Out of scope
- Cloning
ComponentRefs and Constraints at the top level of RecipeMetadataSpec — those merge by name and don't have the aliasing problem.
- Refactoring the cache itself. That's covered by the per-provider isolation child issue.
Dependencies
- None. This is standalone and the smallest of the four issues — recommended to land first.
Part of #983.
Summary
RecipeMetadataSpec.Merge(inpkg/recipe/metadata.go, around line 490) aliases overlay validation phases into the merge result by pointer:other.Validationlives inside the cachedMetadataStorethat the package'ssync.Once-backed loader populates once per process. Every call toBuildRecipeResultfor an overlay that contributes a validation phase aliases that same cached pointer into its result. Any caller mutatingresult.Validation.Readiness.Constraints[i],result.Validation.Performance.NodeSelection.ExcludeNodes, or any other nested slice/map under a phase corrupts the cached overlay for every other concurrent caller of the same overlay.Reproduction (conceptual)
The hazard is dormant because no current in-tree caller mutates a result's validation in-place. It surfaces immediately if:
BuildRecipeResultcalls produce results that both include the same overlay'sValidation.Readiness.result.Validation.Readiness.Constraints.A targeted unit test that explicitly mutates a returned phase and then re-loads the same overlay via the store will catch this.
Why it matters now
This is documented as an anti-pattern in
.claude/CLAUDE.md:The cache is currently process-local and the bug is invisible. As soon as we expose more API surface that returns structured validation data to in-process consumers (see the parent), the assumption that nobody mutates phase data breaks silently and across goroutines. Fix it now while the contract is still clean.
Proposed change
Introduce three private clone helpers in
pkg/recipe/metadata.go:Each must:
Constraints,Checks,ExcludeNodes) and map (NodeSelection.Selector) so the clone shares no backing storage with the input.ValidationConfig,ValidationPhase, orNodeSelection. Add a comment on each helper noting this contract.cloneNodeSelectionin particular must clone bothSelector(map) andExcludeNodes(slice). The most common form of this bug is a clone that copies one and forgets the other.Rewrite the four overlay-precedence branches in
Mergeto call the clone helpers instead of aliasing.Acceptance
pkg/recipe/metadata_test.go:Constraints,Checks, andNodeSelection.ExcludeNodes/Selector, then re-load the underlying overlay via the store and assert it is unchanged.BuildRecipeResultcalls (use-race) to demonstrate independence.make test -race ./pkg/recipe/...clean.pkg/recipe/metadata.gofors.Validation.X = other.Validation.Xpatterns and confirm none remain.Out of scope
ComponentRefsandConstraintsat the top level ofRecipeMetadataSpec— those merge by name and don't have the aliasing problem.Dependencies