diff --git a/README.md b/README.md index 6af5c09..3b65e80 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ tags and a path predicate drive deterministic redacted effective YAML. Typed sec references resolve environment names before validation, and explicit secrets-directory bootstrap is available as a separate startup operation. Explicit CLI override records apply above overlays. Value explanation is available through -detached structured records and deterministic redacted output. +detached structured records and deterministic redacted output. Built-in +`extends` relationships let a mapping inherit from a named sibling during the +same load, retaining explicit presence and source/derivation history. ```go type Config struct { @@ -64,7 +66,7 @@ model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusi The [configuration contract](docs/config.md) describes implemented behavior. The [consumer adoption guide](docs/consumers/README.md) provides maintained simple -native-filesystem and layered examples alongside the migration shapes. +native-filesystem, layered, and sibling-inheritance examples alongside the migration shapes. See the [development guide](docs/development.md) for repository orientation and the [release procedure](docs/release.md) for maintainer-only candidate and publication steps. diff --git a/docs/adr/0009-sibling-inheritance.md b/docs/adr/0009-sibling-inheritance.md new file mode 100644 index 0000000..8f3240f --- /dev/null +++ b/docs/adr/0009-sibling-inheritance.md @@ -0,0 +1,76 @@ +# ADR 0009: Resolve Sibling Inheritance After Source Assembly + +## Status + +Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance) +owns current behavior; the [implementation plan](../roadmap/implementation.md) +records the delivery sequence. + +## Date + +2026-09-29 + +## Context + +Named configuration mappings often share settings while retaining individual +overrides. ConfigKit already distinguishes strict composition of independent +source contributions from recursive overlay between explicit precedence tiers. +Doing another untracked merge in applications would lose omission, source +context, and historical explanation information. A shared structural operation +must preserve the same presence and provenance model as the rest of loading. + +Inheritance also interacts with source precedence, nested namespaces, version +ownership, and secrets. Resolving it before source assembly would validate +superseded references and copy values that a later tier should replace. Fully +resolving nested relationships inside a reusable parent would bind them to the +wrong sibling scope when copied into a child. + +## Decision + +Reserve `extends` as a built-in input key on mappings. A string names one exact +sibling mapping; null cancels the relationship. Complete source acquisition, +strict composition, selected overlay, and CLI precedence first. Then resolve +each effective relationship in the parser-independent document tree, before +typed defaults and decoding. A child recursively overlays its own payload on +its resolved parent using the existing mapping-combination rules. This creates +neither a source tier nor a configurable merge strategy. + +Resolve enclosing sibling chains before nested relationships. Reuse memoized +parent templates with unresolved nested instructions; evaluate those instructions +at each destination. Preserve original source applications separately from +structural transfer/control evidence. Effective origins carry concrete +source-to-destination derivation, and inspection projects relevant inheritance +events between source replay and typed reconciliation. Inherited input counts as +explicit presence, while direct replacement retains its own effective origin. + +The existing main-input version check remains at its original point. Reject +structural contributions or directive consumption affecting its protected path. +Classify sensitivity at corresponding source and destination paths and retain +historical redaction. Secret references remain unresolved input until the +existing lookup point. Keep all machinery behind the root-owned public facade. + +## Alternatives considered + +- **Application-owned inheritance after `Load`.** Leaves every consumer to + recreate ordering, presence, provenance, error reporting, and redaction after + ConfigKit has already declared a result complete. +- **Resolve each source before precedence assembly.** Makes superseded + references active and can copy a parent definition before higher-priority + input finishes changing it. +- **Resolve nested relationships in a cached parent subtree.** Shares more + ready-made output but binds nested sibling lookups to the parent's scope, + giving incorrect values at a child destination. +- **Registered collections, configurable keywords, or multiple-parent merge + modes.** Increase public setup and semantic branches without a requirement + that warrants diverging from one structural rule. + +## Consequences + +Consumers can load a configuration once and inspect direct, inherited, +defaulted, and application-derived values through the existing result surfaces. +`extends` is no longer an ordinary application field, so existing configurations +using that key as data must migrate. Expanding many children can materialize +substantially more output than the input contains; projection and retained-history +budgets bound that work without charging copies as newly acquired sources. +Applications still own domain validation, entry selection, and runtime resource +use. diff --git a/docs/config.md b/docs/config.md index 365d49d..c4cd2ec 100644 --- a/docs/config.md +++ b/docs/config.md @@ -54,7 +54,8 @@ The current lifecycle is: ```text validate options → parse the selected source → traverse/compose enabled imports → build schema → check the optional application version → apply a selected overlay - → apply explicit CLI overrides → create and clone defaults → strictly decode + → apply explicit CLI overrides → expand sibling inheritance + → create and clone defaults → strictly decode → recorded application resolution → resolve typed secret references → semantic validation → classify secrets → snapshot configuration and metadata @@ -152,6 +153,57 @@ The maintained [layered consumer example](consumers/README.md#native-filesystem- shows a main host file, two underlay tiers, embedded defaults, a mapping source, and explicit resource access without a second configuration pass. +### Sibling inheritance + +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` +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. + +`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. + +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, sequence, and +null values replace inherited values, while mappings combine recursively. +The child retains its key. 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. + +Enclosing relationships resolve first. Nested instructions carried by a parent +are then resolved separately at each destination, so a child's nested sibling +override can change an inherited nested relationship. Typed defaults and +application-derived values do not start another inheritance pass. Inherited +input counts as explicit presence for `Metadata.IsExplicit`, +`Resolution.IsExplicit`, and absence-based resolver defaults, even for false, +zero, empty, or null input. `Origin.Inheritance` distinguishes it from a direct +declaration with concrete source, destination, and controlling directive paths. +A direct child replacement has its own effective origin; displaced ancestry +remains inspectable in history. An inherited relative resource keeps the +declaring source's passive `SourceContext`, not the child's directory. + +The optional schema-version checker still runs once against the main input at +its original point. Inheritance cannot add, replace, or consume the protected +version path; an unrelated relationship inside an enclosing mapping does not +invalidate an unchanged version. Expansion uses the decoded projection +node/depth limits without charging copied values as acquired sources; +retained evidence uses the existing history-node limit. See the +[consumer example](consumers/README.md#named-sibling-inheritance) for one-load +usage. + ### Source resource contexts `Result.SourceContext(id)` and callback-scoped `Resolution.SourceContext(id)` @@ -222,8 +274,9 @@ successful result. Callback panics remain programming failures. `Result.Metadata` describes every final canonical path. Queries retain explicit key/index distinctions using actual effective and historical container shapes; only parsed numeric tokens are resolved contextually. A descendant that never -existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths -are explicit and have a base or overlay origin; omitted paths have a default origin. +existed is not historical merely because an atomic ancestor was replaced. Exact +surviving direct or inherited input paths are explicit and retain their source +layer and derivation; omitted paths have a default origin. An explicit parent mapping does not make omitted children explicit. Null state and available source coordinates are retained. `History` records the Go-zero or application default baseline followed by exact base or overlay definitions, including @@ -238,8 +291,9 @@ domain ownership rules remain application responsibilities. The method's Go documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering details. -History is chronological: a path can have a synthetic default record, its exact base -definition, an overlay replacement, and a recorded resolution operation. A descendant +History is chronological: a path can have a synthetic default record, its exact +base definition, an overlay replacement, structural inheritance, and a recorded +resolution operation. A descendant query uses the ancestor's path for a whole-container or whole-sequence replacement; the descendant can therefore retain history after `Lookup` stops reporting it as effective. Equal-valued input is still an explicit base definition, while a derived @@ -255,6 +309,15 @@ operation. Reasons and dependency paths retain first occurrence order with dupli removed. Source records remain ordered as selected source, depth-first imports, the selected overlay, then synthetic CLI records in supplied order. +Inheritance adds no acquired source or precedence layer. `Origin.Inheritance` +lists concrete transfers from contributor paths through destinations, with +each step's controlling `extends` path and location. `History` retains source +applications at their original paths and projects parent contributions, child +replacements, and consumed controls at destination paths after source assembly +and before typed decoding. `HistoricalPaths` includes consumed directives and +displaced inherited descendants. A control can have history even when `Lookup` +reports it absent. Queries return detached records. + ### Secret classification and effective rendering Secret classification combines `configkit:"secret"` field tags with the optional @@ -270,6 +333,11 @@ Propagation reaches a fixed point, so record/map iteration order does not affect result and dependency cycles terminate. A dependency on a classified descendant or on a container that contains one is treated conservatively as sensitive. Applications must classify secret-producing transformations whose dependencies are not recorded. +Inherited value and control paths link source and destination classifications +through chains and nested copies; unrelated sibling fields are not classified +merely because another field is secret. Displaced historical evidence remains +redacted. Secret references are copied only as unresolved names; credential +lookup stays after decoding and application resolution. `Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted YAML. Mappings sort by decoded key string, sequences retain order, indentation is two @@ -304,7 +372,10 @@ parser values. `Present=false` with empty YAML means absence or deletion; explic null is present and renders as `null`. The history starts with the canonical typed application-default or Go-zero baseline, then includes only relevant base sources in traversal order, the selected overlay, supplied CLI records, and audited net -resolution operations. Overlapping parent/child resolution records are rendered +resolution operations. Structural inheritance events appear after source replay +and before typed reconciliation, including consumed or canceled `extends` input +and equal-valued child replacements. Overlapping parent/child resolution records +are rendered as one net subtree event with combined reasons and dependencies, rather than pretending that the recorded assignments ran in lexical order. Explicit equal writes remain visible. When input replay @@ -556,8 +627,8 @@ input document. `[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name `default` select no overlay, and `overlays/default.yml` is never loaded implicitly. Every other name resolves only to `/overlays/.yml`, with -no extension fallback, case normalization, inheritance, or overlay stack. A selected -missing overlay is an error. +no extension fallback, case normalization, inheritance between overlay files, +or overlay stack. A selected missing overlay is an error. The overlay is a separately policy-validated YAML mapping. It is loaded after the base import graph is composed and its schema version is checked, but before defaults diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 265b587..40aa957 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -17,6 +17,7 @@ sanitized shapes using only ConfigKit and the Go standard library: | Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file | | Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem | | Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource | +| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset | The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the executable examples and ordinary tests. It contains no private application data, @@ -50,6 +51,22 @@ both without redefining their syntax. ConfigKit overlays are configuration overl they do not select or replace an application's LLM, model, pipeline, report, or execution profile concepts. +## Named sibling inheritance + +[`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained +[`presets.yml`](../../examples/consumers/testdata/inheritance/presets.yml) in one +call. `custom` inherits `enabled: false` from `base`, while its nested +`settings.primary` changes the inherited `settings.secondary` result at the +custom destination. The application struct has no `extends` field; ConfigKit +consumes the relationship before strict typed decoding. + +The [executable example](../../examples/consumers/consumers_test.go) reads +`Metadata.IsExplicit` for the inherited false value, then follows +`Origin.Inheritance` back to the parent path while `Origin.Locations` identifies +the declaring source. Applications still choose which preset to execute and +validate domain meaning. The [configuration contract](../config.md#sibling-inheritance) +owns exact lookup, precedence, cancellation, and migration rules. + ## Native filesystem and layered input [`LoadBundled`](../../examples/consumers/layered.go) keeps the simple `Load` shape: @@ -150,6 +167,7 @@ focused behavioral owner: | Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests | | `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` | | Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests | +| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` | | Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests | The package-level import-boundary test covers the consumer examples too: supported diff --git a/docs/development.md b/docs/development.md index 674c2d6..4e0a678 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,8 +83,9 @@ and current facade ownership belongs to the [component overview](internal/overvi The completed filesystem-source and underlay roadmap, original feature request, implementation plan, and resolved remediation record are preserved in Git history at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the -[configuration contract](config.md#layered-loading). Named-entry inheritance is -separate proposed work in its [roadmap](roadmap/named-inheritance.md). +[configuration contract](config.md#layered-loading). Built-in sibling +inheritance is now part of the [configuration contract](config.md#sibling-inheritance); +its [roadmap](roadmap/named-inheritance.md) preserves the design background. The completed overlay rename plan and release-workflow simplification record are preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay @@ -141,6 +142,7 @@ code changes. Use the component overview to find the corresponding implementatio | File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` | | Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` | | Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` | +| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` | | Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` | | Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` | | Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` | @@ -149,6 +151,7 @@ code changes. Use the component overview to find the corresponding implementatio | Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` | | Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` | | Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` | +| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` | | Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` | | Development and release checker behavior | [Shell regression](../release_check_test.go) | `go test . -run 'TestCheckScripts\|TestDevelopmentCheck'` | | Guarded release publication and recovery | [Publication regression](../release_publish_test.go) | `go test . -run '^TestReleasePublication'` | diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 3a900d5..73c8e82 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -8,8 +8,9 @@ private state; public signatures and fields do not expose engine types. Implemented component owners: - `internal/engine` owns host and native-filesystem source acquisition, - grouped strict composition and recursive precedence assembly, schema - inspection, decoding, lifecycle coordination, callback auditing, + grouped strict composition, recursive precedence assembly, and destination-local + sibling inheritance before typed decoding. It also owns schema + inspection, lifecycle coordination, callback auditing, provenance/history, passive source-context retention, secret resolution and bootstrap, and safe inspection rendering. It never imports the root package. - Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records, @@ -22,7 +23,8 @@ Implemented component owners: public engine bridge methods. - `internal/document` owns the parser-independent ordered tree and cloning. `internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary. -- Engine mechanism tests own source, composition, decoding, callback, provenance, +- Engine mechanism tests own source, composition, inheritance, decoding, + callback, provenance, source-context, secret, history, rendering, and bootstrap behavior. Root tests own public API compatibility, facade integration, documentation links, and dependency/public boundary checks. Consumer examples remain under @@ -39,4 +41,6 @@ semantics. The [consumer adoption guide](../consumers/README.md) owns applicatio guidance. The [architecture policy](../policy/architecture.md) owns contributor boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade decision. See the [development guide](../development.md) for validation commands -and [release procedure](../release.md) for source-candidate gates. +and [release procedure](../release.md) for source-candidate gates. [ADR +0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in +post-assembly structural operation. diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index ea474ee..8ddade0 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -169,6 +169,15 @@ rules and opt-in version ownership, remain part of the configuration contract. C semantics do not require identical acquisition or lifetime behavior from every source kind. +After source precedence is complete, built-in sibling `extends` relationships +resolve structurally in the same parser-independent representation, using the +existing recursive overlay rules. This adds neither an acquired source nor a +consumer registration mechanism. Enclosing relationships precede nested +destination-local resolution; typed defaults, application resolution, and +secret lookup retain their existing lifecycle roles. The [configuration +contract](../config.md#sibling-inheritance) owns the exact syntax and presence +rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale. + ConfigKit must define supported YAML semantics explicitly rather than inherit parser defaults. Processing order and diagnostics must be deterministic for the same inputs and options. Invalid input must not produce a successful usable diff --git a/docs/roadmap/deferred.md b/docs/roadmap/deferred.md index ae75fb2..9956e55 100644 --- a/docs/roadmap/deferred.md +++ b/docs/roadmap/deferred.md @@ -15,7 +15,8 @@ The following remain deliberately excluded: - automatic `conf.d` discovery; - remote imports, arbitrary providers, and additional configuration formats; -- custom merge strategies, sequence merging, and reset operators; +- custom merge strategies, sequence merging, reset operators, multiple-parent + inheritance, and cross-scope references; - named overlay inheritance; - parser-specific or custom structural-decoding extension APIs; - generic dynamic-subtree conversion and module-registry APIs; @@ -28,7 +29,6 @@ Applications may build their own validate, effective-value, source, bootstrap, a explanation commands around the library. Broadening these boundaries requires concrete consumer evidence, compatibility review, and an explicit design change. -Built-in sibling inheritance using `extends` is proposed in the -[named-inheritance roadmap](named-inheritance.md), based on the -[downstream request](feature-request-promptkit-inheritance.md). It is not yet -implemented and is distinct from inheritance between selected overlay files. +Built-in sibling inheritance using `extends` is implemented as described in the +[configuration contract](../config.md#sibling-inheritance). It is distinct from +inheritance between selected overlay files, which remains excluded above. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 052ef61..f293677 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -314,6 +314,15 @@ not a full duplication of every mechanism test. Run `./scripts/check.sh` and ## Stage 8 — Validate scaling and complete documentation +**Completed 2026-09-29.** Benchmarks cover chains, shared-parent width, nested +destinations, replacements, and full inspected loads. Skipping repeated scope +assembly when no immediate relationship exists reduced measured work while a +single boundary clone preserves caller-input ownership. The maintained preset +example, configuration contract, consumer guidance, internal navigation, +architecture/deferred scope, and ADR 0009 now describe the implemented feature. +`./scripts/check.sh`, `go test -race ./...`, and the focused benchmarks passed. +No release was tagged or published. + Add focused benchmarks beside assembly/underlay benchmarks: long chains, wide shared-parent scopes, nested destination expansion, and replacements. Evaluate allocations/work against input, materialized output, and retained history, diff --git a/docs/roadmap/named-inheritance.md b/docs/roadmap/named-inheritance.md index 74d4422..53f7ad3 100644 --- a/docs/roadmap/named-inheritance.md +++ b/docs/roadmap/named-inheritance.md @@ -1,7 +1,8 @@ # Built-In Sibling Configuration Inheritance -Status: agreed feature design; not implemented. This document defines the intended -end state. The [implementation plan](implementation.md) defines execution steps. +Status: implemented. The [configuration contract](../config.md#sibling-inheritance) +owns current behavior. This document preserves the agreed design and its +baseline; the [implementation plan](implementation.md) records completed work. ## Purpose and priorities @@ -23,7 +24,7 @@ reduced consumer configuration infrastructure. Computational efficiency matters within those goals: resolve shared ancestors once and account for the size of materialized results and retained history. Avoid an unconditional O(n) promise. -## Current baseline +## Baseline at design time Baseline: `b604aa167b684525f0d5d806d9c4267824151ee4`, after the filesystem-source and underlay implementation and its remediation. diff --git a/examples/consumers/consumers_test.go b/examples/consumers/consumers_test.go index d77590d..70cd12c 100644 --- a/examples/consumers/consumers_test.go +++ b/examples/consumers/consumers_test.go @@ -52,6 +52,22 @@ func Example_seriatim() { // Output: visitor true the event occurred } +func Example_inheritedPresets() { + result, err := consumers.LoadPresets(configkit.File(fixture("inheritance", "presets.yml"))) + if err != nil { + fmt.Println(err) + return + } + path := configkit.Keys("presets", "custom", "enabled") + info, _ := result.Metadata().Lookup(path) + custom := result.Config.Presets["custom"] + fmt.Println(custom.Enabled, result.Metadata().IsExplicit(path), custom.Settings["secondary"]["width"]) + fmt.Println(info.Origin.Locations[0].SourceID, info.Origin.Inheritance[0].From) + // Output: + // false true 20 + // source-1 /presets/base/enabled +} + func Example_weatherStartup() { const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED" const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN" diff --git a/examples/consumers/inheritance.go b/examples/consumers/inheritance.go new file mode 100644 index 0000000..6ea639b --- /dev/null +++ b/examples/consumers/inheritance.go @@ -0,0 +1,20 @@ +package consumers + +import configkit "gitea.maximumdirect.net/eric/ConfigKit" + +// PresetCatalog is ordinary application data; no inheritance fields or +// registration hooks are required in the typed configuration. +type PresetCatalog struct { + Presets map[string]Preset `yaml:"presets"` +} + +type Preset struct { + Enabled bool `yaml:"enabled"` + Settings map[string]map[string]int `yaml:"settings"` +} + +// LoadPresets performs one load; callers may inspect inherited presence and +// source origins on the returned result before choosing a preset to execute. +func LoadPresets(source configkit.Source) (*configkit.Result[PresetCatalog], error) { + return configkit.Load[PresetCatalog](source) +} diff --git a/examples/consumers/inheritance_test.go b/examples/consumers/inheritance_test.go new file mode 100644 index 0000000..72983bc --- /dev/null +++ b/examples/consumers/inheritance_test.go @@ -0,0 +1,35 @@ +package consumers_test + +import ( + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" +) + +func TestPresetCatalogOneLoadAndDerivation(t *testing.T) { + result, err := consumers.LoadPresets(configkit.File(fixture("inheritance", "presets.yml"))) + if err != nil { + t.Fatal(err) + } + if result.Config.Presets["base"].Settings["secondary"]["width"] != 10 || + result.Config.Presets["custom"].Settings["secondary"]["width"] != 20 || + result.Config.Presets["custom"].Enabled { + t.Fatalf("expanded presets = %#v", result.Config.Presets) + } + enabled := configkit.Keys("presets", "custom", "enabled") + if !result.Metadata().IsExplicit(enabled) { + t.Fatal("inherited false lost explicit presence") + } + info, found := result.Metadata().Lookup(enabled) + if !found || info.Origin.Locations[0].SourceID != "source-1" || + len(info.Origin.Inheritance) != 1 || info.Origin.Inheritance[0].From.String() != "/presets/base/enabled" { + t.Fatalf("origin and derivation = %#v, %v", info, found) + } + nested := configkit.Keys("presets", "custom", "settings", "secondary", "width") + info, found = result.Metadata().Lookup(nested) + if !found || len(info.Origin.Inheritance) != 1 || + info.Origin.Inheritance[0].From.String() != "/presets/custom/settings/primary/width" { + t.Fatalf("destination-local derivation = %#v, %v", info, found) + } +} diff --git a/examples/consumers/testdata/inheritance/presets.yml b/examples/consumers/testdata/inheritance/presets.yml new file mode 100644 index 0000000..4d5096a --- /dev/null +++ b/examples/consumers/testdata/inheritance/presets.yml @@ -0,0 +1,10 @@ +presets: + base: + enabled: false + settings: + primary: {width: 10} + secondary: {extends: primary} + custom: + extends: base + settings: + primary: {width: 20} diff --git a/inspection.go b/inspection.go index f234827..ccfdf3e 100644 --- a/inspection.go +++ b/inspection.go @@ -39,7 +39,8 @@ type Explanation struct { } // Explain returns safe effective and chronological values for a known final or -// historical path. Unknown and malformed paths return false. +// historical path. Structural inheritance appears between source applications +// and typed reconciliation. Unknown and malformed paths return false. func (r *Result[T]) Explain(path Path) (Explanation, bool) { if r == nil { return Explanation{}, false diff --git a/internal/engine/inheritance_bench_test.go b/internal/engine/inheritance_bench_test.go new file mode 100644 index 0000000..09a5d4c --- /dev/null +++ b/internal/engine/inheritance_bench_test.go @@ -0,0 +1,88 @@ +package engine + +import ( + "strconv" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func BenchmarkInheritanceExpansion(b *testing.B) { + for _, shape := range []string{"chain", "wide", "nested", "replacement"} { + for _, size := range []int{16, 32, 64} { + b.Run(shape+"/"+strconv.Itoa(size), func(b *testing.B) { + input := inheritanceBenchmarkInput(shape, size) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, diagnostics := expandInheritanceWithOrigins(input, nil, defaultProjectionLimits()); len(diagnostics) != 0 { + b.Fatal(diagnostics) + } + } + }) + } + } +} + +func BenchmarkInheritanceInspectedLoad(b *testing.B) { + type entry struct { + Value string `yaml:"value"` + } + type configuration struct { + Entries map[string]entry `yaml:"entries"` + } + for _, size := range []int{16, 32, 64} { + b.Run(strconv.Itoa(size), func(b *testing.B) { + var input strings.Builder + input.WriteString("entries:\n item-0: {value: base}\n") + for index := 1; index <= size; index++ { + input.WriteString(" item-") + input.WriteString(strconv.Itoa(index)) + input.WriteString(": {extends: item-0}\n") + } + source := Bytes("inheritance-benchmark", []byte(input.String())) + query := Keys("entries", "item-"+strconv.Itoa(size), "value") + b.ReportAllocs() + b.ResetTimer() + for range b.N { + loaded, err := Load[configuration](source, testSecretBinding()) + if err != nil { + b.Fatal(err) + } + if _, found := loaded.Inspection.Explain(query); !found { + b.Fatal("inherited value is not explainable") + } + } + }) + } +} + +func inheritanceBenchmarkInput(shape string, size int) *document.Node { + value := func(text string) *document.Node { return &document.Node{Kind: document.String, Text: text} } + mapping := func(entries ...document.Entry) *document.Node { + return &document.Node{Kind: document.Mapping, Mapping: entries} + } + entry := func(key string, node *document.Node) document.Entry { return document.Entry{Key: key, Value: node} } + base := mapping(entry("value", value("base")), entry("settings", mapping(entry("enabled", &document.Node{Kind: document.Boolean, Text: "false"})))) + if shape == "nested" { + base = mapping(entry("settings", mapping(entry("first", mapping(entry("value", value("base")))), entry("second", mapping(entry("extends", value("first"))))))) + } + definitions := make([]document.Entry, 0, size+1) + definitions = append(definitions, entry("item-0", base)) + for index := 1; index <= size; index++ { + parent := "item-0" + if shape == "chain" { + parent = "item-" + strconv.Itoa(index-1) + } + payload := []document.Entry{entry("extends", value(parent))} + if shape == "nested" { + payload = append(payload, entry("settings", mapping(entry("first", mapping(entry("value", value("custom"))))))) + } + if shape == "replacement" { + payload = append(payload, entry("value", value("replacement"))) + } + definitions = append(definitions, entry("item-"+strconv.Itoa(index), mapping(payload...))) + } + return mapping(entry("entries", mapping(definitions...))) +} diff --git a/internal/engine/inheritance_walk.go b/internal/engine/inheritance_walk.go index 10709ce..b3b209f 100644 --- a/internal/engine/inheritance_walk.go +++ b/internal/engine/inheritance_walk.go @@ -19,8 +19,7 @@ type inheritanceWalker struct { } // expandInheritanceTree resolves each destination scope after its enclosing -// relationships. It remains a private, test-only operation until the loading -// lifecycle also carries its complete provenance and inspection evidence. +// relationships. The loading lifecycle uses the origin-aware entry point. func expandInheritanceTree(input *document.Node, limits projectionLimits) (*inheritanceScope, []Diagnostic) { return expandInheritanceWithOrigins(input, nil, limits) } @@ -34,9 +33,13 @@ func expandInheritanceWithBudgets(input *document.Node, inputOrigins map[string] return nil, []Diagnostic{{Code: CodeInheritanceInvalid, Severity: SeverityError, Message: "inheritance requires a mapping document root"}} } - root := input + root := document.Clone(input) + origins := cloneOriginIndex(inputOrigins) + if inputOrigins == nil { + origins = directInheritanceOrigins(input, Path{}, nil) + } walker := inheritanceWalker{ - root: &root, limits: limits, origins: cloneOriginIndex(inputOrigins), + root: &root, limits: limits, origins: origins, retained: newRetainedNodeCounter(historyLimit), } if problem := walker.visit(&root, Path{}, true); problem != nil { @@ -75,22 +78,24 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool *node = withoutInheritanceDirective(*node) } } - resolved, diagnostics := expandSiblingDefinitionsWithOrigins(*node, path, w.limits, w.origins, w.retained) - if len(diagnostics) != 0 { - return &diagnostics[0] - } - *node = resolved.root - 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...) - if !inheritanceLowerBoundWithin(*w.root, w.limits.nodes) { - problem := inheritanceBudgetDiagnostic(path, (*node).Location) - return &problem + if scopeHasInheritanceDefinitions(*node) { + resolved, diagnostics := expandSiblingDefinitionsWithOrigins(*node, path, w.limits, w.origins, w.retained) + if len(diagnostics) != 0 { + return &diagnostics[0] + } + *node = resolved.root + 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...) + if !inheritanceLowerBoundWithin(*w.root, w.limits.nodes) { + problem := inheritanceBudgetDiagnostic(path, (*node).Location) + return &problem + } } indices := make([]int, len((*node).Mapping)) for index := range indices { @@ -116,6 +121,18 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool return nil } +func scopeHasInheritanceDefinitions(scope *document.Node) bool { + for _, entry := range scope.Mapping { + if entry.Value.Kind != document.Mapping { + continue + } + if _, found := inheritanceDirective(entry.Value); found { + return true + } + } + return false +} + // A directive may disappear during later destination resolution, so only // non-directive nodes form a safe early lower bound on final output size. func inheritanceLowerBoundWithin(root *document.Node, limit int) bool { diff --git a/internal/engine/metadata.go b/internal/engine/metadata.go index a493f04..4a10809 100644 --- a/internal/engine/metadata.go +++ b/internal/engine/metadata.go @@ -38,7 +38,7 @@ func (m Metadata) IsExplicit(path Path) bool { return ok && info.Explicit } -// History returns detached definition and resolution records for path. A record's +// History returns detached definition, inheritance, and resolution records for path. A record's // Path can be an ancestor when that container operation replaced or removed path. // Removed historical paths can have history even though Lookup returns false. // Unknown and invalid paths return nil. @@ -51,7 +51,7 @@ func (m Metadata) History(path Path) []Change { } // HistoricalPaths returns the paths observed during loading, including effective -// paths and paths removed by overlays or recorded resolution. It includes default +// paths and paths removed by overlays, inheritance, or recorded resolution. It includes default // paths and the root, but not paths that never existed. The zero Metadata returns // an empty slice. // diff --git a/layered.go b/layered.go index ae148cd..adebc82 100644 --- a/layered.go +++ b/layered.go @@ -30,8 +30,9 @@ type LayeredOptions[T any] struct { // imports with each input's tier. It strictly composes contributions within // each underlay, then recursively combines underlays in listed order beneath // main, a selected overlay, and CLI overrides. Defaults, decoding, resolution, -// and validation run once on the assembled input. Source IDs follow acquisition -// order, not precedence order. Zero or one options value is accepted; any error +// and validation run once after sibling `extends` expansion of the assembled +// input. Source IDs follow acquisition order, not precedence order. Zero or +// one options value is accepted; any error // returns a nil result. func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T], error) { options := make([]engine.LayeredOptions[T], len(supplied)) diff --git a/lifecycle.go b/lifecycle.go index cb2d19e..3dd4004 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -79,8 +79,9 @@ type Result[T any] struct { } // Load parses and strictly decodes zero or one selected source, explicitly enabled -// imports, and one selected overlay. It accepts at most one Options value and -// returns no result when any error is reported. +// imports, and one selected overlay. After source precedence, built-in sibling +// `extends` relationships resolve before defaults and typed decoding. It accepts +// at most one Options value and returns no result when any error is reported. func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { options := make([]engine.Options[T], len(supplied)) for index := range supplied { diff --git a/metadata.go b/metadata.go index 7e88d57..399f18a 100644 --- a/metadata.go +++ b/metadata.go @@ -19,7 +19,7 @@ func (m Metadata) IsExplicit(path Path) bool { return m.value.IsExplicit(toEnginePath(path)) } -// History returns detached definition and resolution records for path. A record's +// History returns detached definition, inheritance, and resolution records for path. A record's // Path can be an ancestor when that container operation replaced or removed path. // Removed historical paths can have history even though Lookup returns false. // Unknown and invalid paths return nil. @@ -36,7 +36,7 @@ func (m Metadata) History(path Path) []Change { } // HistoricalPaths returns the paths observed during loading, including effective -// paths and paths removed by overlays or recorded resolution. It includes default +// paths and paths removed by overlays, inheritance, or recorded resolution. It includes default // paths and the root, but not paths that never existed. The zero Metadata returns // an empty slice. // diff --git a/records.go b/records.go index 3e56a65..5c77f07 100644 --- a/records.go +++ b/records.go @@ -205,8 +205,9 @@ type Diagnostic struct { Related []Location } -// InheritanceStep identifies a concrete payload transfer controlled by a -// relationship directive. Locations belong to that directive, not its value. +// InheritanceStep identifies a concrete payload transfer. From and To name +// semantic value paths; Reference names the controlling destination `extends` +// path. Locations belong to that directive, not the transferred value. type InheritanceStep struct { From Path To Path @@ -216,7 +217,8 @@ type InheritanceStep struct { // Origin describes where an effective definition came from. Multiple locations // represent a composite origin; resolution origins are synthetic and identify their -// operation instead of a file location. +// operation instead of a file location. Inheritance records the concrete +// derivation path while Layer and Locations continue to identify value sources. type Origin struct { Layer Layer Locations []Location