Preserve child identity in inheritance history
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
87
internal/engine/inheritance_identity_history_test.go
Normal file
87
internal/engine/inheritance_identity_history_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user