Retain inherited configuration inspection provenance
This commit is contained in:
@@ -15,7 +15,7 @@ runs anything.
|
|||||||
|
|
||||||
| Boundary | Inputs | Outputs | Does not own |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
defaults and structural validation; Notarius's recorded resolution changes
|
||||||
normalize configured paths after decode. Path normalization follows the source
|
normalize configured paths after decode. Path normalization follows the source
|
||||||
of the surviving value, including a value copied through inheritance, rather
|
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
|
`NOTARIUS_CONFIG` participates in discovery; retired field-level environment
|
||||||
variables do not participate in loading. Version 5 decoding rejects scalar
|
variables do not participate in loading. Version 5 decoding rejects scalar
|
||||||
module bindings and scalar external references. ConfigKit retains redacted,
|
module bindings and scalar external references. ConfigKit retains redacted,
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ Inspection rendering is checked separately in Stage 5.
|
|||||||
|
|
||||||
## Stage 5 — Preserve Inspection, Derivation, And Ownership
|
## Stage 5 — Preserve Inspection, Derivation, And Ownership
|
||||||
|
|
||||||
|
> Completion: complete.
|
||||||
|
|
||||||
Goal: prove inherited configuration remains correctly inspectable after
|
Goal: prove inherited configuration remains correctly inspectable after
|
||||||
Notarius's recorded normalization and runtime conversion.
|
Notarius's recorded normalization and runtime conversion.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
@@ -433,3 +435,175 @@ configkit:
|
|||||||
t.Errorf("overlaid inherited step reference path = %q, want %q", got, want)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user