From 01c08ec8e461bf9892840e5af5b9ae169c0498c3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 3 Oct 2026 17:55:58 +0000 Subject: [PATCH] Preserve inherited presence and validation --- docs/config.md | 8 + docs/roadmap/implementation.md | 2 + internal/core/config/inheritance_test.go | 200 +++++++++++++++++++++++ 3 files changed, 210 insertions(+) diff --git a/docs/config.md b/docs/config.md index 52a30324..06623482 100644 --- a/docs/config.md +++ b/docs/config.md @@ -121,6 +121,14 @@ sequence, so a step cannot extend another step. Replacing `steps` or a validator chain supplies the complete replacement sequence; entries are not appended or merged by identifier. +Inherited values count as explicit input for Notarius's existing +presence-sensitive rules: zero, false, and empty-list overrides stay explicit +instead of restoring a default. Null also replaces the inherited value, but +each field's ordinary type and validation rules still apply; null is not +universally valid. Omitting `extends` preserves a relationship supplied by a +lower-priority source, while `extends: null` cancels that relationship. It +does not independently clear other inherited fields. + `extends` is now a reserved key in every input mapping, including dynamic module options and named maps. Existing ordinary data or entry names using that key must be renamed along with any consumer that reads them. Configuration diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 725c84da..a8942a9e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -168,6 +168,8 @@ syntax section. Presence-sensitive cases belong to Stage 3. ## Stage 3 — Preserve Presence And Existing Domain Validation +> Completion: complete. + Goal: ensure inherited input remains explicit through Notarius normalization, defaulting, and validator-chain conversion. diff --git a/internal/core/config/inheritance_test.go b/internal/core/config/inheritance_test.go index 55af0387..be472cb4 100644 --- a/internal/core/config/inheritance_test.go +++ b/internal/core/config/inheritance_test.go @@ -1,6 +1,9 @@ package config import ( + "errors" + "os" + "path/filepath" "testing" configkit "gitea.maximumdirect.net/eric/ConfigKit" @@ -131,3 +134,200 @@ pipelines: t.Fatalf("Resolve() error = %v", err) } } + +func TestInheritedPresenceOverridesRemainExplicit(t *testing.T) { + t.Parallel() + + loaded, err := loadConfigSource(configkit.Bytes("presence.yml", []byte(`version: 5 +pipelines: + parent: + input: {module: input} + structured_output_repair_attempts: 3 + chunk: + module: generic + validators: [] + artifacts: + score: + extract: + module: extract + validators: [{module: configured-a}] + child: + extends: parent + structured_output_repair_attempts: 0 + artifacts: + score: + extract: + validators: [] +`)), LoadOptions{}) + if err != nil { + var loadError *configkit.LoadError + if errors.As(err, &loadError) { + t.Fatalf("loadConfigSource() diagnostics = %#v", loadError.Diagnostics()) + } + t.Fatalf("loadConfigSource() error = %v", err) + } + + parent := loaded.Config.Pipelines["parent"] + child := loaded.Config.Pipelines["child"] + if parent.Chunk.Module != "generic" || !parent.Chunk.Validators.Set || parent.Chunk.Validators.Validators == nil || len(parent.Chunk.Validators.Validators) != 0 { + t.Fatalf("parent empty chunk validators = %#v", parent.Chunk) + } + if !parent.Artifacts["score"].Extract.Validators.Set || len(parent.Artifacts["score"].Extract.Validators.Validators) != 1 || + parent.Artifacts["score"].Extract.Validators.Validators[0].Module != "configured-a" { + t.Fatalf("parent extraction validators = %#v", parent.Artifacts["score"].Extract.Validators) + } + if parent.StructuredOutputRepairAttempts == nil || *parent.StructuredOutputRepairAttempts != 3 { + t.Fatalf("parent repair budget = %#v", parent.StructuredOutputRepairAttempts) + } + if child.StructuredOutputRepairAttempts == nil || *child.StructuredOutputRepairAttempts != 0 { + t.Fatalf("child zero repair budget = %#v", child.StructuredOutputRepairAttempts) + } + extract := child.Artifacts["score"].Extract + if !extract.Validators.Set || extract.Validators.Validators == nil || len(extract.Validators.Validators) != 0 { + t.Fatalf("child empty extraction validators = %#v", extract.Validators) + } + if !child.Chunk.Validators.Set || child.Chunk.Validators.Validators == nil || len(child.Chunk.Validators.Validators) != 0 { + t.Fatalf("inherited empty chunk validators = %#v", child.Chunk.Validators) + } + + resolved, err := loaded.Config.Resolve(ResolveInput{PipelineID: "child", Catalog: effectiveCatalog(t)}) + if err != nil { + t.Fatalf("Resolve(child) error = %v", err) + } + lanes := resolved.ResolvedPipeline.AllArtifactLanes() + if len(lanes) != 1 || lanes[0].Extract.StructuredOutputRepairAttempts == nil || *lanes[0].Extract.StructuredOutputRepairAttempts != 0 { + t.Fatalf("resolved child repair budget = %#v", lanes) + } + if !resolved.ResolvedPipeline.Chunk.Validators.Set || len(resolved.ResolvedPipeline.Chunk.Validators.Validators) != 0 || + !lanes[0].Extract.Validators.Set || len(lanes[0].Extract.Validators.Validators) != 0 { + t.Fatalf("resolved empty validator overrides = chunk %#v, extraction %#v", resolved.ResolvedPipeline.Chunk.Validators, lanes[0].Extract.Validators) + } +} + +func TestInheritedFalseOptionRetainsExplicitMetadata(t *testing.T) { + t.Parallel() + + loaded, err := loadConfigSource(configkit.Bytes("false-option.yml", []byte(`version: 5 +pipelines: + parent: + input: {module: input} + output: + module: json + options: + evidence_context: {enabled: false} + child: + extends: parent +`)), LoadOptions{}) + if err != nil { + t.Fatalf("loadConfigSource() error = %v", err) + } + options := loaded.Config.Pipelines["child"].Output.Options["evidence_context"].(map[string]any) + if enabled, ok := options["enabled"].(bool); !ok || enabled { + t.Fatalf("inherited evidence_context.enabled = %#v", options["enabled"]) + } + path := configkit.Keys("pipelines", "child", "output", "options", "evidence_context", "enabled") + info, ok := loaded.result.Metadata().Lookup(path) + if !ok || !info.Explicit || info.Null || len(info.Origin.Inheritance) == 0 { + t.Fatalf("inherited false metadata = %#v, found=%v", info, ok) + } +} + +func TestInheritedAndDirectNullRepairBudgetsRemainInvalid(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + source string + path string + }{ + { + name: "child null", + source: `version: 5 +pipelines: + parent: {input: {module: input}, structured_output_repair_attempts: 2} + child: + extends: parent + structured_output_repair_attempts: null +`, + path: "/pipelines/child/structured_output_repair_attempts", + }, + { + name: "inherited null", + source: `version: 5 +pipelines: + parent: {input: {module: input}, structured_output_repair_attempts: null} + child: {extends: parent} +`, + path: "/pipelines/parent/structured_output_repair_attempts", + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := loadConfigSource(configkit.Bytes("null.yml", []byte(test.source)), LoadOptions{}) + assertDocumentDiagnosticAt(t, err, test.path) + }) + } +} + +func TestOverlayAndOverridesComposeBeforeInheritance(t *testing.T) { + t.Parallel() + + root := t.TempDir() + overlays := filepath.Join(root, "overlays") + if err := os.MkdirAll(overlays, 0o700); err != nil { + t.Fatal(err) + } + main := filepath.Join(root, "config.yml") + if err := os.WriteFile(main, []byte(`version: 5 +pipelines: + parent: + input: {module: input} + structured_output_repair_attempts: 1 + inherited: + extends: parent + overridden: + extends: parent + structured_output_repair_attempts: 0 + cancelled: + extends: parent + input: {module: input} +`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(overlays, "development.yml"), []byte(`pipelines: + parent: + structured_output_repair_attempts: 2 + cancelled: + extends: null +`), 0o600); err != nil { + t.Fatal(err) + } + + loaded, err := Load(main, LoadOptions{Overlay: "development"}) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got := *loaded.Config.Pipelines["inherited"].StructuredOutputRepairAttempts; got != 2 { + t.Fatalf("overlay-inherited repair budget = %d, want 2", got) + } + if got := *loaded.Config.Pipelines["overridden"].StructuredOutputRepairAttempts; got != 0 { + t.Fatalf("child replacement repair budget = %d, want 0", got) + } + if loaded.Config.Pipelines["cancelled"].StructuredOutputRepairAttempts != nil { + t.Fatalf("cancelled child inherited parent settings: %#v", loaded.Config.Pipelines["cancelled"]) + } + + withOverride, err := Load(main, LoadOptions{ + Overlay: "development", + Overrides: []configkit.Override{{ + Path: configkit.Keys("pipelines", "parent", "structured_output_repair_attempts"), + Value: 3, + Flag: "--repair-budget", + }}, + }) + if err != nil { + t.Fatalf("Load() with explicit override error = %v", err) + } + if got := *withOverride.Config.Pipelines["inherited"].StructuredOutputRepairAttempts; got != 3 { + t.Fatalf("override-inherited repair budget = %d, want 3", got) + } +}