Preserve inherited presence and validation

This commit is contained in:
2026-10-03 17:55:58 +00:00
parent d7ea0fd32d
commit 01c08ec8e4
3 changed files with 210 additions and 0 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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)
}
}