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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
5
load.go
5
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user