Resolve inheritance between identified records

This commit is contained in:
2026-10-23 20:48:46 +00:00
parent 268fd03579
commit 33a51606da
10 changed files with 447 additions and 31 deletions

View File

@@ -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 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 `name` among its immediate siblings. Names are exact keys, including the empty
string; lookup does not search ancestor or unrelated scopes. The document root string; lookup does not search ancestor or unrelated scopes. A mapping inside a
and a sequence-element mapping have no sibling namespace for their own string sequence can also participate in record inheritance when that sequence has an
relationship, though mappings nested inside them can have one. An `extends` 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 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 types, incomplete or extra reference fields, missing parents, and cycles fail
diagnostics. the load with value-free diagnostics.
Within each sibling scope, direct mapping definitions resolve by dependency Within each mapping sibling scope or identified sequence, direct definitions
readiness. A parent is resolved before a child; among currently eligible resolve by dependency readiness. A parent is resolved before a child; among
definitions, the lexically smallest canonical semantic pointer is selected, currently eligible definitions, the lexically smallest canonical semantic
including newly eligible definitions as soon as their parent completes. This pointer is selected, including newly eligible definitions as soon as their
ordering governs inheritance operations and their history, controls, and parent completes. This ordering governs inheritance operations and their
transfers. It does not reorder the mappings in the resulting configuration. 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 `extends` is reserved structural input across ordinary and layered loads, every
source kind, imports, selected overlays, and explicit CLI overrides. It is source kind, imports, selected overlays, and explicit CLI overrides. It is
consumed before typed decoding and need not appear in the application's Go consumed before typed decoding and need not appear in the application's Go
types. Applications previously using `extends` as an ordinary YAML field must types. Applications previously using `extends` as an ordinary YAML field must
rename that key and its field. No collection registration or keyword setup is 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 ConfigKit completes strict composition within tiers and precedence assembly
between tiers before resolving relationships. An omitted `extends` retains a between tiers before resolving relationships. Only records that survive that
lower-priority relationship; `extends: null` cancels it while preserving the assembly participate in identified-sequence lookup: an upserted parent supplies
child's own settings. Superseded references are historical evidence, not active its final record, a replaced parent is unavailable, and a higher replacement
relationships to validate. Each child recursively overlays its own settings without `extends` supersedes a lower relationship. An omitted `extends` retains
over its resolved parent: explicit zero, false, empty string, and a lower-priority mapping-sibling relationship; an identified record's effective
null values replace inherited values, while mappings combine recursively. 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 Sequences at collection destinations use the shared collection operation with
the parent as lower input and the child as higher input. The destination's 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 explicit mode applies; otherwise a copied identity selects upsert and an
unkeyed destination replaces. A parent's explicit mode does not move to a unkeyed destination replaces. A parent's explicit mode does not move to a
different destination. Sequences outside collection destinations remain atomic different destination. Sequences outside collection destinations remain atomic
replacements. 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 entries the application may never select for execution. Partial parent
definitions undergo normal typed decoding and application-owned validation, definitions undergo normal typed decoding and application-owned validation,
not a special structural exemption. 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 invalid registration paths produce `options.invalid`. Unused and root registrations
are allowed and have no effect. are allowed and have no effect.
Registration changes strict composition only. Each underlay group and the main Registration controls strict composition and supplies identity fields for parent
tier has independent duplicate-identity state. Precedence assembly is unchanged: lookup when surviving records in that sequence use `extends`. Each underlay group
a present higher-priority sequence replaces the lower sequence as one value, and the main tier has independent duplicate-identity state. Precedence assembly
including at a registered path. Overlays, CLI overrides, defaults, inheritance, is unchanged: a present higher-priority sequence replaces the lower sequence as
and application resolution do not run the registered identity check. The accepted one value, including at a registered path. Registration does not add a final
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md). 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) The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
shows item path and glob imports, helper-supplied identity, and main-tier shows item path and glob imports, helper-supplied identity, and main-tier
replacement of a lower-priority sequence. replacement of a lower-priority sequence.

View File

