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

@@ -587,7 +587,15 @@ main records as a workaround.
## 15. Exclude Parent Identity From Inherited History Payload — P2 ## 15. Exclude Parent Identity From Inherited History Payload — P2
Status: [ ] Open. Status: [x] Complete.
Completion: retained record-inheritance operations now exclude parent identity
from their payload and origin projection. Replay preserves the child's own
identity before taking field, record, sequence, or root snapshots, without
inventing inheritance events for retained identity fields. Regressions cover
single and compound identities, explicit child provenance, ordinary inherited
payload, and the parent's original history. Verified inheritance/inspection
coverage, `./scripts/check.sh`, and `go test -race ./internal/engine`.
Owners: inheritance operation construction in Owners: inheritance operation construction in
[`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go), [`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go),

View File

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

View File

@@ -25,6 +25,12 @@ func appendInheritanceExplanation(explanation *Explanation, working **document.N
} }
before := valueSnapshot(*working, query, secrets) before := valueSnapshot(*working, query, secrets)
replaceHistoryPath(working, operation.transfer.to, operation.parent) 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) afterParent := valueSnapshot(*working, query, secrets)
parent, parentPresent := transferredOperationOrigin(operation, operationPathForQuery(operation.transfer.to, query)) parent, parentPresent := transferredOperationOrigin(operation, operationPathForQuery(operation.transfer.to, query))
if pathsIntersect(operation.transfer.to, query) && parentPresent { if pathsIntersect(operation.transfer.to, query) && parentPresent {

View File

@@ -1,6 +1,7 @@
package engine package engine
import ( import (
"slices"
"sort" "sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "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, return []Change{{Path: operation.control.path.clone(), Origin: origin,
Dependencies: []Path{operation.control.path.clone()}}} 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 return nil
} }
path := query path := query
@@ -155,6 +156,9 @@ func projectedInheritanceChanges(query Path, operation inheritanceOperation) []C
} }
func transferredOperationOrigin(operation inheritanceOperation, destination Path) (Origin, bool) { func transferredOperationOrigin(operation inheritanceOperation, destination Path) (Origin, bool) {
if inheritanceOperationIdentity(operation, destination) {
return Origin{}, false
}
relative, ok := relativeHistoryPath(operation.transfer.to, destination) relative, ok := relativeHistoryPath(operation.transfer.to, destination)
if !ok { if !ok {
return Origin{}, false return Origin{}, false
@@ -168,3 +172,12 @@ func transferredOperationOrigin(operation inheritanceOperation, destination Path
operation.transfer.from, operation.transfer.to, *operation.transfer) operation.transfer.from, operation.transfer.to, *operation.transfer)
return transferred[destination.String()], true 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()) parentPolicyScope, replaced = lowerCollectionPoliciesForClaims(parentPolicyScope, claims.ordered, declarations, adapter.applicationPolicy())
} }
merged := inheritanceParentPayload(parent, node.identityFields) 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( mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
parentPolicyScope, childPolicyScope, node.path, parent, payload, adapter.applicationPolicy(), replaced, 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) resolvedPolicies[index] = inheritancePolicyScopeAtPath(mergedPolicyScope, node.path)
result.transfers = append(result.transfers, transfer) result.transfers = append(result.transfers, transfer)
result.operations = append(result.operations, inheritanceOperation{ result.operations = append(result.operations, inheritanceOperation{
transfer: &transfer, parent: parent, effective: merged, transfer: &transfer, parent: historyParent, effective: merged,
parentOrigins: resolvedOrigins[node.parent], effectiveOrigins: mergedOrigins, parentOrigins: resolvedOrigins[node.parent], effectiveOrigins: mergedOrigins,
parentPolicies: cloneInheritancePolicyTemplates(parentPolicies), parentPolicies: cloneInheritancePolicyTemplates(parentPolicies),
templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies), templatePolicies: cloneCollectionPolicyScopeForInheritance(templatePolicies),
effectivePolicies: cloneInheritancePolicyTemplates(effectivePolicies), effectivePolicies: cloneInheritancePolicyTemplates(effectivePolicies),
sequences: cloneSequenceHistoryContributions(sequenceContributions), sequences: cloneSequenceHistoryContributions(sequenceContributions),
identityFields: cloneStrings(node.identityFields),
}) })
resolvedPolicyTemplates[index] = effectivePolicies resolvedPolicyTemplates[index] = effectivePolicies
} else { } else {