diff --git a/.codebase-memory/artifact.json b/.codebase-memory/artifact.json index ffde2f1..59f3a47 100644 --- a/.codebase-memory/artifact.json +++ b/.codebase-memory/artifact.json @@ -1,11 +1,11 @@ { "schema_version": 2, - "commit": "b8935e77bd5ac1dc347339966fbaba5de9af5348", - "indexed_at": "2026-09-12T16:16:09Z", + "commit": "da307c9f49550d789df4efe7310bb3d837e97df4", + "indexed_at": "2026-09-12T16:19:20Z", "project": "home-eric-Workspace-ConfigKit", - "nodes": 2447, - "edges": 13587, + "nodes": 2469, + "edges": 13742, "original_size": 11206656, - "compressed_size": 1701304, + "compressed_size": 1715591, "compression_level": 3 } \ No newline at end of file diff --git a/.codebase-memory/graph.db.zst b/.codebase-memory/graph.db.zst index 9a29a05..55370b0 100644 Binary files a/.codebase-memory/graph.db.zst and b/.codebase-memory/graph.db.zst differ diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c75eb41..a2671da 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -423,7 +423,7 @@ candidate inspection and secrets-bootstrap surface. ## Stage 7 — Complete candidate inspection and bootstrap surface -Status: not started. +Status: complete (2026-09-12). 1. Add the public explanation/value records and Result Explain, WriteEffective, and WriteExplanation delegation. Inspection uses the frozen engine state, @@ -444,6 +444,24 @@ integration evidence for inspection, credentials, bootstrap, and writer failures Run `go test ./internal/facade ./internal/engine`, race tests for both packages, and the everyday check. Root remains unchanged until Stage 8. +### Completion + +Completed the candidate's inspection and bootstrap API with root-owned +explanation, snapshot, secret-reference, and bootstrap records. Result delegates +to the private frozen engine inspection for explanation and rendering, so public +configuration mutation cannot affect historical or effective output. Bootstrap +delegates to the engine while returning facade-owned report and error types with +detached converted entries and diagnostics. + +The candidate's exported declarations and methods now match the root surface +after normalizing its temporary import path. External integration tests cover +frozen inspection, secret-safe explanation/rendering, malformed and nil result +handling, bootstrap application/skips, detached report entries, and typed +bootstrap failures. Linux validation with Go 1.26.7 passed +`go test ./internal/facade ./internal/engine`, `go test -race ./internal/facade ./internal/engine`, +and `./scripts/check.sh`. There were no deviations or remaining risks. The next +handoff moves the verified candidate to the root and removes migration copies. + ## Stage 8 — Cut over the root and remove migration duplication Status: not started. diff --git a/internal/facade/conversion.go b/internal/facade/conversion.go index 9253c2e..45dafe5 100644 --- a/internal/facade/conversion.go +++ b/internal/facade/conversion.go @@ -89,3 +89,45 @@ func fromEngineChange(value engine.Change) Change { } return result } + +func fromEngineValueSnapshot(value engine.ValueSnapshot) ValueSnapshot { + return ValueSnapshot{Present: value.Present, Redacted: value.Redacted, YAML: value.YAML} +} + +func fromEngineValueChange(value engine.ValueChange) ValueChange { + result := ValueChange{ + Before: fromEngineValueSnapshot(value.Before), + After: fromEngineValueSnapshot(value.After), + Origin: fromEngineOrigin(value.Origin), + Reasons: append([]string(nil), value.Reasons...), + } + if value.Dependencies != nil { + result.Dependencies = make([]Path, len(value.Dependencies)) + for index := range value.Dependencies { + result.Dependencies[index] = fromEnginePath(value.Dependencies[index]) + } + } + return result +} + +func fromEngineExplanation(value engine.Explanation) Explanation { + result := Explanation{ + Path: fromEnginePath(value.Path), + Effective: fromEngineValueSnapshot(value.Effective), + Origin: fromEngineOrigin(value.Origin), + } + if value.History != nil { + result.History = make([]ValueChange, len(value.History)) + for index := range value.History { + result.History[index] = fromEngineValueChange(value.History[index]) + } + } + if value.SecretReference != nil { + result.SecretReference = &SecretReferenceInfo{Name: value.SecretReference.Name} + } + return result +} + +func fromEngineSecretFileResult(value engine.SecretFileResult) SecretFileResult { + return SecretFileResult{Name: value.Name, Path: value.Path, Status: SecretFileStatus(value.Status), ExistingEmpty: value.ExistingEmpty} +} diff --git a/internal/facade/facade_external_test.go b/internal/facade/facade_external_test.go index 1692666..52d3184 100644 --- a/internal/facade/facade_external_test.go +++ b/internal/facade/facade_external_test.go @@ -1,10 +1,14 @@ package configkit_test import ( + "bytes" "encoding/json" "errors" "fmt" + "io" + "os" "reflect" + "strings" "testing" configkit "gitea.maximumdirect.net/eric/ConfigKit/internal/facade" @@ -29,6 +33,10 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) { reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](), reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](), reflect.TypeFor[configkit.Result[struct{}]](), + reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](), + reflect.TypeFor[configkit.SecretReferenceInfo](), reflect.TypeFor[configkit.Explanation](), + reflect.TypeFor[configkit.SecretFileResult](), reflect.TypeFor[configkit.SecretsReport](), + reflect.TypeFor[configkit.SecretsError](), } { if candidate.PkgPath() != packagePath { t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath()) @@ -48,6 +56,118 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) { } } +func TestFacadeInspectionUsesFrozenState(t *testing.T) { + t.Setenv("FACADE_INSPECTION_SECRET", "credential") + + type configuration struct { + Value string `yaml:"value"` + Secret configkit.Secret `yaml:"secret"` + } + result, err := configkit.Load[configuration](configkit.Bytes("inspection.yml", []byte("value: original\nsecret: FACADE_INSPECTION_SECRET\n"))) + if err != nil { + t.Fatal(err) + } + if !result.Config.Secret.IsResolved() || result.Config.Secret.Value() != "credential" { + t.Fatalf("secret = %#v", result.Config.Secret) + } + explanation, ok := result.Explain(configkit.Keys("value")) + if !ok || explanation.Effective.YAML != "original\n" { + t.Fatalf("explanation = %#v, %v", explanation, ok) + } + explanation.Effective.YAML = "mutated\n" + result.Config.Value = "mutated" + var effective bytes.Buffer + if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "value: original\n") || strings.Contains(effective.String(), "credential") { + t.Fatalf("effective = %q, %v", effective.String(), err) + } + again, ok := result.Explain(configkit.Keys("value")) + if !ok || again.Effective.YAML != "original\n" { + t.Fatalf("inspection changed = %#v, %v", again, ok) + } + secret, ok := result.Explain(configkit.Keys("secret")) + if !ok || secret.SecretReference == nil || secret.SecretReference.Name != "FACADE_INSPECTION_SECRET" { + t.Fatalf("secret explanation = %#v, %v", secret, ok) + } + var explanationOutput bytes.Buffer + if err := result.WriteExplanation(&explanationOutput, configkit.Keys("value")); err != nil || !strings.Contains(explanationOutput.String(), `path: "/value"`) { + t.Fatalf("explanation output = %q, %v", explanationOutput.String(), err) + } + + unknown := bytes.NewBufferString("unchanged") + if err := result.WriteExplanation(unknown, configkit.Keys("unknown")); err == nil || unknown.String() != "unchanged" { + t.Fatalf("unknown explanation = %q, %v", unknown.String(), err) + } + var nilResult *configkit.Result[configuration] + if _, ok := nilResult.Explain(configkit.Keys("value")); ok { + t.Fatal("nil result explained a path") + } + if err := nilResult.WriteEffective(io.Discard); err == nil { + t.Fatal("nil result rendered effective configuration") + } +} + +func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) { + const ( + loadedName = "FACADE_BOOTSTRAP_LOADED" + existingName = "FACADE_BOOTSTRAP_EXISTING" + ) + restore := func(name string) { + value, present := os.LookupEnv(name) + t.Cleanup(func() { + if present { + _ = os.Setenv(name, value) + } else { + _ = os.Unsetenv(name) + } + }) + } + restore(loadedName) + restore(existingName) + if err := os.Unsetenv(loadedName); err != nil { + t.Fatal(err) + } + if err := os.Setenv(existingName, ""); err != nil { + t.Fatal(err) + } + directory := t.TempDir() + if err := os.WriteFile(directory+"/"+loadedName, []byte("bootstrap\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(directory+"/"+existingName, []byte("ignored\n"), 0o600); err != nil { + t.Fatal(err) + } + + report, err := configkit.LoadSecretsDir(directory) + if err != nil { + t.Fatal(err) + } + entries := report.Entries() + if len(entries) != 2 || os.Getenv(loadedName) != "bootstrap" { + t.Fatalf("entries/environment = %#v / %q", entries, os.Getenv(loadedName)) + } + status := map[string]configkit.SecretFileResult{} + for _, entry := range entries { + status[entry.Name] = entry + } + if status[loadedName].Status != configkit.SecretFileLoaded || status[existingName].Status != configkit.SecretFileSkippedExisting || !status[existingName].ExistingEmpty { + t.Fatalf("statuses = %#v", status) + } + entries[0].Name = "mutated" + if report.Entries()[0].Name == "mutated" { + t.Fatal("report entries share mutable state") + } + var rendered bytes.Buffer + if _, err := report.WriteTo(&rendered); err != nil || strings.Contains(rendered.String(), "bootstrap") || strings.Contains(rendered.String(), "ignored") { + t.Fatalf("report rendering = %q, %v", rendered.String(), err) + } + + failedReport, err := configkit.LoadSecretsDir("") + var secretsError *configkit.SecretsError + if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath { + t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err) + } +} + func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) { t.Setenv("FACADE_SECRET_NAME", "credential") diff --git a/internal/facade/inspection.go b/internal/facade/inspection.go new file mode 100644 index 0000000..f234827 --- /dev/null +++ b/internal/facade/inspection.go @@ -0,0 +1,70 @@ +package configkit + +import ( + "io" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" +) + +// ValueSnapshot is a detached, deterministic YAML representation of one value. +// Present distinguishes absence or deletion from an explicit null value. +type ValueSnapshot struct { + Present bool + Redacted bool + YAML string +} + +// ValueChange describes one explicit definition or audited domain operation. +type ValueChange struct { + Before ValueSnapshot + After ValueSnapshot + Origin Origin + Reasons []string + Dependencies []Path +} + +// SecretReferenceInfo describes a successfully resolved effective reference. +// Name is the environment-variable name, never its value. +type SecretReferenceInfo struct { + Name string +} + +// Explanation is a detached safe account of one effective or historical path. +type Explanation struct { + Path Path + Effective ValueSnapshot + Origin Origin + History []ValueChange + SecretReference *SecretReferenceInfo +} + +// 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) { + if r == nil { + return Explanation{}, false + } + value, ok := r.inspection.Explain(toEnginePath(path)) + return fromEngineExplanation(value), ok +} + +// 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 { + if r == nil { + var inspection *engine.Inspection + return inspection.WriteEffective(output) + } + return r.inspection.WriteEffective(output) +} + +// 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 { + if r == nil { + var inspection *engine.Inspection + return inspection.WriteExplanation(output, toEnginePath(path)) + } + return r.inspection.WriteExplanation(output, toEnginePath(path)) +} diff --git a/internal/facade/secrets.go b/internal/facade/secrets.go new file mode 100644 index 0000000..e8141a1 --- /dev/null +++ b/internal/facade/secrets.go @@ -0,0 +1,102 @@ +package configkit + +import ( + "errors" + "io" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" +) + +// SecretFileStatus describes the outcome for one file considered by +// LoadSecretsDir. +type SecretFileStatus string + +const ( + SecretFileLoaded SecretFileStatus = "loaded" + SecretFileSkippedExisting SecretFileStatus = "skipped_existing" + SecretFileNotApplied SecretFileStatus = "not_applied" + SecretFileFailed SecretFileStatus = "failed" +) + +// SecretFileResult is the safe, value-free bootstrap result for one directory +// entry. ExistingEmpty is meaningful when Status is SecretFileSkippedExisting. +type SecretFileResult struct { + Name string + Path string + Status SecretFileStatus + ExistingEmpty bool +} + +// SecretsReport is a detached snapshot of an explicit secrets-directory +// bootstrap. It does not retain file contents or environment values. +type SecretsReport struct{ value *engine.SecretsReport } + +// Entries returns a copy of the report's filename-ordered entries. +func (r *SecretsReport) Entries() []SecretFileResult { + if r == nil || r.value == nil { + return nil + } + values := r.value.Entries() + if values == nil { + return nil + } + result := make([]SecretFileResult, len(values)) + for index := range values { + result[index] = fromEngineSecretFileResult(values[index]) + } + return result +} + +// WriteTo renders one safe, deterministic line per entry. Names and paths are +// quoted so control characters cannot affect terminal output. +func (r *SecretsReport) WriteTo(output io.Writer) (int64, error) { + if r == nil || r.value == nil { + return (*engine.SecretsReport)(nil).WriteTo(output) + } + return r.value.WriteTo(output) +} + +// SecretsError reports safe structured diagnostics for a failed explicit +// secrets-directory bootstrap. It intentionally does not unwrap filesystem or +// environment errors. +type SecretsError struct{ value *engine.SecretsError } + +// Error implements error without rendering filenames, paths, or file contents. +func (e *SecretsError) Error() string { + if e == nil || e.value == nil { + return "configkit: secrets bootstrap failed" + } + return e.value.Error() +} + +// Diagnostics returns a deep copy of the bootstrap diagnostics. +func (e *SecretsError) Diagnostics() []Diagnostic { + if e == nil || e.value == nil { + return nil + } + values := e.value.Diagnostics() + if values == nil { + return nil + } + result := make([]Diagnostic, len(values)) + for index := range values { + result[index] = fromEngineDiagnostic(values[index]) + } + return result +} + +// LoadSecretsDir explicitly loads immediate regular files from directory into +// previously unset process environment variables. Applications should call it +// once during startup, before concurrent work; Load never calls it. +func LoadSecretsDir(directory string) (*SecretsReport, error) { + report, err := engine.LoadSecretsDir(directory) + result := &SecretsReport{value: report} + if err == nil { + return result, nil + } + var secretsError *engine.SecretsError + if errors.As(err, &secretsError) { + return result, &SecretsError{value: secretsError} + } + return result, &SecretsError{} +}