Preserve child identity in inheritance history

This commit is contained in:
2026-10-24 01:17:00 +00:00
parent 988e49bb09
commit 1a673753b9
6 changed files with 126 additions and 3 deletions

View File

@@ -44,6 +44,7 @@ type inheritanceOperation struct {
templatePolicies collectionPolicyScope
effectivePolicies []collectionPolicyScope
sequences []sequenceHistoryContribution
identityFields []string
}
func cloneInheritancePolicyTemplates(templates []collectionPolicyScope) []collectionPolicyScope {
@@ -107,6 +108,7 @@ func cloneInheritanceOperations(operations []inheritanceOperation) []inheritance
cloned[index].templatePolicies = cloneCollectionPolicyScopeForInheritance(operation.templatePolicies)
cloned[index].effectivePolicies = cloneInheritancePolicyTemplates(operation.effectivePolicies)
cloned[index].sequences = cloneSequenceHistoryContributions(operation.sequences)
cloned[index].identityFields = cloneStrings(operation.identityFields)
}
return cloned
}

View File

@@ -25,6 +25,12 @@ func appendInheritanceExplanation(explanation *Explanation, working **document.N
}
before := valueSnapshot(*working, query, secrets)
replaceHistoryPath(working, operation.transfer.to, operation.parent)
// A record keeps its own identity throughout inheritance. Restore it
// before taking enclosing snapshots as well as direct field snapshots.
for _, field := range operation.identityFields {
identity, _ := documentAtPath(operation.effective, Keys(field))
replaceHistoryPath(working, operation.transfer.to.Key(field), identity)
}
afterParent := valueSnapshot(*working, query, secrets)
parent, parentPresent := transferredOperationOrigin(operation, operationPathForQuery(operation.transfer.to, query))
if pathsIntersect(operation.transfer.to, query) && parentPresent {

View File

@@ -1,6 +1,7 @@
package engine
import (
"slices"
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
@@ -114,7 +115,7 @@ func projectedInheritanceChanges(query Path, operation inheritanceOperation) []C
return []Change{{Path: operation.control.path.clone(), Origin: origin,
Dependencies: []Path{operation.control.path.clone()}}}
}
if operation.transfer == nil || !pathsIntersect(operation.transfer.to, query) {
if operation.transfer == nil || !pathsIntersect(operation.transfer.to, query) || inheritanceOperationIdentity(operation, query) {
return nil
}
path := query
@@ -155,6 +156,9 @@ func projectedInheritanceChanges(query Path, operation inheritanceOperation) []C
}
func transferredOperationOrigin(operation inheritanceOperation, destination Path) (Origin, bool) {
if inheritanceOperationIdentity(operation, destination) {
return Origin{}, false
}
relative, ok := relativeHistoryPath(operation.transfer.to, destination)
if !ok {
return Origin{}, false
@@ -168,3 +172,12 @@ func transferredOperationOrigin(operation inheritanceOperation, destination Path
operation.transfer.from, operation.transfer.to, *operation.transfer)
return transferred[destination.String()], true
}
func inheritanceOperationIdentity(operation inheritanceOperation, path Path) bool {
if operation.transfer == nil || len(operation.identityFields) == 0 {
return false
}
relative, within := relativeHistoryPath(operation.transfer.to, path)
return within && len(relative.segments) != 0 && relative.segments[0].kind != pathSegmentIndex &&
slices.Contains(operation.identityFields, relative.segments[0].key)
}

View File

@@ -0,0 +1,87 @@
package engine
import (
"fmt"
"strings"
"testing"
)
func TestRecordInheritanceHistoryPreservesChildIdentity(t *testing.T) {
t.Parallel()
for _, compound := range []bool{false, true} {
t.Run(fmt.Sprintf("compound=%t", compound), func(t *testing.T) {
key, reference := "id", "{id: base}"
identities := map[string]string{"id": "child"}
if compound {
key, reference = "[id, tenant]", "{id: base, tenant: parent-team}"
identities["tenant"] = "child-team"
}
body := "configkit:\n sequences: [{destination: [items], key: " + key + "}]\nitems:\n - id: base\n tenant: parent-team\n value: inherited\n - id: child\n tenant: child-team\n extends: " + reference + "\n"
loaded, err := testLoad(Bytes("records", []byte(body)), Options[map[string]any]{})
if err != nil {
t.Fatal(err)
}
child := Keys("items").Index(1)
for field, want := range identities {
path := child.Key(field)
info, found := loaded.Metadata().Lookup(path)
if !found || !info.Explicit || len(info.Origin.Inheritance) != 0 {
t.Fatalf("child identity provenance = %#v, found %t", info, found)
}
for _, change := range loaded.Metadata().History(path) {
if change.Origin.Operation == ChangeInheritance {
t.Fatalf("child identity received inheritance history: %#v", change)
}
}
explanation, found := loaded.Explain(path)
if !found {
t.Fatalf("missing identity explanation at %s", path)
}
for _, change := range explanation.History {
for _, snapshot := range []ValueSnapshot{change.Before, change.After} {
if snapshot.Present && snapshot.YAML != want+"\n" {
t.Fatalf("child identity snapshot = %q, want %q", snapshot.YAML, want)
}
}
}
}
for _, query := range []Path{child, Keys("items"), {}} {
explanation, found := loaded.Explain(query)
if !found {
t.Fatalf("missing ancestor explanation at %s", query)
}
relative, _ := relativeHistoryPath(query, child)
for _, change := range explanation.History {
for _, snapshot := range []ValueSnapshot{change.Before, change.After} {
if !snapshot.Present {
continue
}
wrapped := "snapshot:\n " + strings.ReplaceAll(snapshot.YAML, "\n", "\n ") + "\n"
root := parseItemPolicyDocument(t, "history", wrapped)
for field, want := range identities {
value, present := documentAtPath(root, appendDestination(Keys("snapshot"), relative.Key(field)))
if present && value.Text != want {
t.Fatalf("ancestor %s snapshot has child %s = %q", query, field, value.Text)
}
}
}
}
}
payload, found := loaded.Explain(child.Key("value"))
if !found || payload.Effective.YAML != "inherited\n" {
t.Fatalf("inherited payload = %#v, found %t", payload, found)
}
inherited := false
for _, change := range payload.History {
inherited = inherited || change.Origin.Operation == ChangeInheritance
}
if !inherited {
t.Fatal("ordinary inherited payload lost its history")
}
parent, found := loaded.Explain(Keys("items").Index(0).Key("id"))
if !found || parent.Effective.YAML != "base\n" || len(parent.History) == 0 {
t.Fatalf("parent identity history = %#v, found %t", parent, found)
}
})
}
}

View File

@@ -231,6 +231,12 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
parentPolicyScope, replaced = lowerCollectionPoliciesForClaims(parentPolicyScope, claims.ordered, declarations, adapter.applicationPolicy())
}
merged := inheritanceParentPayload(parent, node.identityFields)
historyParent := parent
if len(node.identityFields) != 0 {
// The retained parent snapshot is the inherited payload, before
// child fields are applied, with parent identity excluded.
historyParent = document.Clone(merged)
}
mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
parentPolicyScope, childPolicyScope, node.path, parent, payload, adapter.applicationPolicy(), replaced,
)
@@ -316,12 +322,13 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
resolvedPolicies[index] = inheritancePolicyScopeAtPath(mergedPolicyScope, node.path)
result.transfers = append(result.transfers, transfer)
result.operations = append(result.operations, inheritanceOperation{
transfer: &transfer, parent: parent, effective: merged,
transfer: &transfer, parent: historyParent, effective: merged,
parentOrigins: resolvedOrigins[node.parent], effectiveOrigins: mergedOrigins,
parentPolicies: cloneInheritancePolicyTemplates(parentPolicies),
templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies),
effectivePolicies: cloneInheritancePolicyTemplates(effectivePolicies),
sequences: cloneSequenceHistoryContributions(sequenceContributions),
identityFields: cloneStrings(node.identityFields),
})
resolvedPolicyTemplates[index] = effectivePolicies
} else {