From 33a51606da805e9ecb5cbe014e0d7e3319c196aa Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 23 Oct 2026 20:48:46 +0000 Subject: [PATCH] Resolve inheritance between identified records --- docs/config.md | 66 +++--- docs/consumers/README.md | 13 +- docs/roadmap/implementation.md | 7 +- facade_inheritance_test.go | 208 ++++++++++++++++++ internal/engine/inheritance_identified.go | 12 + .../engine/inheritance_identified_test.go | 87 ++++++++ internal/engine/inheritance_schedule.go | 2 +- internal/engine/inheritance_scope.go | 19 ++ internal/engine/inheritance_walk.go | 59 +++++ lifecycle.go | 5 +- 10 files changed, 447 insertions(+), 31 deletions(-) diff --git a/docs/config.md b/docs/config.md index 69d5196..a92ff24 100644 --- a/docs/config.md +++ b/docs/config.md @@ -222,41 +222,55 @@ is recorded in [ADR 0011](adr/0011-input-destinations.md) and Any input mapping may declare `extends: name` to inherit from the mapping named `name` among its immediate siblings. Names are exact keys, including the empty -string; lookup does not search ancestor or unrelated scopes. The document root -and a sequence-element mapping have no sibling namespace for their own string -relationship, though mappings nested inside them can have one. An `extends` +string; lookup does not search ancestor or unrelated scopes. A mapping inside a +sequence can also participate in record inheritance when that sequence has an +effective identity declaration from `configkit.sequences` or an application +registration. In that case, `extends` is a mapping containing exactly the +sequence's identity fields, with scalar values; lookup is limited to that final +sequence. The document root and a sequence-element mapping without an effective +identity declaration have no sibling namespace for non-null string +relationships, though mappings nested inside them can have one. An `extends` value of null cancels a relationship without looking up a parent. Other value -types, missing or non-mapping parents, and cycles fail the load with value-free -diagnostics. +types, incomplete or extra reference fields, missing parents, and cycles fail +the load with value-free diagnostics. -Within each sibling scope, direct mapping definitions resolve by dependency -readiness. A parent is resolved before a child; among currently eligible -definitions, the lexically smallest canonical semantic pointer is selected, -including newly eligible definitions as soon as their parent completes. This -ordering governs inheritance operations and their history, controls, and -transfers. It does not reorder the mappings in the resulting configuration. +Within each mapping sibling scope or identified sequence, direct definitions +resolve by dependency readiness. A parent is resolved before a child; among +currently eligible definitions, the lexically smallest canonical semantic +pointer is selected, including newly eligible definitions as soon as their +parent completes. This ordering governs inheritance operations and their +history, controls, and transfers. It does not reorder mappings or sequence +records in the resulting configuration. `extends` is reserved structural input across ordinary and layered loads, every source kind, imports, selected overlays, and explicit CLI overrides. It is consumed before typed decoding and need not appear in the application's Go types. Applications previously using `extends` as an ordinary YAML field must rename that key and its field. No collection registration or keyword setup is -required. +required for mapping-sibling relationships; identified-sequence relationships +require effective identity fields for that collection. ConfigKit completes strict composition within tiers and precedence assembly -between tiers before resolving relationships. An omitted `extends` retains a -lower-priority relationship; `extends: null` cancels it while preserving the -child's own settings. Superseded references are historical evidence, not active -relationships to validate. Each child recursively overlays its own settings -over its resolved parent: explicit zero, false, empty string, and -null values replace inherited values, while mappings combine recursively. +between tiers before resolving relationships. Only records that survive that +assembly participate in identified-sequence lookup: an upserted parent supplies +its final record, a replaced parent is unavailable, and a higher replacement +without `extends` supersedes a lower relationship. An omitted `extends` retains +a lower-priority mapping-sibling relationship; an identified record's effective +directive depends on the record that survives assembly. `extends: null` cancels +a relationship while preserving the child's own settings. Superseded references +are historical evidence, not active relationships to validate. Each child +recursively overlays its own settings over its resolved parent: explicit zero, +false, empty string, and +null values replace inherited values, while mappings combine recursively. For +identified records, the child's identity fields and their origins remain its +own; parent identity fields do not contribute to the payload. Sequences at collection destinations use the shared collection operation with the parent as lower input and the child as higher input. The destination's explicit mode applies; otherwise a copied identity selects upsert and an unkeyed destination replaces. A parent's explicit mode does not move to a different destination. Sequences outside collection destinations remain atomic replacements. -The child retains its key. All effective relationships resolve, including +All effective relationships resolve, including entries the application may never select for execution. Partial parent definitions undergo normal typed decoding and application-owned validation, not a special structural exemption. @@ -822,12 +836,14 @@ Identical registrations coalesce; conflicting registrations at one path and invalid registration paths produce `options.invalid`. Unused and root registrations are allowed and have no effect. -Registration changes strict composition only. Each underlay group and the main -tier has independent duplicate-identity state. Precedence assembly is unchanged: -a present higher-priority sequence replaces the lower sequence as one value, -including at a registered path. Overlays, CLI overrides, defaults, inheritance, -and application resolution do not run the registered identity check. The accepted -rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md). +Registration controls strict composition and supplies identity fields for parent +lookup when surviving records in that sequence use `extends`. Each underlay group +and the main tier has independent duplicate-identity state. Precedence assembly +is unchanged: a present higher-priority sequence replaces the lower sequence as +one value, including at a registered path. Registration does not add a final +identity check to defaults, application resolution, or the published typed +configuration. The accepted rationale is recorded in +[ADR 0014](adr/0014-sequence-placement-and-composition.md). The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences) shows item path and glob imports, helper-supplied identity, and main-tier replacement of a lower-priority sequence. diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 5a79f90..ff7f325 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -21,7 +21,7 @@ sanitized shapes using only ConfigKit and the Go standard library: | Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset | | Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation | | Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation | -| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation | +| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, same-sequence record inheritance, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation | The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the executable examples and ordinary tests. It contains no private application data, @@ -206,10 +206,17 @@ are complete local examples. keep the smaller adoption path visible: one explicitly required file import, the same identity registration, and the same application validation. +The registered `id` also identifies surviving prompt records that use +`extends: {id: "base-id"}`. ConfigKit resolves these references within the +completed prompt sequence after tier assembly, preserves the child's own ID, and +records inherited values at the child's paths. The +[configuration contract](../config.md#sibling-inheritance) defines the exact +reference and nested collection behavior. + The example's `validatePromptCatalog` remains application-owned. It checks that prompt names and message lists satisfy this consumer's requirements after -ConfigKit has composed, projected, and decoded the configuration. It does not -add recursive record merging, identity-based overlay, or final uniqueness checks +ConfigKit has composed, inherited, projected, and decoded the configuration. +ConfigKit does not apply identity-based overlays or enforce final uniqueness after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition), [import syntax](../config.md#explicit-imports), and [ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 41252f0..5bf6ea6 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–20 complete. +Status: implementation in progress; Stages 1–21 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -732,6 +732,11 @@ expansion phase, with values and inspection working together. Validation: all inheritance and cross-feature inspection tests, `go test -race ./internal/engine`, and the everyday check. +**Completion:** Activated identified-record inheritance after tier assembly, +including nested collection relationships, ownership and inspection evidence. +Verified inheritance/inspection regressions, `go test -race ./internal/engine`, +and `./scripts/check.sh`. + ## Stage 22 — Expose The Root-Owned Registration API Scope: the settled facade surface and migration of in-repository callers. diff --git a/facade_inheritance_test.go b/facade_inheritance_test.go index 1e4510e..8647b8e 100644 --- a/facade_inheritance_test.go +++ b/facade_inheritance_test.go @@ -91,6 +91,214 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) { } } +func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testing.T) { + const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET" + const canary = "IDENTIFIED-INHERITANCE-SECRET-CANARY" + t.Setenv(secretName, canary) + type step struct { + ID string `yaml:"id"` + Value string `yaml:"value"` + } + type job struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Credential configkit.Secret `yaml:"credential"` + Resource string `yaml:"resource"` + Steps []step `yaml:"steps"` + Settings map[string]map[string]string `yaml:"settings"` + } + type configuration struct { + Jobs []job `yaml:"jobs"` + } + input := []byte(`jobs: +- id: child + extends: {id: parent} + name: child-name +- id: parent + extends: {id: base} +- id: base + name: base-name + credential: CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET + resource: icon.txt + steps: + - id: base-step + value: base-step-value + - id: second-step + value: second-step-value + settings: + primary: {value: base-setting} + secondary: {extends: primary} +`) + filesystem := configkit.NewFilesystem("identified-bundle", fstest.MapFS{ + "config/identified.yml": &fstest.MapFile{Data: input}, + "config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")}, + }) + registrations := []configkit.SequenceComposition{ + {Path: configkit.Keys("jobs"), IdentityField: "id"}, + } + result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{ + Definition: configkit.Definition[configuration]{ + SequenceCompositions: registrations, + Secret: func(path configkit.Path) bool { + return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential" + }, + }, + }) + if err != nil { + if loadError, ok := err.(*configkit.LoadError); ok { + t.Fatalf("load identified inheritance: %v (%#v)", err, loadError.Diagnostics()) + } + t.Fatal(err) + } + if len(result.Config.Jobs) != 3 || result.Config.Jobs[0].ID != "child" || result.Config.Jobs[1].ID != "parent" || result.Config.Jobs[2].ID != "base" { + t.Fatalf("inherited job positions and identities = %#v", result.Config.Jobs) + } + child := result.Config.Jobs[0] + if child.Name != "child-name" || child.Credential.Value() != canary || child.Resource != "icon.txt" || len(child.Steps) != 2 { + t.Fatalf("inherited child = %#v", child) + } + if got := []string{child.Steps[0].ID, child.Steps[1].ID}; strings.Join(got, ",") != "base-step,second-step" { + t.Fatalf("inherited nested steps = %v", got) + } + if child.Steps[0].Value != "base-step-value" || child.Steps[1].Value != "second-step-value" || child.Settings["secondary"]["value"] != "base-setting" { + t.Fatalf("inherited nested collection and mapping = %#v", child) + } + credentialPath := configkit.Keys("jobs").Index(0).Key("credential") + credentialInfo, found := result.Metadata().Lookup(credentialPath) + if !found || len(credentialInfo.Origin.Inheritance) != 2 { + t.Fatalf("inherited secret provenance = %#v, %v", credentialInfo, found) + } + resourceInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("resource")) + if !found || len(resourceInfo.Origin.Inheritance) != 2 || len(resourceInfo.Origin.Locations) != 1 { + t.Fatalf("inherited resource provenance = %#v, %v", resourceInfo, found) + } + context, found := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID) + if !found || context.BaseDir != "config" || context.FS == nil { + t.Fatalf("inherited resource context = %#v, %v", context, found) + } + resource, readErr := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource) + if readErr != nil || string(resource) != "identified-parent-resource" { + t.Fatalf("inherited resource = %q, %v", resource, readErr) + } + resourceHistory, found := result.Explain(configkit.Keys("jobs").Index(0).Key("resource")) + if !found || len(resourceHistory.History) == 0 { + t.Fatalf("inherited resource history = %#v, %v", resourceHistory, found) + } + credentialHistory, found := result.Explain(credentialPath) + if !found || !credentialHistory.Effective.Redacted { + t.Fatalf("inherited secret explanation = %#v, %v", credentialHistory, found) + } + identityInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("id")) + if !found || !identityInfo.Explicit || len(identityInfo.Origin.Inheritance) != 0 || + len(identityInfo.Origin.Locations) != 1 { + t.Fatalf("child identity origin and presence = %#v, %v", identityInfo, found) + } + if _, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("extends")); found { + t.Fatal("identified record relationship remained in effective metadata") + } + var rendered bytes.Buffer + if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || strings.Contains(rendered.String(), "extends:") { + t.Fatalf("effective inherited configuration = %q, %v", rendered.String(), err) + } +} + +func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.T) { + const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET" + const canary = "IDENTIFIED-INHERITANCE-FAILURE-CANARY" + t.Setenv(secretName, canary) + type job struct { + ID string `yaml:"id"` + Credential configkit.Secret `yaml:"credential"` + } + type configuration struct { + Jobs []job `yaml:"jobs"` + } + input := []byte(`jobs: +- id: child + extends: {id: missing} + credential: CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET +`) + _, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{ + Definition: configkit.Definition[configuration]{ + SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("jobs"), IdentityField: "id"}}, + Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" }, + }, + }) + if err == nil || strings.Contains(err.Error(), canary) { + t.Fatalf("identified inheritance failure = %v; secret canary must remain undisclosed", err) + } +} + +func TestFacadeLayeredIdentifiedInheritanceUsesSurvivingRecords(t *testing.T) { + policy := func(mode string) string { + return "configkit:\n sequences:\n - destination: [jobs]\n key: id\n mode: " + mode + "\n" + } + tests := []struct { + name string + lower string + higher string + wantCode configkit.Code + wantValue string + wantSuccess bool + }{ + { + name: "upserted parent supplies its final payload", + lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n", + higher: policy("upsert") + "jobs: [{id: parent, value: higher}, {id: child, extends: {id: parent}}]\n", + wantValue: "higher", wantSuccess: true, + }, + { + name: "replacement removes a lower parent before lookup", + lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n", + higher: policy("replace") + "jobs: [{id: child, extends: {id: parent}}]\n", + wantCode: configkit.CodeInheritanceMissing, + }, + { + name: "upserted child without a directive replaces the invalid lower child", + lower: policy("upsert") + "jobs: [{id: child, extends: {id: missing}, value: lower}]\n", + higher: policy("upsert") + "jobs: [{id: child, value: higher}]\n", + wantValue: "higher", wantSuccess: true, + }, + { + name: "self-reference cannot resolve through a lower predecessor", + lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n", + higher: policy("upsert") + "jobs: [{id: parent, extends: {id: parent}, value: higher}]\n", + wantCode: configkit.CodeInheritanceCycle, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main.yml", []byte(test.higher))}, configkit.LayeredOptions[map[string]any]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower.yml", []byte(test.lower))}}}}, + }) + if test.wantSuccess { + if err != nil { + t.Fatal(err) + } + jobs, ok := result.Config["jobs"].([]any) + if !ok || len(jobs) == 0 { + t.Fatalf("assembled jobs = %#v", result.Config["jobs"]) + } + child, ok := jobs[len(jobs)-1].(map[string]any) + if !ok || child["value"] != test.wantValue { + t.Fatalf("surviving record = %#v, want inherited value %q", jobs[len(jobs)-1], test.wantValue) + } + return + } + loadError, ok := err.(*configkit.LoadError) + if !ok { + t.Fatalf("load error = %v, want code %s", err, test.wantCode) + } + for _, diagnostic := range loadError.Diagnostics() { + if diagnostic.Code == test.wantCode { + return + } + } + t.Fatalf("load diagnostics = %#v, want code %s", loadError.Diagnostics(), test.wantCode) + }) + } +} + func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) { filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{ "config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")}, diff --git a/internal/engine/inheritance_identified.go b/internal/engine/inheritance_identified.go index 8c3f1b7..4357428 100644 --- a/internal/engine/inheritance_identified.go +++ b/internal/engine/inheritance_identified.go @@ -22,6 +22,18 @@ type identifiedInheritanceGraph struct { byIdentity map[string]int } +func sequenceHasIdentifiedInheritanceReferences(sequence *document.Node) bool { + if sequence == nil || sequence.Kind != document.Sequence { + return false + } + for _, item := range sequence.Sequence { + if directive, found := inheritanceDirective(item); found && directive.Value.Kind != document.Null { + return true + } + } + return false +} + // analyzeIdentifiedInheritanceGraph validates relationships inside one // identified sequence. It prepares a graph only; mapping-sibling expansion // remains the active inheritance behavior until the reusable scope executor is diff --git a/internal/engine/inheritance_identified_test.go b/internal/engine/inheritance_identified_test.go index 520f3a1..c62b6d6 100644 --- a/internal/engine/inheritance_identified_test.go +++ b/internal/engine/inheritance_identified_test.go @@ -147,6 +147,93 @@ func TestIdentifiedInheritanceScopeAdapterReusesTemplateExecution(t *testing.T) } } +func TestIdentifiedInheritanceResolvesNestedCollectionsAtEachDestination(t *testing.T) { + root := parseItemPolicyDocument(t, "identified-nested", `jobs: + - id: child + extends: {id: parent} + - id: parent + extends: {id: base} + steps: + - id: child-step + extends: {id: parent-step} + value: child-value + - id: parent-step + value: parent-value + - id: base + steps: + - id: base-step + value: base-value +`) + identity := collectionPolicy{identityFields: []string{"id"}, identityPresent: true} + policies := collectionPolicyScope{} + policies.set(Keys("jobs"), identity) + for index := 0; index < 3; index++ { + policies.set(Keys("jobs").Index(index).Key("steps"), identity) + } + expanded, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits()) + if len(diagnostics) != 0 { + t.Fatalf("expand nested identified inheritance: %#v", diagnostics) + } + steps, found := documentAtPath(expanded.root, Keys("jobs").Index(0).Key("steps")) + if !found || steps.Kind != document.Sequence || len(steps.Sequence) != 3 { + t.Fatalf("child nested steps = %#v, found %t", steps, found) + } + for index, want := range []string{"base-step", "child-step", "parent-step"} { + id, exists := documentAtPath(steps.Sequence[index], Keys("id")) + if !exists || id.Text != want { + t.Fatalf("child step %d identity = %#v, found %t; want %q", index, id, exists, want) + } + } + value, found := documentAtPath(steps.Sequence[1], Keys("value")) + if !found || value.Text != "child-value" { + t.Fatalf("nested record inheritance = %#v, found %t", value, found) + } + if containsInheritanceDirective(expanded.root) { + t.Fatal("a nested record relationship remained after destination-local expansion") + } + childIdentity, found := expanded.origins["/jobs/0/id"] + if !found || len(childIdentity.Locations) == 0 || childIdentity.Locations[0].SourceID != "identified-nested" { + t.Fatalf("child identity origin = %#v, found %t", childIdentity, found) + } + if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found { + t.Fatal("nested inherited value did not retain its destination origin") + } + for _, replacement := range expanded.replacements { + if replacement.path.String() == "/jobs/0/id" { + t.Fatal("parent identity was treated as payload replaced by the child's identity") + } + } +} + +func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) { + root := parseItemPolicyDocument(t, "identified-budget", `jobs: + - id: child + extends: {id: parent} + - id: parent + value: inherited +`) + policies := collectionPolicyScope{} + policies.set(Keys("jobs"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true}) + baseline, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits()) + if len(diagnostics) != 0 { + t.Fatalf("unbounded identified inheritance: %#v", diagnostics) + } + outputNodes, within := inheritanceLowerBoundCount(baseline.root, -1) + if !within || outputNodes < 2 { + t.Fatalf("identified output node count = %d, within %t", outputNodes, within) + } + limits := defaultProjectionLimits() + limits.nodes = outputNodes + if result, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, limits); result == nil || len(diagnostics) != 0 { + t.Fatalf("at output-node boundary: result = %#v, diagnostics = %#v", result, diagnostics) + } + limits.nodes = outputNodes - 1 + if result, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, limits); result != nil || + len(diagnostics) != 1 || diagnostics[0].Code != CodeSchemaProjectionLimit { + t.Fatalf("below output-node boundary: result = %#v, diagnostics = %#v", result, diagnostics) + } +} + func TestIdentifiedInheritanceRequiresChildIdentityBeforeParentLookup(t *testing.T) { sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child extends: {id: parent, tenant: acme} diff --git a/internal/engine/inheritance_schedule.go b/internal/engine/inheritance_schedule.go index fbc18ac..1ec147a 100644 --- a/internal/engine/inheritance_schedule.go +++ b/internal/engine/inheritance_schedule.go @@ -220,7 +220,7 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies)) parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path) childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path) - merged := document.Clone(parent) + merged := inheritanceParentPayload(parent, node.identityFields) mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies( parentPolicyScope, childPolicyScope, node.path, parent, adapter.applicationPolicy(), ) diff --git a/internal/engine/inheritance_scope.go b/internal/engine/inheritance_scope.go index f98a201..3d535c5 100644 --- a/internal/engine/inheritance_scope.go +++ b/internal/engine/inheritance_scope.go @@ -159,6 +159,25 @@ func preserveInheritanceIdentityFields(merged, child *document.Node, fields []st } } +func inheritanceParentPayload(parent *document.Node, identityFields []string) *document.Node { + cloned := document.Clone(parent) + if len(identityFields) == 0 || cloned == nil || cloned.Kind != document.Mapping { + return cloned + } + excluded := make(map[string]struct{}, len(identityFields)) + for _, field := range identityFields { + excluded[field] = struct{}{} + } + kept := cloned.Mapping[:0] + for _, entry := range cloned.Mapping { + if _, omit := excluded[entry.Key]; !omit { + kept = append(kept, entry) + } + } + cloned.Mapping = kept + return cloned +} + func applyIdentifiedInheritanceTemplates( graph *identifiedInheritanceGraph, limits projectionLimits, diff --git a/internal/engine/inheritance_walk.go b/internal/engine/inheritance_walk.go index 21dd119..312e56b 100644 --- a/internal/engine/inheritance_walk.go +++ b/internal/engine/inheritance_walk.go @@ -158,6 +158,11 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool } } case document.Sequence: + if sequenceHasIdentifiedInheritanceReferences(*node) { + if problem := w.expandIdentifiedSequence(node, path); problem != nil { + return problem + } + } working := **node working.Sequence = slices.Clone(working.Sequence) *node = &working @@ -171,6 +176,60 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool return nil } +func (w *inheritanceWalker) expandIdentifiedSequence(node **document.Node, path Path) *Diagnostic { + policy, hasPolicy := w.collectionPolicies.at(path) + applicationFields, _ := w.applicationPolicy.identityFieldsAt(path) + var lower *collectionPolicy + if hasPolicy { + lower = &policy + } + resolution, policyDiagnostics := resolveCollectionPolicy(path, nil, applicationFields, lower) + if len(policyDiagnostics) != 0 { + return &policyDiagnostics[0] + } + // Without an identity rule, retain the existing diagnostic for a reference + // that has no enclosing sibling scope. A null cancellation needs no lookup. + if len(resolution.effectiveIdentityFields) == 0 { + return nil + } + original := *node + graph, diagnostics := analyzeIdentifiedInheritanceGraph(*node, path, resolution.effectiveIdentityFields) + if len(diagnostics) != 0 { + return &diagnostics[0] + } + before, _ := inheritanceLowerBoundCount(*node, -1) + limits := w.limits + if limits.nodes >= 0 { + limits.nodes -= w.nodes - before + } + resolved, diagnostics := applyIdentifiedInheritanceTemplates( + graph, limits, w.origins, w.collectionPolicies, w.applicationPolicy, w.retained, + ) + if len(diagnostics) != 0 { + return &diagnostics[0] + } + *node = resolved.root + for pointer, entry := range w.collectionPolicies.collections { + if pathHasPrefix(entry.destination, path) { + delete(w.collectionPolicies.collections, pointer) + } + } + for _, entry := range resolved.collectionPolicies.collections { + w.collectionPolicies.set(entry.destination, entry.policy) + } + deleteOriginSubtree(w.origins, path, original) + for pointer, origin := range resolved.origins { + w.origins[pointer] = origin + } + w.transfers = append(w.transfers, resolved.transfers...) + w.controls = append(w.controls, resolved.controls...) + w.replacements = append(w.replacements, resolved.replacements...) + w.operations = append(w.operations, resolved.operations...) + after, _ := inheritanceLowerBoundCount(*node, -1) + w.nodes += after - before + return nil +} + func scopeHasInheritanceDefinitions(scope *document.Node) bool { for _, entry := range scope.Mapping { if entry.Value.Kind != document.Mapping { diff --git a/lifecycle.go b/lifecycle.go index 19c8db1..c7d3578 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -18,7 +18,9 @@ type Definition[T any] struct { // SequenceCompositions registers mapping paths whose sequence contributions // strictly append distinct records by the selected literal identity field. // Invalid or conflicting registrations fail option validation. Repeated input - // identities fail strict composition after source and helper processing. + // identities fail strict composition after source and helper processing. The + // field also supplies identity for parent lookup when surviving records at + // that path use record-inheritance directives. // Registration paths are copied when a load begins; definitions are read-only // after being shared. SequenceCompositions []SequenceComposition @@ -46,6 +48,7 @@ type SequenceComposition struct { Path Path // IdentityField is one literal mapping key and may be empty. Records must // contain a string at this key; malformed records fail strict composition. + // The field also identifies parents for record inheritance at this path. IdentityField string }