Expose resolution input provenance

This commit is contained in:
2026-09-12 13:13:07 +00:00
parent 88f21d09c7
commit e20a0a0998
8 changed files with 184 additions and 8 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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}

View File

@@ -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()