diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2cc2681..be646f7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -287,6 +287,9 @@ Scope: private scoped-state transformations built on Stage 4's resolution helper Validation: focused collection-policy tests and the everyday check. This stage must not add path-existence, shape, nullability, or global YAML final-check rules. +**Completion:** Implemented and verified with focused collection-policy tests, +`go test -race ./internal/engine`, and `./scripts/check.sh`. + ## Stage 6 — Implement The Shared Sequence Operations Scope: a pure document-level combination helper, not separate implementations diff --git a/internal/engine/collection_policy.go b/internal/engine/collection_policy.go index 0a372d0..bb60aac 100644 --- a/internal/engine/collection_policy.go +++ b/internal/engine/collection_policy.go @@ -7,6 +7,8 @@ type collectionPolicy struct { explicitMode sequenceMode explicitModePresent bool modeLocation *Location + recordOrder []*collectionRecordOwner + recordPolicies map[*collectionRecordOwner]collectionPolicyScope } type collectionPolicyResolution struct { diff --git a/internal/engine/collection_policy_state.go b/internal/engine/collection_policy_state.go new file mode 100644 index 0000000..fde8b47 --- /dev/null +++ b/internal/engine/collection_policy_state.go @@ -0,0 +1,242 @@ +package engine + +import ( + "sort" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +type collectionPolicyScope struct { + collections map[string]collectionPolicyEntry +} + +type collectionPolicyEntry struct { + destination Path + policy collectionPolicy +} + +type collectionRecordPolicy struct { + owner *collectionRecordOwner + scope collectionPolicyScope +} + +// The nonzero field ensures simultaneously live record owners have distinct +// addresses. The token is per-record ownership, not a source or identity key. +type collectionRecordOwner struct { + marker byte +} + +type projectedCollectionPolicy struct { + path Path + policy collectionPolicy +} + +func newCollectionRecordPolicy() collectionRecordPolicy { + return collectionRecordPolicy{owner: &collectionRecordOwner{marker: 1}} +} + +func retainedCollectionRecordPolicy(record collectionRecordPolicy) collectionRecordPolicy { + return collectionRecordPolicy{ + owner: record.owner, + scope: cloneCollectionPolicyScope(record.scope), + } +} + +func replacedCollectionRecordPolicy() collectionRecordPolicy { + return newCollectionRecordPolicy() +} + +func (policy *collectionPolicy) setRecordPolicy(record collectionRecordPolicy) { + if record.owner == nil { + record.owner = newCollectionRecordPolicy().owner + } + if policy.recordPolicies == nil { + policy.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope) + } + if _, exists := policy.recordPolicies[record.owner]; !exists { + policy.recordOrder = append(policy.recordOrder, record.owner) + } + policy.recordPolicies[record.owner] = cloneCollectionPolicyScope(record.scope) +} + +func (policy collectionPolicy) recordPolicyAt(index int) (collectionRecordPolicy, bool) { + if index < 0 || index >= len(policy.recordOrder) { + return collectionRecordPolicy{}, false + } + owner := policy.recordOrder[index] + scope, ok := policy.recordPolicies[owner] + if !ok { + return collectionRecordPolicy{}, false + } + return collectionRecordPolicy{owner: owner, scope: cloneCollectionPolicyScope(scope)}, true +} + +func collectionPolicyForReplacement(lower *collectionPolicy, current collectionPolicy) collectionPolicy { + result := cloneCollectionPolicy(current) + if !current.explicitModePresent && lower != nil && lower.explicitModePresent { + result.explicitMode = lower.explicitMode + result.explicitModePresent = true + result.modeLocation = cloneLocation(lower.modeLocation) + } + return result +} + +// collectionPolicyWithoutValue keeps identity and record ownership because no +// collection value was replaced. A key-only declaration can remain pending +// when no sequence is present, or validate the retained sequence immediately. +func collectionPolicyWithoutValue( + lower *collectionPolicy, + current collectionPolicy, + retainedValue *document.Node, + path Path, +) (collectionPolicy, []Diagnostic) { + result := collectionPolicy{} + if lower != nil { + result = cloneCollectionPolicy(*lower) + } + if lower != nil && lower.identityPresent && current.identityPresent && !equalSequenceIdentityFields(lower.identityFields, current.identityFields) { + return collectionPolicy{}, []Diagnostic{collectionPolicyConflictDiagnostic( + path, current.identityLocation, lower.identityLocation, + "collection identity declaration conflicts with retained identity", + )} + } + if current.identityPresent { + result.identityFields = cloneStrings(current.identityFields) + result.identityPresent = true + result.identityLocation = cloneLocation(current.identityLocation) + } + if current.explicitModePresent { + result.explicitMode = current.explicitMode + result.explicitModePresent = true + result.modeLocation = cloneLocation(current.modeLocation) + } + if current.identityPresent && retainedValue != nil && retainedValue.Kind == document.Sequence { + _, diagnostics := validateSequenceIdentity(retainedValue, path, result.identityFields, sequenceIdentityDiagnosticCodes{ + invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict, + }) + if len(diagnostics) != 0 { + return collectionPolicy{}, diagnostics + } + } + return result, nil +} + +func (scope *collectionPolicyScope) set(destination Path, policy collectionPolicy) { + if scope.collections == nil { + scope.collections = make(map[string]collectionPolicyEntry) + } + scope.collections[destination.String()] = collectionPolicyEntry{ + destination: destination.clone(), policy: cloneCollectionPolicy(policy), + } +} + +func (scope collectionPolicyScope) at(destination Path) (collectionPolicy, bool) { + entry, ok := scope.collections[destination.String()] + if !ok { + return collectionPolicy{}, false + } + return cloneCollectionPolicy(entry.policy), true +} + +// retireCollectionPoliciesAtOrBelow clears YAML identity and record-owned state +// below a replaced ancestor while retaining independently scoped explicit modes. +func (scope *collectionPolicyScope) retireCollectionPoliciesAtOrBelow(ancestor Path) { + if scope == nil { + return + } + for key, entry := range scope.collections { + if !pathHasPrefix(entry.destination, ancestor) { + continue + } + entry.policy.identityFields = nil + entry.policy.identityPresent = false + entry.policy.identityLocation = nil + entry.policy.recordOrder = nil + entry.policy.recordPolicies = nil + scope.collections[key] = entry + } +} + +func projectCollectionPolicyScope(scope collectionPolicyScope, prefix Path) []projectedCollectionPolicy { + keys := make([]string, 0, len(scope.collections)) + for key := range scope.collections { + keys = append(keys, key) + } + sort.Strings(keys) + var projected []projectedCollectionPolicy + for _, key := range keys { + entry := scope.collections[key] + concrete := appendDestination(prefix, entry.destination) + projected = append(projected, projectedCollectionPolicy{path: concrete, policy: cloneCollectionPolicy(entry.policy)}) + for index, owner := range entry.policy.recordOrder { + if recordScope, ok := entry.policy.recordPolicies[owner]; ok { + projected = append(projected, projectCollectionPolicyScope(recordScope, concrete.Index(index))...) + } + } + } + return projected +} + +func cloneCollectionPolicyScope(scope collectionPolicyScope) collectionPolicyScope { + if scope.collections == nil { + return collectionPolicyScope{} + } + cloned := collectionPolicyScope{collections: make(map[string]collectionPolicyEntry, len(scope.collections))} + for key, entry := range scope.collections { + cloned.collections[key] = collectionPolicyEntry{ + destination: entry.destination.clone(), policy: cloneCollectionPolicy(entry.policy), + } + } + return cloned +} + +func cloneCollectionPolicyScopeForInheritance(scope collectionPolicyScope) collectionPolicyScope { + if scope.collections == nil { + return collectionPolicyScope{} + } + cloned := collectionPolicyScope{collections: make(map[string]collectionPolicyEntry, len(scope.collections))} + for key, entry := range scope.collections { + cloned.collections[key] = collectionPolicyEntry{ + destination: entry.destination.clone(), policy: cloneCollectionPolicyForInheritance(entry.policy), + } + } + return cloned +} + +func cloneCollectionPolicy(policy collectionPolicy) collectionPolicy { + cloned := policy + cloned.identityFields = cloneStrings(policy.identityFields) + cloned.identityLocation = cloneLocation(policy.identityLocation) + cloned.modeLocation = cloneLocation(policy.modeLocation) + if policy.recordOrder != nil { + cloned.recordOrder = append([]*collectionRecordOwner(nil), policy.recordOrder...) + } + if policy.recordPolicies != nil { + cloned.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope, len(policy.recordPolicies)) + for owner, scope := range policy.recordPolicies { + cloned.recordPolicies[owner] = cloneCollectionPolicyScope(scope) + } + } + return cloned +} + +func cloneCollectionPolicyForInheritance(policy collectionPolicy) collectionPolicy { + cloned := policy + cloned.identityFields = cloneStrings(policy.identityFields) + cloned.identityLocation = cloneLocation(policy.identityLocation) + cloned.modeLocation = cloneLocation(policy.modeLocation) + if policy.recordOrder != nil { + cloned.recordOrder = make([]*collectionRecordOwner, 0, len(policy.recordOrder)) + } + if policy.recordPolicies != nil { + cloned.recordPolicies = make(map[*collectionRecordOwner]collectionPolicyScope, len(policy.recordPolicies)) + for _, sourceOwner := range policy.recordOrder { + owner := newCollectionRecordPolicy().owner + cloned.recordOrder = append(cloned.recordOrder, owner) + if sourceScope, ok := policy.recordPolicies[sourceOwner]; ok { + cloned.recordPolicies[owner] = cloneCollectionPolicyScopeForInheritance(sourceScope) + } + } + } + return cloned +} diff --git a/internal/engine/collection_policy_state_test.go b/internal/engine/collection_policy_state_test.go new file mode 100644 index 0000000..8c71c3a --- /dev/null +++ b/internal/engine/collection_policy_state_test.go @@ -0,0 +1,292 @@ +package engine + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestCollectionPolicyWithoutValueRetainsIdentityAndMode(t *testing.T) { + lower := collectionPolicy{ + identityFields: []string{"id"}, + identityPresent: true, + explicitMode: sequenceModeAppend, + explicitModePresent: true, + } + lower.setRecordPolicy(newCollectionRecordPolicy()) + current := collectionPolicy{explicitMode: sequenceModeReplace, explicitModePresent: true} + retained, diagnostics := collectionPolicyWithoutValue(&lower, current, nil, Keys("prompts")) + if len(diagnostics) != 0 || !retained.identityPresent || !reflect.DeepEqual(retained.identityFields, []string{"id"}) { + t.Fatalf("no-value replace reset identity: %#v, %#v", retained, diagnostics) + } + if retained.explicitMode != sequenceModeReplace || !retained.explicitModePresent || len(retained.recordOrder) != 1 { + t.Fatalf("no-value policy update lost mode or record ownership: %#v", retained) + } + retainedRecord, _ := retained.recordPolicyAt(0) + lowerRecord, _ := lower.recordPolicyAt(0) + if retainedRecord.owner != lowerRecord.owner { + t.Fatal("no-value policy update changed record ownership") + } +} + +func TestCollectionPolicyWithoutValueLeavesAbsentAndNonSequenceDestinationsUnconstrained(t *testing.T) { + current := collectionPolicy{identityFields: []string{"id"}, identityPresent: true} + for _, value := range []*document.Node{nil, parsePolicyNode(t, "value: scalar\n").Mapping[0].Value} { + retained, diagnostics := collectionPolicyWithoutValue(nil, current, value, Keys("prompts")) + if len(diagnostics) != 0 || !retained.identityPresent || !reflect.DeepEqual(retained.identityFields, []string{"id"}) { + t.Fatalf("pending identity at absent/non-sequence destination = %#v, %#v", retained, diagnostics) + } + } +} + +func TestCollectionPolicyWithoutValueValidatesRetainedSequenceImmediately(t *testing.T) { + current := collectionPolicy{identityFields: []string{"id"}, identityPresent: true} + valid := parsePolicyNode(t, "items: [{id: one}, {id: two}]\n").Mapping[0].Value + retained, diagnostics := collectionPolicyWithoutValue(nil, current, valid, Keys("prompts")) + if len(diagnostics) != 0 || !retained.identityPresent { + t.Fatalf("valid retained records were rejected: %#v, %#v", retained, diagnostics) + } + + invalid := parsePolicyNode(t, "items: [{name: missing-id}]\n").Mapping[0].Value + retained, diagnostics = collectionPolicyWithoutValue(nil, current, invalid, Keys("prompts")) + if !reflect.DeepEqual(retained, collectionPolicy{}) || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/prompts/0/id" { + t.Fatalf("invalid retained records = %#v, %#v", retained, diagnostics) + } +} + +func TestCollectionPolicyWithoutValueRejectsIdentityChange(t *testing.T) { + lower := collectionPolicy{identityFields: []string{"id"}, identityPresent: true} + current := collectionPolicy{identityFields: []string{"name"}, identityPresent: true} + retained, diagnostics := collectionPolicyWithoutValue(&lower, current, nil, Keys("prompts")) + if !reflect.DeepEqual(retained, collectionPolicy{}) || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict { + t.Fatalf("no-value identity change = %#v, %#v", retained, diagnostics) + } +} + +func TestCollectionPolicyLifetimePersistenceAndReplacementTable(t *testing.T) { + tests := []struct { + name string + current collectionPolicy + replaceValue bool + wantIdentity []string + wantIdentitySet bool + wantMode sequenceMode + wantRecords int + }{ + { + name: "mode-only replace declaration does not reset retained identity", + current: collectionPolicy{ + explicitMode: sequenceModeReplace, explicitModePresent: true, + }, + wantIdentity: []string{"id"}, wantIdentitySet: true, + wantMode: sequenceModeReplace, wantRecords: 1, + }, + { + name: "whole-sequence replacement resets lower identity", + replaceValue: true, + wantIdentitySet: false, + wantMode: sequenceModeAppend, + wantRecords: 0, + }, + { + name: "replacement can establish a new identity", + current: collectionPolicy{identityFields: []string{"name"}, identityPresent: true}, + replaceValue: true, + wantIdentity: []string{"name"}, + wantIdentitySet: true, + wantMode: sequenceModeAppend, + wantRecords: 0, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + lower := collectionPolicy{ + identityFields: []string{"id"}, identityPresent: true, + explicitMode: sequenceModeAppend, explicitModePresent: true, + } + lower.setRecordPolicy(newCollectionRecordPolicy()) + var got collectionPolicy + if test.replaceValue { + got = collectionPolicyForReplacement(&lower, test.current) + } else { + var diagnostics []Diagnostic + got, diagnostics = collectionPolicyWithoutValue(&lower, test.current, nil, Keys("prompts")) + if len(diagnostics) != 0 { + t.Fatalf("policy diagnostics = %#v", diagnostics) + } + } + if got.identityPresent != test.wantIdentitySet || !reflect.DeepEqual(got.identityFields, test.wantIdentity) || got.explicitMode != test.wantMode || len(got.recordOrder) != test.wantRecords { + t.Fatalf("transition result = %#v", got) + } + }) + } +} + +func TestCollectionPolicyValueReplacementResetsIdentityButRetainsExplicitMode(t *testing.T) { + lower := collectionPolicy{ + identityFields: []string{"id"}, + identityPresent: true, + explicitMode: sequenceModeAppend, + explicitModePresent: true, + } + lower.setRecordPolicy(newCollectionRecordPolicy()) + current := collectionPolicy{identityFields: []string{"name"}, identityPresent: true} + replaced := collectionPolicyForReplacement(&lower, current) + if !replaced.identityPresent || !reflect.DeepEqual(replaced.identityFields, []string{"name"}) { + t.Fatalf("replacement identity was not reset: %#v", replaced) + } + if replaced.explicitMode != sequenceModeAppend || !replaced.explicitModePresent || len(replaced.recordOrder) != 0 { + t.Fatalf("replacement failed to retain explicit mode or retire lower record policy: %#v", replaced) + } + + cleared := collectionPolicyForReplacement(&lower, collectionPolicy{}) + if cleared.identityPresent || len(cleared.identityFields) != 0 || cleared.explicitMode != sequenceModeAppend || !cleared.explicitModePresent { + t.Fatalf("replacement without a new key did not clear YAML identity while retaining mode: %#v", cleared) + } +} + +func TestCollectionRecordRetentionAndReplacementUseOpaqueOwners(t *testing.T) { + oldRecord := newCollectionRecordPolicy() + oldRecord.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true}) + retained := retainedCollectionRecordPolicy(oldRecord) + if retained.owner != oldRecord.owner { + t.Fatal("retained record did not keep its owner") + } + if got, ok := retained.scope.at(Keys("steps")); !ok || !got.identityPresent { + t.Fatalf("retained record lost its local policy: %#v, %t", got, ok) + } + if retained.scope.collections["/steps"].policy.identityFields[0] = "changed"; oldRecord.scope.collections["/steps"].policy.identityFields[0] != "id" { + t.Fatal("retained record policy aliases its source") + } + + // A whole-record replacement at the same list position, even with equal + // authored identity/source metadata, receives new ownership and no old rules. + replacement := replacedCollectionRecordPolicy() + if replacement.owner == oldRecord.owner || len(replacement.scope.collections) != 0 { + t.Fatal("replacement record inherited discarded owner policy") + } +} + +func TestCollectionPolicyAncestorRetirementPreservesIndependentMode(t *testing.T) { + var scope collectionPolicyScope + owner := newCollectionRecordPolicy() + owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true}) + scope.set(Keys("catalog", "prompts"), collectionPolicyWithRecords(collectionPolicy{ + identityFields: []string{"id"}, + identityPresent: true, + explicitMode: sequenceModeAppend, + explicitModePresent: true, + }, owner)) + scope.retireCollectionPoliciesAtOrBelow(Keys("catalog")) + policy, ok := scope.at(Keys("catalog", "prompts")) + if !ok || policy.identityPresent || len(policy.recordOrder) != 0 || policy.explicitMode != sequenceModeAppend || !policy.explicitModePresent { + t.Fatalf("ancestor retirement = %#v, %t", policy, ok) + } + reintroduced, diagnostics := collectionPolicyWithoutValue(&policy, collectionPolicy{}, nil, Keys("catalog", "prompts")) + if len(diagnostics) != 0 || reintroduced.identityPresent || reintroduced.explicitMode != sequenceModeAppend { + t.Fatalf("retired identity was resurrected while mode persisted: %#v, %#v", reintroduced, diagnostics) + } +} + +func TestEqualOuterIdentitiesHaveDistinctRecordOwners(t *testing.T) { + retained := newCollectionRecordPolicy() + replacement := replacedCollectionRecordPolicy() + if retained.owner == replacement.owner { + t.Fatal("replacement with the same outer identity reused the old owner") + } + if retained.owner == nil || replacement.owner == nil { + t.Fatal("record owner token is missing") + } +} + +func TestCollectionPolicyScopesKeepRootAndItemRulesIndependent(t *testing.T) { + var root collectionPolicyScope + root.set(Keys("steps"), collectionPolicy{identityFields: []string{"root-id"}, identityPresent: true}) + owner := newCollectionRecordPolicy() + owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"item-id"}, identityPresent: true}) + root.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, owner)) + + root.retireCollectionPoliciesAtOrBelow(Keys("steps")) + rootPolicy, ok := root.at(Keys("steps")) + if !ok || rootPolicy.identityPresent { + t.Fatalf("root-local policy was not retired: %#v", rootPolicy) + } + itemPolicy, ok := root.at(Keys("items")) + if !ok || len(itemPolicy.recordOrder) != 1 { + t.Fatalf("unrelated item owner disappeared: %#v", itemPolicy) + } + itemRecord, _ := itemPolicy.recordPolicyAt(0) + nested, ok := itemRecord.scope.at(Keys("steps")) + if !ok || !nested.identityPresent || !reflect.DeepEqual(nested.identityFields, []string{"item-id"}) { + t.Fatalf("item-local policy was coupled to root destination: %#v", nested) + } +} + +func TestCollectionPolicyProjectionUsesConcretePlacement(t *testing.T) { + first := newCollectionRecordPolicy() + first.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"step"}, identityPresent: true}) + second := newCollectionRecordPolicy() + second.scope.set(Keys("steps"), collectionPolicy{explicitMode: sequenceModeAppend, explicitModePresent: true}) + var scope collectionPolicyScope + scope.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, first, second)) + + projected := projectCollectionPolicyScope(scope, Keys("mounted")) + if len(projected) != 3 { + t.Fatalf("projected policies = %#v", projected) + } + want := []string{"/mounted/items", "/mounted/items/0/steps", "/mounted/items/1/steps"} + for index, policy := range projected { + if policy.path.String() != want[index] { + t.Fatalf("projected path[%d] = %q, want %q", index, policy.path.String(), want[index]) + } + } +} + +func TestCollectionPolicyInheritanceCopiesOwnersAndState(t *testing.T) { + owner := newCollectionRecordPolicy() + owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true}) + original := collectionPolicyWithRecords(collectionPolicy{}, owner) + copy := cloneCollectionPolicyForInheritance(original) + copyOwner := copy.recordOrder[0] + if copyOwner == owner.owner { + t.Fatal("inheritance copy retained the source record owner") + } + copy.recordPolicies[copyOwner].collections["/steps"].policy.identityFields[0] = "changed" + if owner.scope.collections["/steps"].policy.identityFields[0] != "id" { + t.Fatal("inheritance copy aliases source policy") + } +} + +func TestAcquisitionScopeCloneOwnsCollectionPolicyCopy(t *testing.T) { + owner := newCollectionRecordPolicy() + owner.scope.set(Keys("steps"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true}) + var scope acquisitionScope + scope.collectionPolicies.set(Keys("items"), collectionPolicyWithRecords(collectionPolicy{}, owner)) + cloned := cloneAcquisitionScope(scope) + clonedOwner := cloned.collectionPolicies.collections["/items"].policy.recordOrder[0] + cloned.collectionPolicies.collections["/items"].policy.recordPolicies[clonedOwner].collections["/steps"].policy.identityFields[0] = "changed" + sourceOwner := scope.collectionPolicies.collections["/items"].policy.recordOrder[0] + if scope.collectionPolicies.collections["/items"].policy.recordPolicies[sourceOwner].collections["/steps"].policy.identityFields[0] != "id" { + t.Fatal("acquisition scope clone aliases collection policy") + } + if clonedOwner != owner.owner { + t.Fatal("acquisition scope clone changed record ownership") + } +} + +func collectionPolicyWithRecords(policy collectionPolicy, records ...collectionRecordPolicy) collectionPolicy { + for _, record := range records { + policy.setRecordPolicy(record) + } + return policy +} + +func parsePolicyNode(t *testing.T, input string) *document.Node { + t.Helper() + parsed, diagnostics := parseSource(Bytes("collection-policy-state.yml", []byte(input)), "collection-policy-state", defaultParseLimits()) + if len(diagnostics) != 0 { + t.Fatalf("parse policy value: %#v", diagnostics) + } + return parsed.document +} diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 9054cd7..d4ec6dc 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -39,13 +39,17 @@ type acquiredContribution struct { // acquisitionScope is the ordered strict mapping scope produced by one input // and its ordinary imports. type acquisitionScope struct { - owner importNodeKey - destination Path - contributions []acquiredContribution + owner importNodeKey + destination Path + contributions []acquiredContribution + collectionPolicies collectionPolicyScope } func cloneAcquisitionScope(scope acquisitionScope) acquisitionScope { - cloned := acquisitionScope{owner: scope.owner, destination: scope.destination.clone()} + cloned := acquisitionScope{ + owner: scope.owner, destination: scope.destination.clone(), + collectionPolicies: cloneCollectionPolicyScope(scope.collectionPolicies), + } if scope.contributions != nil { cloned.contributions = make([]acquiredContribution, len(scope.contributions)) for index, contribution := range scope.contributions {