@@ -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 | | 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 | | 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 | | 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 The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data, 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, keep the smaller adoption path visible: one explicitly required file import,
the same identity registration, and the same application validation. 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 The example's `validatePromptCatalog` remains application-owned. It checks that
prompt names and message lists satisfy this consumer's requirements after prompt names and message lists satisfy this consumer's requirements after
ConfigKit has composed, projected, and decoded the configuration. It does not ConfigKit has composed, inherited, projected, and decoded the configuration.
add recursive record merging, identity-based overlay, or final uniqueness checks 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), after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
[import syntax](../config.md#explicit-imports), and [import syntax](../config.md#explicit-imports), and
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact [ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact

View File

@@ -1,6 +1,6 @@
# Sequence Identity And Combination Implementation Plan # 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 Audience: a Luna coding agent implementing one numbered stage per prompt, in
order. This plan starts at commit 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, Validation: all inheritance and cross-feature inspection tests,
`go test -race ./internal/engine`, and the everyday check. `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 ## Stage 22 — Expose The Root-Owned Registration API
Scope: the settled facade surface and migration of in-repository callers. Scope: the settled facade surface and migration of in-repository callers.

View File

@@ -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) { func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{ filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")}, "config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},

View File

@@ -22,6 +22,18 @@ type identifiedInheritanceGraph struct {
byIdentity map[string]int 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 // analyzeIdentifiedInheritanceGraph validates relationships inside one
// identified sequence. It prepares a graph only; mapping-sibling expansion // identified sequence. It prepares a graph only; mapping-sibling expansion
// remains the active inheritance behavior until the reusable scope executor is // remains the active inheritance behavior until the reusable scope executor is

View File

@@ -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) { func TestIdentifiedInheritanceRequiresChildIdentityBeforeParentLookup(t *testing.T) {
sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child
extends: {id: parent, tenant: acme} extends: {id: parent, tenant: acme}

View File

@@ -220,7 +220,7 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies)) effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies))
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path) parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path)
childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path) childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path)
merged := document.Clone(parent) merged := inheritanceParentPayload(parent, node.identityFields)
mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies( mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
parentPolicyScope, childPolicyScope, node.path, parent, adapter.applicationPolicy(), parentPolicyScope, childPolicyScope, node.path, parent, adapter.applicationPolicy(),
) )

View File

@@ -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( func applyIdentifiedInheritanceTemplates(
graph *identifiedInheritanceGraph, graph *identifiedInheritanceGraph,
limits projectionLimits, limits projectionLimits,

View File

@@ -158,6 +158,11 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
} }
} }
case document.Sequence: case document.Sequence:
if sequenceHasIdentifiedInheritanceReferences(*node) {
if problem := w.expandIdentifiedSequence(node, path); problem != nil {
return problem
}
}
working := **node working := **node
working.Sequence = slices.Clone(working.Sequence) working.Sequence = slices.Clone(working.Sequence)
*node = &working *node = &working
@@ -171,6 +176,60 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
return nil 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 { func scopeHasInheritanceDefinitions(scope *document.Node) bool {
for _, entry := range scope.Mapping { for _, entry := range scope.Mapping {
if entry.Value.Kind != document.Mapping { if entry.Value.Kind != document.Mapping {

View File

@@ -18,7 +18,9 @@ type Definition[T any] struct {
// SequenceCompositions registers mapping paths whose sequence contributions // SequenceCompositions registers mapping paths whose sequence contributions
// strictly append distinct records by the selected literal identity field. // strictly append distinct records by the selected literal identity field.
// Invalid or conflicting registrations fail option validation. Repeated input // 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 // Registration paths are copied when a load begins; definitions are read-only
// after being shared. // after being shared.
SequenceCompositions []SequenceComposition SequenceCompositions []SequenceComposition
@@ -46,6 +48,7 @@ type SequenceComposition struct {
Path Path Path Path
// IdentityField is one literal mapping key and may be empty. Records must // IdentityField is one literal mapping key and may be empty. Records must
// contain a string at this key; malformed records fail strict composition. // contain a string at this key; malformed records fail strict composition.
// The field also identifies parents for record inheritance at this path.
IdentityField string IdentityField string
} }