diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2772306..3b8d9c8 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -345,7 +345,7 @@ generic result before lifecycle delegation begins. ## Stage 5 — Separate engine publication and inspection -Status: not started. +Status: complete (2026-09-12). 1. Split generic engine Result into the short-lived loaded configuration carrier and non-generic inspection state specified above. Move inspection methods to @@ -363,6 +363,23 @@ configuration detachment but does not store Config in inspection. Run `go test ./internal/engine -run 'Load|Metadata|Provenance|Historical|Explain|Effective|Render|Independence'`, `go test -race ./internal/engine`, and the everyday check. +### Completion + +Replaced the engine's generic result with `Loaded[T]`, a short-lived carrier of +the detached configuration and a non-generic `Inspection`. `Inspection` now +owns the frozen metadata, sources, diagnostics, document snapshots, history, +resolution snapshots, and sensitivity state; its query, explanation, and +rendering methods retain their previous behavior. The published configuration +is detached as before and is not retained by `Inspection`. + +Engine tests use a test-only adapter while exercising the moved inspection +receiver, and a direct `Loaded` test verifies that mutating `Config` cannot +change its frozen effective view. Linux validation with Go 1.26.7 passed +`go test ./internal/engine -run 'Load|Metadata|Provenance|Historical|Explain|Effective|Render|Independence'`, +`go test -race ./internal/engine`, and `./scripts/check.sh`. There were no +deviations or remaining risks. The next handoff wires the candidate facade's +lifecycle and callback wrappers to the split engine result. + ## Stage 6 — Wire candidate lifecycle and callback wrappers Status: not started. diff --git a/internal/engine/explain.go b/internal/engine/explain.go index 86e28bc..c6b6a2a 100644 --- a/internal/engine/explain.go +++ b/internal/engine/explain.go @@ -36,7 +36,7 @@ type Explanation struct { // Explain returns safe effective and chronological values for a known final or // historical path. Unknown and malformed paths return false. -func (r *Result[T]) Explain(path Path) (Explanation, bool) { +func (r *Inspection) Explain(path Path) (Explanation, bool) { if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) { return Explanation{}, false } diff --git a/internal/engine/explain_render.go b/internal/engine/explain_render.go index 8c62790..457a9e4 100644 --- a/internal/engine/explain_render.go +++ b/internal/engine/explain_render.go @@ -14,7 +14,7 @@ var ( // WriteExplanation writes a deterministic human-readable rendering of Explain. // Unknown paths produce no output. Writer failures may leave partial output. -func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error { +func (r *Inspection) WriteExplanation(output io.Writer, path Path) error { if r == nil || output == nil { return errExplanationUnavailable } diff --git a/internal/engine/independence_test.go b/internal/engine/independence_test.go index 65e9357..185436a 100644 --- a/internal/engine/independence_test.go +++ b/internal/engine/independence_test.go @@ -38,7 +38,7 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) { } const count = 32 - results := make([]*Result[independentConfig], count) + results := make([]*testResult[independentConfig], count) errorsByIndex := make([]error, count) var group sync.WaitGroup for index := range count { @@ -73,6 +73,29 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) { } } +func TestLoadedConfigurationIsSeparateFromInspection(t *testing.T) { + type configuration struct { + Value string `yaml:"value"` + } + + loaded, err := Load[configuration](Bytes("config.yml", []byte("value: original\n")), testSecretBinding()) + if err != nil { + t.Fatal(err) + } + if loaded.Inspection == nil { + t.Fatal("inspection is nil") + } + + loaded.Config.Value = "mutated" + var effective bytes.Buffer + if err := loaded.Inspection.WriteEffective(&effective); err != nil { + t.Fatal(err) + } + if effective.String() != "value: original\n" { + t.Fatalf("inspection changed with configuration: %q", effective.String()) + } +} + func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) { source := Reader("shared", strings.NewReader("value: one\n")) copied := source diff --git a/internal/engine/load.go b/internal/engine/load.go index 87d751a..f061444 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -66,10 +66,15 @@ type Version struct { Text string } -// Result is a successful independently owned configuration load. -type Result[T any] struct { - Config T +// Loaded is a short-lived generic carrier for a successful configuration load. +// Inspection is frozen independently of Config and does not retain application values. +type Loaded[T any] struct { + Config T + Inspection *Inspection +} +// Inspection owns frozen load snapshots and detached query state. +type Inspection struct { metadata Metadata sources []SourceInfo diagnostics []Diagnostic @@ -84,7 +89,7 @@ type Result[T any] struct { } // Metadata returns detached effective presence, origin, and history records. -func (r *Result[T]) Metadata() Metadata { +func (r *Inspection) Metadata() Metadata { if r == nil { return Metadata{} } @@ -92,7 +97,7 @@ func (r *Result[T]) Metadata() Metadata { } // Sources returns a detached list of sources used by the load. -func (r *Result[T]) Sources() []SourceInfo { +func (r *Inspection) Sources() []SourceInfo { if r == nil { return nil } @@ -100,7 +105,7 @@ func (r *Result[T]) Sources() []SourceInfo { } // Diagnostics returns detached nonfatal diagnostics from the load. -func (r *Result[T]) Diagnostics() []Diagnostic { +func (r *Inspection) Diagnostics() []Diagnostic { if r == nil { return nil } @@ -110,11 +115,11 @@ func (r *Result[T]) Diagnostics() []Diagnostic { // Load parses and strictly decodes zero or one selected source, explicitly enabled // imports, and one selected profile. It accepts at most one Options value and // returns no result when any error is reported. -func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Result[T], error) { +func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (*Loaded[T], error) { return loadWithSecretLookup(source, secrets, os.LookupEnv, supplied...) } -func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) { +func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) { accumulator := diagnosticAccumulator{} options, diagnostics := selectOptions(source, supplied) accumulator.add(diagnostics...) diff --git a/internal/engine/load_result.go b/internal/engine/load_result.go index abc72cc..4f8562c 100644 --- a/internal/engine/load_result.go +++ b/internal/engine/load_result.go @@ -80,7 +80,7 @@ func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, m return detached.Interface().(T), nil } -func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Result[T], error) { +func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Loaded[T], error) { // A read-only validator can replace a value with an equal borrowed object. // Detach without re-reading the environment or discarding validated state. configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets) @@ -92,8 +92,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T] final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) - return &Result[T]{ - Config: configuration, + inspection := &Inspection{ metadata: metadata, sources: cloneSourceInfos(accumulator.sources), diagnostics: canonicalDiagnostics(accumulator.diagnostics), @@ -105,7 +104,8 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T] applications: applications, secrets: secrets, secretReferences: cloneSecretReferences(references), - }, nil + } + return &Loaded[T]{Config: configuration, Inspection: inspection}, nil } func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic { diff --git a/internal/engine/provenance_test.go b/internal/engine/provenance_test.go index d6a40c0..c2c4a6e 100644 --- a/internal/engine/provenance_test.go +++ b/internal/engine/provenance_test.go @@ -166,7 +166,7 @@ func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) { options := Options[layeredConfig]{Profile: "testing"} const loads = 16 - results := make(chan *Result[layeredConfig], loads) + results := make(chan *testResult[layeredConfig], loads) errors := make(chan error, loads) var group sync.WaitGroup for index := 0; index < loads; index++ { @@ -187,7 +187,7 @@ func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) { for err := range errors { t.Fatal(err) } - var collected []*Result[layeredConfig] + var collected []*testResult[layeredConfig] for result := range results { collected = append(collected, result) } diff --git a/internal/engine/render.go b/internal/engine/render.go index a7cc52f..d08198d 100644 --- a/internal/engine/render.go +++ b/internal/engine/render.go @@ -14,7 +14,7 @@ var errEffectiveUnavailable = errors.New("configkit: effective configuration is // WriteEffective writes deterministic redacted YAML from the frozen resolved // snapshot. The output is intended for inspection and is not promised reloadable. // Writer errors, including short writes, are returned and may leave partial output. -func (r *Result[T]) WriteEffective(output io.Writer) error { +func (r *Inspection) WriteEffective(output io.Writer) error { if r == nil || r.final == nil || output == nil { return errEffectiveUnavailable } diff --git a/internal/engine/render_test.go b/internal/engine/render_test.go index 9c041e7..72b3303 100644 --- a/internal/engine/render_test.go +++ b/internal/engine/render_test.go @@ -139,7 +139,7 @@ sequence: [second, first] if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) { t.Fatalf("short writer error = %v", err) } - var nilResult *Result[renderConfig] + var nilResult *testResult[renderConfig] if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) { t.Fatalf("nil result error = %v", err) } diff --git a/internal/engine/resolution_test.go b/internal/engine/resolution_test.go index 84c1e71..f752377 100644 --- a/internal/engine/resolution_test.go +++ b/internal/engine/resolution_test.go @@ -440,7 +440,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) { retained.Warning(Keys("version"), "too late") } -func assertLoadDiagnostic[T any](t *testing.T, result *Result[T], err error, code Code) *LoadError { +func assertLoadDiagnostic[T any](t *testing.T, result *testResult[T], err error, code Code) *LoadError { t.Helper() if result != nil || err == nil { t.Fatalf("result, err = %#v, %v", result, err) diff --git a/internal/engine/secret_binding_test.go b/internal/engine/secret_binding_test.go index b85f471..02d57a8 100644 --- a/internal/engine/secret_binding_test.go +++ b/internal/engine/secret_binding_test.go @@ -58,12 +58,62 @@ func testSecretBinding() SecretBinding { } } -func testLoad[T any](source Source, supplied ...Options[T]) (*Result[T], error) { - return Load(source, testSecretBinding(), supplied...) +func testLoad[T any](source Source, supplied ...Options[T]) (*testResult[T], error) { + loaded, err := Load(source, testSecretBinding(), supplied...) + return testLoaded(loaded), err } -func testLoadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) { - return loadWithSecretLookup(source, testSecretBinding(), lookup, supplied...) +func testLoadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*testResult[T], error) { + loaded, err := loadWithSecretLookup(source, testSecretBinding(), lookup, supplied...) + return testLoaded(loaded), err +} + +type testResult[T any] struct { + Config T + *Inspection +} + +func testLoaded[T any](loaded *Loaded[T]) *testResult[T] { + if loaded == nil { + return nil + } + return &testResult[T]{Config: loaded.Config, Inspection: loaded.Inspection} +} +func (r *testResult[T]) Metadata() Metadata { + if r == nil { + return Metadata{} + } + return r.Inspection.Metadata() +} +func (r *testResult[T]) Sources() []SourceInfo { + if r == nil { + return nil + } + return r.Inspection.Sources() +} +func (r *testResult[T]) Diagnostics() []Diagnostic { + if r == nil { + return nil + } + return r.Inspection.Diagnostics() +} +func (r *testResult[T]) Explain(path Path) (Explanation, bool) { + if r == nil { + return Explanation{}, false + } + return r.Inspection.Explain(path) +} +func (r *testResult[T]) WriteEffective(output io.Writer) error { + if r == nil { + return errEffectiveUnavailable + } + return r.Inspection.WriteEffective(output) +} +func (r *testResult[T]) WriteExplanation(output io.Writer, path Path) error { + if r == nil { + return errExplanationUnknownPath + } + return r.Inspection.WriteExplanation(output, path) } func testBuildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {