Explain defaults restored during final decoding

This commit is contained in:
2026-09-06 19:07:03 -05:00
parent f7ca150a7f
commit 831c83a0b5
6 changed files with 78 additions and 4 deletions

View File

@@ -162,11 +162,17 @@ 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 profile, supplied CLI records, and audited net
resolution operations. Explicit equal writes remain visible.
resolution operations. Explicit equal writes remain visible. When input replay
differs from the final typed input, a `ChangeDecoding` event records the transition
before domain resolution. Its origin retains the decoded path's source/default
attribution; its reason identifies static-default retention and canonical decoding.
This built-in inspection operation is not accepted by `Resolution.Record`.
Base, profile, and CLI history preserves the policy-validated input representation,
including a superseded value that could not have decoded into the final Go field.
Baseline, effective, and resolution values use canonical typed projection. Mapping
Baseline, effective, and resolution values use canonical typed projection. A
decoding event bridges replayed input and canonical typed values, so intermediate
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
queries reconstruct each source or override contribution, while sequences remain
atomic: a descendant history identifies its presence before and after the containing
sequence replacement without implying element-wise merging. Removed paths remain

View File

@@ -104,6 +104,12 @@ Keep final projection, secret lookup, and validation ordering consistent.
## 4. P2 — Reconcile history replay with final-only default application
Status: complete. Explanation now records an explicit canonical decoding transition
when replay differs from final typed input, preserving decoded origins and showing
default restoration. Null/scalar-to-map profile and CLI regressions pass at parent
and descendant paths. Full tests pass with the issue-8 TMPDIR workaround; vet,
build and whitespace checks pass.
Evidence: [explain.go](../../explain.go), `Explain` initializes replay from defaults
and merges each input into it. Actual decoding applies defaults to the final
composed/overlaid input, so intermediate replacements are not lasting default resets.

View File

@@ -117,6 +117,22 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
})
}
// Intermediate input replacements do not permanently suppress defaults:
// decoding applies them to the final input tree. Show that reconciliation
// explicitly, including canonical scalar conversions, before domain changes.
replayed, _ := documentAtPath(working, path)
decoded, _ := documentAtPath(r.input, path)
if !documentNodesEqual(replayed, decoded) {
origin := cloneOrigin(r.inputOrigins[path.String()])
origin.Operation = ChangeDecoding
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshot(r.input, path, r.secrets),
Origin: origin,
Reasons: []string{"applied retained defaults and canonical typed decoding"},
})
}
working = document.Clone(r.input)
for _, snapshot := range r.resolution {
relevant := pathsIntersect(snapshot.change.Path, path)
@@ -422,7 +438,9 @@ func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
rendered.WriteString("history:\n")
for _, change := range explanation.History {
rendered.WriteString(" - representation: ")
if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
if change.Origin.Operation == ChangeDecoding {
rendered.WriteString("input to canonical typed\n")
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
rendered.WriteString("policy-validated input\n")
} else {
rendered.WriteString("canonical typed\n")

View File

@@ -326,3 +326,42 @@ func historyValueLayers(explanation Explanation) []Layer {
}
return layers
}
func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
type cfg struct {
M map[string]int `yaml:"m"`
}
for _, base := range []string{"null", "replaced-text"} {
for _, cli := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/cli=%v", base, cli), func(t *testing.T) {
root := t.TempDir()
main := writeImportFile(t, root, "main.yml", "m: "+base+"\n")
opts := Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { return cfg{M: map[string]int{"a": 1}} }}}
if cli {
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
} else {
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
opts.Profile = "p"
}
result, err := Load(File(main), opts)
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("m"), Keys("m", "a")} {
explanation, ok := result.Explain(path)
if !ok {
t.Fatal("missing explanation")
}
last := explanation.History[len(explanation.History)-1]
if last.After != explanation.Effective || last.Origin.Operation != ChangeDecoding {
t.Fatalf("history does not reconcile final value: %#v", explanation)
}
info, _ := result.Metadata().Lookup(path)
if last.Origin.Layer != info.Origin.Layer {
t.Fatal("decoding lost effective origin")
}
}
})
}
}
}

View File

@@ -72,6 +72,7 @@ type Result[T any] struct {
diagnostics []Diagnostic
baseline *document.Node
input *document.Node
inputOrigins map[string]Origin
final *document.Node
resolution []resolutionSnapshot
applications []historyApplication
@@ -288,6 +289,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
baseline: decoded.baseline,
input: decoded.final,
inputOrigins: cloneOriginIndex(decoded.origins),
final: final,
resolution: resolutionSnapshots,
applications: applications,

View File

@@ -11,11 +11,14 @@ const (
LayerResolution Layer = "resolution"
)
// ChangeKind identifies an application resolution operation. The zero value is
// ChangeKind identifies a recorded operation. The zero value is
// used for input and default origins.
type ChangeKind string
const (
// ChangeDecoding records the built-in transition to canonical typed values.
// It is inspection metadata, not an operation accepted by Resolution.Record.
ChangeDecoding ChangeKind = "decoding"
ChangeDerivedDefault ChangeKind = "derived_default"
ChangeNormalization ChangeKind = "normalization"
)