From 1a673753b9770b529d3c8d691d3d76a0909e7d01 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 24 Oct 2026 01:17:00 +0000 Subject: [PATCH] Preserve child identity in inheritance history --- docs/roadmap/remediation.md | 10 ++- internal/engine/inheritance.go | 2 + internal/engine/inheritance_explain.go | 6 ++ internal/engine/inheritance_history.go | 15 +++- .../inheritance_identity_history_test.go | 87 +++++++++++++++++++ internal/engine/inheritance_schedule.go | 9 +- 6 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 internal/engine/inheritance_identity_history_test.go diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 59617c6..a1f0ee0 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -587,7 +587,15 @@ main records as a workaround. ## 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 [`applyInheritanceScopeTemplates`](../../internal/engine/inheritance_schedule.go), diff --git a/internal/engine/inheritance.go b/internal/engine/inheritance.go index d906177..4951999 100644 --- a/internal/engine/inheritance.go +++ b/internal/engine/inheritance.go @@ -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 } diff --git a/internal/engine/inheritance_explain.go b/internal/engine/inheritance_explain.go index 5fd5467..c978f04 100644 --- a/internal/engine/inheritance_explain.go +++ b/internal/engine/inheritance_explain.go @@ -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 { diff --git a/internal/engine/inheritance_history.go b/internal/engine/inheritance_history.go index 1fbbf90..fe311b4 100644 --- a/internal/engine/inheritance_history.go +++ b/internal/engine/inheritance_history.go @@ -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) +} diff --git a/internal/engine/inheritance_identity_history_test.go b/internal/engine/inheritance_identity_history_test.go new file mode 100644 index 0000000..a1e23d8 --- /dev/null +++ b/internal/engine/inheritance_identity_history_test.go @@ -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) + } + }) + } +} diff --git a/internal/engine/inheritance_schedule.go b/internal/engine/inheritance_schedule.go index b0b4a4d..a7460ac 100644 --- a/internal/engine/inheritance_schedule.go +++ b/internal/engine/inheritance_schedule.go @@ -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 {