Expose resolution input provenance
This commit is contained in:
@@ -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
|
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
||||||
null are never confused with absence or static defaults.
|
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.
|
`Default` assigns and records a derived value only when its path is not explicit.
|
||||||
`Normalize` always assigns and records. For transformations performed directly by
|
`Normalize` always assigns and records. For transformations performed directly by
|
||||||
application code, `Resolution.Record` declares a derived-default or normalization
|
application code, `Resolution.Record` declares a derived-default or normalization
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ The completed string-and-integer schema-version implementation and remediation
|
|||||||
records are preserved in Git history at `244104f`. The
|
records are preserved in Git history at `244104f`. The
|
||||||
[configuration contract](config.md#application-schema-versions) owns current behavior.
|
[configuration contract](config.md#application-schema-versions) owns current behavior.
|
||||||
|
|
||||||
Planned feature: [source origins during resolution](roadmap/source-origins-during-resolution.md).
|
Current implementation work: [source origins during resolution](roadmap/source-origins-during-resolution.md)
|
||||||
This roadmap defines future behavior; its staged implementation plan is not yet written.
|
and its [staged plan](roadmap/implementation.md). The roadmap defines target
|
||||||
|
behavior; the implementation plan records progress and handoffs.
|
||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ Stage 1 risks were identified.
|
|||||||
|
|
||||||
## Stage 2 — Wire the callback API and enforce lifetime
|
## 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
|
Scope: `load_result.go`, `resolution.go`, necessary private callers, reference
|
||||||
comments, and resolution/callback ownership tests.
|
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
|
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.
|
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
|
## Stage 3 — Verify layered provenance and snapshot independence
|
||||||
|
|
||||||
Status: not started. Dependencies: Stage 2.
|
Status: not started. Dependencies: Stage 2.
|
||||||
|
|||||||
5
load.go
5
load.go
@@ -44,8 +44,9 @@ type Override struct {
|
|||||||
Flag string
|
Flag string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolution exposes surviving-input presence and records audited application
|
// Resolution exposes entry-state input provenance, surviving-input presence, and
|
||||||
// resolution changes. A resolver must not retain or concurrently use the context.
|
// audited application resolution changes. A resolver must not retain or
|
||||||
|
// concurrently use the context.
|
||||||
type Resolution struct {
|
type Resolution struct {
|
||||||
state *resolutionState
|
state *resolutionState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
final := decoded.final
|
final := decoded.final
|
||||||
var resolutionSnapshots []resolutionSnapshot
|
var resolutionSnapshots []resolutionSnapshot
|
||||||
if definition.Resolve != nil {
|
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...)
|
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||||
if hasErrors(resolved.diagnostics) {
|
if hasErrors(resolved.diagnostics) {
|
||||||
return resolvedLoad[T]{}, diagnostics
|
return resolvedLoad[T]{}, diagnostics
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
|
|||||||
value.Items[0] = "normalized"
|
value.Items[0] = "normalized"
|
||||||
resolution.Record(Keys("items"), ChangeNormalization, "normalized item")
|
resolution.Record(Keys("items"), ChangeNormalization, "normalized item")
|
||||||
return nil
|
return nil
|
||||||
}, schema, decoded, document)
|
}, schema, decoded, document, input)
|
||||||
if len(resolved.diagnostics) != 0 {
|
if len(resolved.diagnostics) != 0 {
|
||||||
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type resolutionState struct {
|
|||||||
schema *schemaDescriptor
|
schema *schemaDescriptor
|
||||||
presence map[string]inputPresence
|
presence map[string]inputPresence
|
||||||
document *document.Node
|
document *document.Node
|
||||||
|
input resolutionInput
|
||||||
records map[string]*resolutionRecord
|
records map[string]*resolutionRecord
|
||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,27 @@ func (r *Resolution) IsExplicit(path Path) bool {
|
|||||||
return explicit
|
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
|
// Record declares the intent for a resolver mutation. A container path covers
|
||||||
// mutations to its descendants.
|
// mutations to its descendants.
|
||||||
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
|
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)
|
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{
|
state := &resolutionState{
|
||||||
active: true, schema: schema, presence: decoded.presence, document: input,
|
active: true, schema: schema, presence: decoded.presence, document: input,
|
||||||
|
input: entry,
|
||||||
records: make(map[string]*resolutionRecord),
|
records: make(map[string]*resolutionRecord),
|
||||||
}
|
}
|
||||||
context := &Resolution{state: state}
|
context := &Resolution{state: state}
|
||||||
|
|||||||
@@ -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) {
|
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -301,6 +378,45 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
|
|||||||
retained.IsExplicit(Keys("value"))
|
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) {
|
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user