Separate engine loading from inspection state

This commit is contained in:
2026-09-12 16:12:01 +00:00
parent 05812a6ad3
commit b8935e77bd
11 changed files with 120 additions and 25 deletions

View File

@@ -345,7 +345,7 @@ generic result before lifecycle delegation begins.
## Stage 5 — Separate engine publication and inspection ## 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 1. Split generic engine Result into the short-lived loaded configuration carrier
and non-generic inspection state specified above. Move inspection methods to 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 ./internal/engine -run 'Load|Metadata|Provenance|Historical|Explain|Effective|Render|Independence'`,
`go test -race ./internal/engine`, and the everyday check. `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 ## Stage 6 — Wire candidate lifecycle and callback wrappers
Status: not started. Status: not started.

View File

@@ -36,7 +36,7 @@ type Explanation struct {
// Explain returns safe effective and chronological values for a known final or // Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false. // 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) { if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
return Explanation{}, false return Explanation{}, false
} }

View File

@@ -14,7 +14,7 @@ var (
// WriteExplanation writes a deterministic human-readable rendering of Explain. // WriteExplanation writes a deterministic human-readable rendering of Explain.
// Unknown paths produce no output. Writer failures may leave partial output. // 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 { if r == nil || output == nil {
return errExplanationUnavailable return errExplanationUnavailable
} }

View File

@@ -38,7 +38,7 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
} }
const count = 32 const count = 32
results := make([]*Result[independentConfig], count) results := make([]*testResult[independentConfig], count)
errorsByIndex := make([]error, count) errorsByIndex := make([]error, count)
var group sync.WaitGroup var group sync.WaitGroup
for index := range count { 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) { func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
source := Reader("shared", strings.NewReader("value: one\n")) source := Reader("shared", strings.NewReader("value: one\n"))
copied := source copied := source

View File

@@ -66,10 +66,15 @@ type Version struct {
Text string Text string
} }
// Result is a successful independently owned configuration load. // Loaded is a short-lived generic carrier for a successful configuration load.
type Result[T any] struct { // Inspection is frozen independently of Config and does not retain application values.
Config T type Loaded[T any] struct {
Config T
Inspection *Inspection
}
// Inspection owns frozen load snapshots and detached query state.
type Inspection struct {
metadata Metadata metadata Metadata
sources []SourceInfo sources []SourceInfo
diagnostics []Diagnostic diagnostics []Diagnostic
@@ -84,7 +89,7 @@ type Result[T any] struct {
} }
// Metadata returns detached effective presence, origin, and history records. // Metadata returns detached effective presence, origin, and history records.
func (r *Result[T]) Metadata() Metadata { func (r *Inspection) Metadata() Metadata {
if r == nil { if r == nil {
return Metadata{} return Metadata{}
} }
@@ -92,7 +97,7 @@ func (r *Result[T]) Metadata() Metadata {
} }
// Sources returns a detached list of sources used by the load. // 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 { if r == nil {
return nil return nil
} }
@@ -100,7 +105,7 @@ func (r *Result[T]) Sources() []SourceInfo {
} }
// Diagnostics returns detached nonfatal diagnostics from the load. // Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Result[T]) Diagnostics() []Diagnostic { func (r *Inspection) Diagnostics() []Diagnostic {
if r == nil { if r == nil {
return 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 // 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 // imports, and one selected profile. It accepts at most one Options value and
// returns no result when any error is reported. // 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...) 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{} accumulator := diagnosticAccumulator{}
options, diagnostics := selectOptions(source, supplied) options, diagnostics := selectOptions(source, supplied)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)

View File

@@ -80,7 +80,7 @@ func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, m
return detached.Interface().(T), nil 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. // A read-only validator can replace a value with an equal borrowed object.
// Detach without re-reading the environment or discarding validated state. // Detach without re-reading the environment or discarding validated state.
configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets) 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 final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
return &Result[T]{ inspection := &Inspection{
Config: configuration,
metadata: metadata, metadata: metadata,
sources: cloneSourceInfos(accumulator.sources), sources: cloneSourceInfos(accumulator.sources),
diagnostics: canonicalDiagnostics(accumulator.diagnostics), diagnostics: canonicalDiagnostics(accumulator.diagnostics),
@@ -105,7 +104,8 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
applications: applications, applications: applications,
secrets: secrets, secrets: secrets,
secretReferences: cloneSecretReferences(references), secretReferences: cloneSecretReferences(references),
}, nil }
return &Loaded[T]{Config: configuration, Inspection: inspection}, nil
} }
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic { func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {

View File

@@ -166,7 +166,7 @@ func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
options := Options[layeredConfig]{Profile: "testing"} options := Options[layeredConfig]{Profile: "testing"}
const loads = 16 const loads = 16
results := make(chan *Result[layeredConfig], loads) results := make(chan *testResult[layeredConfig], loads)
errors := make(chan error, loads) errors := make(chan error, loads)
var group sync.WaitGroup var group sync.WaitGroup
for index := 0; index < loads; index++ { for index := 0; index < loads; index++ {
@@ -187,7 +187,7 @@ func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
for err := range errors { for err := range errors {
t.Fatal(err) t.Fatal(err)
} }
var collected []*Result[layeredConfig] var collected []*testResult[layeredConfig]
for result := range results { for result := range results {
collected = append(collected, result) collected = append(collected, result)
} }

View File

@@ -14,7 +14,7 @@ var errEffectiveUnavailable = errors.New("configkit: effective configuration is
// WriteEffective writes deterministic redacted YAML from the frozen resolved // WriteEffective writes deterministic redacted YAML from the frozen resolved
// snapshot. The output is intended for inspection and is not promised reloadable. // snapshot. The output is intended for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output. // 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 { if r == nil || r.final == nil || output == nil {
return errEffectiveUnavailable return errEffectiveUnavailable
} }

View File

@@ -139,7 +139,7 @@ sequence: [second, first]
if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) { if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err) 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) { if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) {
t.Fatalf("nil result error = %v", err) t.Fatalf("nil result error = %v", err)
} }

View File

@@ -440,7 +440,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
retained.Warning(Keys("version"), "too late") 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() t.Helper()
if result != nil || err == nil { if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err) t.Fatalf("result, err = %#v, %v", result, err)

View File

@@ -58,12 +58,62 @@ func testSecretBinding() SecretBinding {
} }
} }
func testLoad[T any](source Source, supplied ...Options[T]) (*Result[T], error) { func testLoad[T any](source Source, supplied ...Options[T]) (*testResult[T], error) {
return Load(source, testSecretBinding(), supplied...) loaded, err := Load(source, testSecretBinding(), supplied...)
return testLoaded(loaded), err
} }
func testLoadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) { func testLoadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*testResult[T], error) {
return loadWithSecretLookup(source, testSecretBinding(), lookup, supplied...) 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) { func testBuildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {