From e20a0a09984e4b34ca63efc52a43e9d9f5ace9a4 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 12 Sep 2026 13:13:07 +0000 Subject: [PATCH] Expose resolution input provenance --- docs/config.md | 21 ++++++ docs/development.md | 5 +- docs/roadmap/implementation.md | 15 ++++- load.go | 5 +- load_result.go | 3 +- metadata_test.go | 2 +- resolution.go | 25 ++++++- resolution_test.go | 116 +++++++++++++++++++++++++++++++++ 8 files changed, 184 insertions(+), 8 deletions(-) diff --git a/docs/config.md b/docs/config.md index 93fd63b..9d18a64 100644 --- a/docs/config.md +++ b/docs/config.md @@ -326,6 +326,27 @@ receives the working configuration and a per-load `Resolution` context. The cont `IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and null are never confused with absence or static defaults. +`Resolution.InputInfo(path)` returns detached effective metadata from the fixed +decoded entry state, before application resolution runs. It includes retained static +defaults, preserves explicit null, and uses the same exact semantic path matching as +`Metadata.Lookup`; missing, malformed, and key/index-shape-mismatched paths return +false without diagnostics. Its `Explicit` value agrees with `IsExplicit` for every +found entry path. Resolution changes, additions, removals, and moves do not alter +this entry view. + +`Resolution.Source(id)` returns a detached record for an already acquired source in +the same load: the main source, imports, selected profile, and supplied CLI records +where applicable. Empty and unknown IDs return false. It never reloads input, reads +the filesystem, resolves symlinks, or looks up credentials. A source path exists +only when the acquired source has one; defaults and byte, reader, and CLI sources do +not acquire an invented file path. Applications select a base directory from exact +origin locations and these source records according to their own policy. + +Both queries are callback-scoped like the rest of `Resolution`: nil, zero, or +retained contexts panic with the fixed closed-context failure outside the callback. +Returned field and source records are detached and remain usable after callback +exit; changing them cannot affect later queries, results, or another load. + `Default` assigns and records a derived value only when its path is not explicit. `Normalize` always assigns and records. For transformations performed directly by application code, `Resolution.Record` declares a derived-default or normalization diff --git a/docs/development.md b/docs/development.md index b33c7d4..59c32d5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -46,8 +46,9 @@ The completed string-and-integer schema-version implementation and remediation records are preserved in Git history at `244104f`. The [configuration contract](config.md#application-schema-versions) owns current behavior. -Planned feature: [source origins during resolution](roadmap/source-origins-during-resolution.md). -This roadmap defines future behavior; its staged implementation plan is not yet written. +Current implementation work: [source origins during resolution](roadmap/source-origins-during-resolution.md) +and its [staged plan](roadmap/implementation.md). The roadmap defines target +behavior; the implementation plan records progress and handoffs. ## Baseline validation diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f59f2a7..35ed148 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -123,7 +123,7 @@ Stage 1 risks were identified. ## Stage 2 — Wire the callback API and enforce lifetime -Status: not started. Dependencies: Stage 1. +Status: complete (2026-09-12). Dependencies: Stage 1. Scope: `load_result.go`, `resolution.go`, necessary private callers, reference comments, and resolution/callback ownership tests. @@ -152,6 +152,19 @@ existing mutation auditing remain unchanged. Validation: `go test . -run 'Resolution|Callback|Metadata|Load'`, then the everyday checker. Do not mark complete with public methods that work only in private tests. +Completion record (2026-09-12): `resolveLoadInput` now creates a +`resolutionInput` after decoded/overlay metadata and passes it explicitly into +`runResolution`; the private state retains it for the callback. `Resolution.InputInfo` +and `Resolution.Source` use the existing activity guard before exact entry lookup or +source lookup, and return detached records. Public load tests cover a canonical +file-authored value, a retained default, explicit null, misses, empty/unknown IDs, +and defaults-only input; they also cover nil, zero, successful-retained, and +failed-retained contexts plus saved record independence. Go reference comments, +the configuration contract, and the development guide now describe the active work +accurately. `go test . -run 'Resolution|Callback|Metadata|Load'` and +`./scripts/check.sh` passed with Go 1.26.7 on Linux. No deviations or remaining +Stage 2 risks were identified. + ## Stage 3 — Verify layered provenance and snapshot independence Status: not started. Dependencies: Stage 2. diff --git a/load.go b/load.go index 9e93e3c..79aa119 100644 --- a/load.go +++ b/load.go @@ -44,8 +44,9 @@ type Override struct { Flag string } -// Resolution exposes surviving-input presence and records audited application -// resolution changes. A resolver must not retain or concurrently use the context. +// Resolution exposes entry-state input provenance, surviving-input presence, and +// audited application resolution changes. A resolver must not retain or +// concurrently use the context. type Resolution struct { state *resolutionState } diff --git a/load_result.go b/load_result.go index 41c5efb..86c94bc 100644 --- a/load_result.go +++ b/load_result.go @@ -51,7 +51,8 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], final := decoded.final var resolutionSnapshots []resolutionSnapshot if definition.Resolve != nil { - resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document) + entry := newResolutionInput(metadata, decoded.final, input.sources) + resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry) diagnostics = append(diagnostics, resolved.diagnostics...) if hasErrors(resolved.diagnostics) { return resolvedLoad[T]{}, diagnostics diff --git a/metadata_test.go b/metadata_test.go index bdf425e..611c6a2 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -211,7 +211,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) { value.Items[0] = "normalized" resolution.Record(Keys("items"), ChangeNormalization, "normalized item") return nil - }, schema, decoded, document) + }, schema, decoded, document, input) if len(resolved.diagnostics) != 0 { t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics) } diff --git a/resolution.go b/resolution.go index 1373010..9fd1af4 100644 --- a/resolution.go +++ b/resolution.go @@ -15,6 +15,7 @@ type resolutionState struct { schema *schemaDescriptor presence map[string]inputPresence document *document.Node + input resolutionInput records map[string]*resolutionRecord diagnostics []Diagnostic } @@ -50,6 +51,27 @@ func (r *Resolution) IsExplicit(path Path) bool { return explicit } +// InputInfo returns detached entry-state metadata for path. The entry state is +// the decoded configuration, including retained static defaults, immediately +// before resolution starts. It is fixed while resolution records mutate the +// working configuration; unknown or shape-mismatched paths return false. +// +// InputInfo is valid only during the resolution callback. See the configuration +// contract for source and provenance semantics. +func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) { + return r.activeState().input.lookup(path) +} + +// Source returns a detached record for a source acquired by this load. Empty or +// unknown IDs return false and do not perform input, filesystem, or credential +// access. A source path is present only when that acquired source has one. +// +// Source is valid only during the resolution callback. See the configuration +// contract for source and provenance semantics. +func (r *Resolution) Source(id string) (SourceInfo, bool) { + return r.activeState().input.source(id) +} + // Record declares the intent for a resolver mutation. A container path covers // mutations to its descendants. func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) { @@ -221,9 +243,10 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string state.diagnostics = append(state.diagnostics, diagnostic) } -func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult { +func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult { state := &resolutionState{ active: true, schema: schema, presence: decoded.presence, document: input, + input: entry, records: make(map[string]*resolutionRecord), } context := &Resolution{state: state} diff --git a/resolution_test.go b/resolution_test.go index 52351ce..a01b477 100644 --- a/resolution_test.go +++ b/resolution_test.go @@ -280,6 +280,83 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t } } +func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) { + t.Parallel() + + type config struct { + Declared string `yaml:"declared"` + Default string `yaml:"default"` + Optional *string `yaml:"optional"` + } + filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n") + var savedField FieldInfo + var savedSource SourceInfo + result, err := Load[config](File(filename), Options[config]{Definition: Definition[config]{ + Defaults: func() config { return config{Default: "retained"} }, + Resolve: func(_ *config, resolution *Resolution) error { + for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} { + info, found := resolution.InputInfo(path) + if !found || info.Explicit != resolution.IsExplicit(path) { + return errors.New("entry metadata does not agree with explicitness") + } + } + defaultInfo, _ := resolution.InputInfo(Keys("default")) + optionalInfo, _ := resolution.InputInfo(Keys("optional")) + if defaultInfo.Explicit || defaultInfo.Origin.Layer != LayerDefault || !optionalInfo.Explicit || !optionalInfo.Null { + return errors.New("entry metadata lost default or null state") + } + if _, found := resolution.InputInfo(Keys("missing")); found { + return errors.New("missing entry path was found") + } + declared, _ := resolution.InputInfo(Keys("declared")) + if len(declared.Origin.Locations) != 1 { + return errors.New("declared source location is missing") + } + source, found := resolution.Source(declared.Origin.Locations[0].SourceID) + if !found || source.Path != canonicalTestPath(t, filename) || source.ID != "source-1" { + return errors.New("declared source record is missing") + } + if _, found := resolution.Source(""); found { + return errors.New("empty source identity was found") + } + if _, found := resolution.Source("unknown"); found { + return errors.New("unknown source identity was found") + } + savedField, savedSource = declared, source + return nil + }, + }}) + if err != nil { + t.Fatal(err) + } + if result.Config.Declared != "file" || result.Config.Default != "retained" || result.Config.Optional != nil { + t.Fatalf("configuration = %#v", result.Config) + } + savedField.Origin.Locations[0].SourceID = "mutated" + savedSource.Label = "mutated" + field, _ := result.Metadata().Lookup(Keys("declared")) + if field.Origin.Locations[0].SourceID != "source-1" || result.Sources()[0].Label == "mutated" { + t.Fatal("saved resolution records alias a completed result") + } + + noSource, err := Load[config](NoSource(), Options[config]{Definition: Definition[config]{ + Defaults: func() config { return config{Default: "retained"} }, + Resolve: func(_ *config, resolution *Resolution) error { + info, found := resolution.InputInfo(Keys("default")) + if !found || info.Explicit || resolution.IsExplicit(Keys("default")) { + return errors.New("defaults-only entry metadata is wrong") + } + if _, found := resolution.Source("source-1"); found { + return errors.New("defaults-only load invented a source") + } + return nil + }, + }}) + if err != nil || noSource.Config.Default != "retained" { + t.Fatalf("defaults-only load = %#v, %v", noSource, err) + } +} + func TestResolutionContextPanicsAfterCallback(t *testing.T) { t.Parallel() @@ -301,6 +378,45 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) { retained.IsExplicit(Keys("value")) } +func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) { + t.Parallel() + + type config struct { + Value int `yaml:"value"` + } + requireClosed := func(t *testing.T, call func()) { + t.Helper() + defer func() { + if recovered := recover(); recovered != closedCallbackContextMessage { + t.Fatalf("panic = %#v", recovered) + } + }() + call() + } + requireClosed(t, func() { (*Resolution)(nil).InputInfo(Keys("value")) }) + requireClosed(t, func() { (&Resolution{}).Source("source-1") }) + + var successful *Resolution + if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { + successful = resolution + return nil + }}}); err != nil { + t.Fatal(err) + } + requireClosed(t, func() { successful.InputInfo(Keys("value")) }) + requireClosed(t, func() { successful.Source("source-1") }) + + var failed *Resolution + if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { + failed = resolution + return errors.New("resolver failed") + }}}); err == nil { + t.Fatal("failed resolver succeeded") + } + requireClosed(t, func() { failed.InputInfo(Keys("value")) }) + requireClosed(t, func() { failed.Source("source-1") }) +} + func TestValidationContextPanicsAfterVersionCallback(t *testing.T) { t.Parallel()