From 831c83a0b5ba45764e8d94e52f5959d89ac04230 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 6 Sep 2026 19:07:03 -0500 Subject: [PATCH] Explain defaults restored during final decoding --- docs/config.md | 10 ++++++++-- docs/roadmap/remediation.md | 6 ++++++ explain.go | 20 ++++++++++++++++++- explain_test.go | 39 +++++++++++++++++++++++++++++++++++++ load.go | 2 ++ records.go | 5 ++++- 6 files changed, 78 insertions(+), 4 deletions(-) diff --git a/docs/config.md b/docs/config.md index 8858fe9..7f7644e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 58f9c57..af3dfc1 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -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. diff --git a/explain.go b/explain.go index ba5ccc4..2e6e084 100644 --- a/explain.go +++ b/explain.go @@ -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") diff --git a/explain_test.go b/explain_test.go index 7c16584..57bb168 100644 --- a/explain_test.go +++ b/explain_test.go @@ -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") + } + } + }) + } + } +} diff --git a/load.go b/load.go index d454159..5a6308c 100644 --- a/load.go +++ b/load.go @@ -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, diff --git a/records.go b/records.go index ae338ba..a9738f3 100644 --- a/records.go +++ b/records.go @@ -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" )