diff --git a/docs/internal/configuration.md b/docs/internal/configuration.md index 0f3b8c2c..3cbbfb09 100644 --- a/docs/internal/configuration.md +++ b/docs/internal/configuration.md @@ -15,7 +15,7 @@ runs anything. | Boundary | Inputs | Outputs | Does not own | | --- | --- | --- | --- | -| Loading | Selected file path, profile, overrides, and invocation directory | **LoadedConfig** with ConfigKit inspection metadata and a populated **Config** | Choosing the file path or reporting a command result. | +| Loading | Selected file path, overlay, overrides, and invocation directory | **LoadedConfig** with ConfigKit inspection metadata and a populated **Config** | Choosing the file path or reporting a command result. | | Validation | **Config** | Structural configuration errors with pipeline, lane, or binding context | Module availability, capabilities, or construction. | | Resolution | Valid **Config**, selected pipeline and lanes, runtime reference changes, LLM override, and module catalog | **EffectiveConfig** with a **ResolvedPipeline** | Materializing reference bytes, preparing modules, execution, or filesystem state. | | Inspection | **LoadedConfig** and an optional JSON Pointer | Redacted effective YAML or explanation text | Pipeline selection, module preparation, or provider work. | @@ -34,7 +34,15 @@ inheritance before typed decoding. The document definition supplies typed defaults and structural validation; Notarius's recorded resolution changes normalize configured paths after decode. Path normalization follows the source of the surviving value, including a value copied through inheritance, rather -than the inheritance directive's location. Only +than the inheritance directive's location. Inherited input remains explicit +for presence-sensitive behavior, including zero, false, and empty values; +existing field validation still governs whether the resulting values are valid. +ConfigKit's inspection history retains inheritance transfers alongside later +normalization, and effective configuration renders the normalized values used +by runtime conversion. The private typed document consumes the directive, so +no upstream inheritance type or unresolved directive reaches runtime pipeline +or module contracts. See [Configuration](../config.md#sibling-inheritance) +for the operator syntax and field rules. Only `NOTARIUS_CONFIG` participates in discovery; retired field-level environment variables do not participate in loading. Version 5 decoding rejects scalar module bindings and scalar external references. ConfigKit retains redacted, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 62761b4a..7c25b392 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -286,6 +286,8 @@ Inspection rendering is checked separately in Stage 5. ## Stage 5 — Preserve Inspection, Derivation, And Ownership +> Completion: complete. + Goal: prove inherited configuration remains correctly inspectable after Notarius's recorded normalization and runtime conversion. diff --git a/internal/core/config/inheritance_test.go b/internal/core/config/inheritance_test.go index efa91f67..fde19fe6 100644 --- a/internal/core/config/inheritance_test.go +++ b/internal/core/config/inheritance_test.go @@ -1,9 +1,11 @@ package config import ( + "bytes" "errors" "os" "path/filepath" + "strings" "testing" configkit "gitea.maximumdirect.net/eric/ConfigKit" @@ -433,3 +435,175 @@ configkit: t.Errorf("overlaid inherited step reference path = %q, want %q", got, want) } } + +func TestInheritedInspectionRetainsOriginsRedactionAndOwnership(t *testing.T) { + t.Parallel() + + root := t.TempDir() + parentDirectory := filepath.Join(root, "parents") + childDirectory := filepath.Join(root, "children") + overlayDirectory := filepath.Join(root, "overlays") + for _, directory := range []string{parentDirectory, childDirectory, overlayDirectory} { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + } + parentFile := filepath.Join(parentDirectory, "pipelines.yml") + if err := os.WriteFile(parentFile, []byte(`pipelines: + parent: + input: {module: input} + structured_output_repair_attempts: 2 + references: + source: {path: ./shared.txt} + artifacts: + summary: + extract: + module: extract + options: + nested: {value: original} + credentials: {api_key: fake-parent-secret} +`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parentDirectory, "shared.txt"), []byte("source\n"), 0o600); err != nil { + t.Fatal(err) + } + childFile := filepath.Join(childDirectory, "pipelines.yml") + if err := os.WriteFile(childFile, []byte(`pipelines: + child: + extends: parent + structured_output_repair_attempts: 0 +`), 0o600); err != nil { + t.Fatal(err) + } + main := filepath.Join(root, "notarius.yml") + if err := os.WriteFile(main, []byte(`version: 5 +configkit: + imports: + - path: parents/pipelines.yml + - path: children/pipelines.yml +`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(overlayDirectory, "development.yml"), []byte(`pipelines: + parent: + artifacts: + summary: + extract: + options: + credentials: {api_key: fake-overlay-secret} +`), 0o600); err != nil { + t.Fatal(err) + } + + loaded, err := Load(main, LoadOptions{Overlay: "development"}) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + child := loaded.Config.Pipelines["child"] + wantPath := filepath.Join(parentDirectory, "shared.txt") + if got := child.References["source"].Path; got != wantPath { + t.Fatalf("runtime inherited path = %q, want %q", got, wantPath) + } + if child.StructuredOutputRepairAttempts == nil || *child.StructuredOutputRepairAttempts != 0 { + t.Fatalf("runtime inherited repair setting = %#v", child.StructuredOutputRepairAttempts) + } + + var effective bytes.Buffer + if err := loaded.WriteEffective(&effective); err != nil { + t.Fatalf("WriteEffective() error = %v", err) + } + if !strings.Contains(effective.String(), wantPath) || strings.Contains(effective.String(), "extends:") || + !strings.Contains(effective.String(), "structured_output_repair_attempts: 0") { + t.Fatalf("effective configuration is inconsistent with runtime conversion: %s", effective.String()) + } + for _, secret := range []string{"fake-parent-secret", "fake-overlay-secret"} { + if strings.Contains(effective.String(), secret) { + t.Fatalf("effective configuration exposed %q", secret) + } + } + + for _, test := range []struct { + name string + path configkit.Path + }{ + {name: "repair setting", path: configkit.Keys("pipelines", "child", "structured_output_repair_attempts")}, + {name: "normalized reference", path: configkit.Keys("pipelines", "child", "references", "source", "path")}, + } { + t.Run(test.name, func(t *testing.T) { + explanation, ok := loaded.result.Explain(test.path) + if !ok || len(explanation.History) == 0 { + t.Fatalf("inherited explanation = %#v, found=%v", explanation, ok) + } + inheritanceFromParent := false + for _, change := range explanation.History { + if len(change.Origin.Inheritance) == 0 { + continue + } + for _, location := range change.Origin.Locations { + for _, source := range loaded.result.Sources() { + if source.ID == location.SourceID && source.Path == parentFile { + inheritanceFromParent = true + } + } + } + } + if !inheritanceFromParent { + t.Fatalf("inheritance history lacks parent source evidence: %#v", explanation.History) + } + var rendered bytes.Buffer + if err := loaded.WriteExplanation(&rendered, test.path.String()); err != nil { + t.Fatalf("WriteExplanation() error = %v", err) + } + if !strings.Contains(rendered.String(), filepath.ToSlash(filepath.Join("parents", "pipelines.yml"))) { + t.Fatalf("explanation lacks parent source evidence: %s", rendered.String()) + } + if test.name == "normalized reference" { + foundNormalization := false + for _, change := range explanation.History { + if change.Origin.Operation == configkit.ChangeNormalization { + foundNormalization = true + } + } + if !foundNormalization || !strings.Contains(rendered.String(), wantPath) { + t.Fatalf("normalized path evidence = %#v; rendered %s", explanation.History, rendered.String()) + } + } + }) + } + + secretPath := configkit.Keys("pipelines", "child", "artifacts", "summary", "extract", "options", "credentials", "api_key") + secretExplanation, ok := loaded.result.Explain(secretPath) + if !ok || !secretExplanation.Effective.Redacted { + t.Fatalf("inherited secret explanation = %#v, found=%v", secretExplanation, ok) + } + redactedHistory := false + for _, change := range secretExplanation.History { + redactedHistory = redactedHistory || change.Before.Redacted || change.After.Redacted + } + if !redactedHistory { + t.Fatalf("secret history was not redacted: %#v", secretExplanation.History) + } + var secretOutput bytes.Buffer + if err := loaded.WriteExplanation(&secretOutput, secretPath.String()); err != nil { + t.Fatalf("WriteExplanation(secret) error = %v", err) + } + if strings.Contains(secretOutput.String(), "fake-parent-secret") || strings.Contains(secretOutput.String(), "fake-overlay-secret") || + !strings.Contains(secretOutput.String(), "[REDACTED]") { + t.Fatalf("secret explanation did not redact values and history: %s", secretOutput.String()) + } + + childOptions := loaded.Config.Pipelines["child"].Artifacts["summary"].Extract.Options + childOptions["nested"].(map[string]any)["value"] = "changed" + parentValue := loaded.Config.Pipelines["parent"].Artifacts["summary"].Extract.Options["nested"].(map[string]any)["value"] + if parentValue != "original" { + t.Fatalf("child runtime mutation changed parent value: %#v", parentValue) + } + var frozen bytes.Buffer + if err := loaded.WriteEffective(&frozen); err != nil { + t.Fatalf("WriteEffective() after runtime mutation error = %v", err) + } + if !strings.Contains(frozen.String(), "original") || strings.Contains(frozen.String(), "changed") { + t.Fatalf("runtime mutation changed frozen inspection: %s", frozen.String()) + } +}