diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 988e988..2129def 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -358,15 +358,19 @@ validator defers when shape is invalid, then checks every non-empty spell name against the immutable effective SRD and overlay catalog. It accepts normalized canonical names and aliases without rewriting the artifact; unknown names reject the complete result with bounded, stable index/name diagnostics. The -source-reference validator applies generic source-reference validation to every -cited range. The relatedness validator resolves all cited ranges through the -shared document-order traversal, then warns when a case-insensitive spell name -is absent from the cited source text. Invalid shape or cited ranges produce no -relatedness warnings; the shape and source-reference validators own those -defects. +source-reference validator defers malformed shapes, validates every cited +range, and reports all range defects through a bounded aggregate while +preserving `invalid_source_refs`. The relatedness validator resolves all cited +ranges through the shared document-order traversal, then warns when a +case-insensitive spell name is absent from the cited source text. Invalid shape +or cited ranges produce no relatedness warnings; the shape and source-reference +validators own those defects. -These validators are deterministic. Their selectable keys and production order -are defined in +These validators are deterministic. Shape, source-reference, and relatedness +each expose a local semantic `policy` checkpoint fingerprint. The catalog +validator instead exposes its effective catalog digest as its semantic +checkpoint identity and does not add a separate policy fingerprint. Their +selectable keys and production order are defined in [Configuration](../config.md#implemented-production-validators); their durable payload rules are defined in the [artifact contract](../integrations/dnd-spell-artifacts.md). @@ -374,23 +378,26 @@ payload rules are defined in the ## D&D NPC Validators NPC shape validation checks required strings, arrays, and source-reference -shape. The source-reference validator checks current-document identity, unit -existence, and range ordering; source relatedness uses the shared document-order -traversal and normalized consecutive-token matching, emitting at most one -bounded warning per record when neither the canonical name nor an alias occurs -near its cited text. Invalid shape or cited ranges produce no relatedness -warnings. Normalize identity validation checks deterministic IDs, canonical -names, aliases, and cross-record ownership or canonical collisions. All are -deterministic and expose the policy fingerprints used by the production chains. +shape. The source-reference validator defers malformed shapes, checks +current-document identity, unit existence, and range ordering, and reports all +defects through bounded aggregates. Source relatedness uses the shared +document-order traversal and normalized consecutive-token matching, emitting at +most one bounded warning per record when neither the canonical name nor an +alias occurs near its cited text. Invalid shape or cited ranges produce no +relatedness warnings. Normalize identity validation checks deterministic IDs, +canonical names, aliases, and cross-record ownership or canonical collisions. +All are deterministic and expose the policy fingerprints used by the +production chains. ## D&D Combat Validators Combat shape validation owns required arrays, strings, nullable values, positive rounds, and supported enums. Combat source-reference validation defers invalid -shape and checks source identity, unit existence, and range order. Combat -source-relatedness defers invalid shape or ranges, uses the shared traversal to -combine overlapping cited units in document order, and emits at most one -bounded advisory warning per turn for unrelated actors or declaration text. +shape, checks source identity, unit existence, and range order, and reports all +defects through bounded aggregates. Combat source-relatedness defers invalid +shape or ranges, uses the shared traversal to combine overlapping cited units +in document order, and emits at most one bounded advisory warning per turn for +unrelated actors or declaration text. Actors use normalized consecutive-token matching; declarations retain the minimum four-rune token heuristic. The normalized-invariants validator owns display normalization, comparison-unique targets, canonical diff --git a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go index 5ae2b0c..24d7d2b 100644 --- a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "unicode/utf8" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" @@ -53,6 +54,22 @@ func TestValidatorDefersMalformedShape(t *testing.T) { } } +func TestValidatorBoundsAggregateDiagnostics(t *testing.T) { + value := validCombatTurnList() + value.CombatTurns[0].SourceRefs = make([]source.SourceRef, 24) + for index := range value.CombatTurns[0].SourceRefs { + value.CombatTurns[0].SourceRefs[index] = source.SourceRef{SourceID: "session", StartUnitID: 99 + index, EndUnitID: 99 + index} + } + value.CombatTurns[0].SourceRefs[0].SourceID = strings.Repeat("火", 220) + "\n\t" + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) { + t.Fatalf("Validate() = %#v, %v; want bounded aggregate rejection", result, err) + } + if strings.Count(result.Message, "combat_turns[0].source_refs[") != 20 || !strings.Contains(result.Message, "source_refs[19]") || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, "…") { + t.Fatalf("message = %q, want all bounded aggregate diagnostics", result.Message) + } +} + func TestSpecRegisterOptionsAndPolicy(t *testing.T) { if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { t.Fatalf("CheckpointFingerprints() = %#v, want source-reference policy", got) diff --git a/internal/modules/dnd/validate/spells/shape/validator.go b/internal/modules/dnd/validate/spells/shape/validator.go index 52825ab..43fefe0 100644 --- a/internal/modules/dnd/validate/spells/shape/validator.go +++ b/internal/modules/dnd/validate/spells/shape/validator.go @@ -10,19 +10,26 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" ) -const Key = "extract/dnd/spells/shape" -const ReasonCode = "invalid_spell_shape" +const ( + Key = "extract/dnd/spells/shape" + ReasonCode = "invalid_spell_shape" + policy = "dnd.spells.validator.shape.v1" +) type Options struct{} type Validator struct{} var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } func (v *Validator) Name() string { return Key } func (v *Validator) ExecutionClass() contracts.ExecutionClass { return contracts.ExecutionClassDeterministic } +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) { if err := Validate(req.Value); err != nil { return rejection(err.Error()), nil diff --git a/internal/modules/dnd/validate/spells/shape/validator_test.go b/internal/modules/dnd/validate/spells/shape/validator_test.go index b7340b1..7703295 100644 --- a/internal/modules/dnd/validate/spells/shape/validator_test.go +++ b/internal/modules/dnd/validate/spells/shape/validator_test.go @@ -48,7 +48,13 @@ func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) { } } -func TestSpecAndRegister(t *testing.T) { +func TestValidatorSpecCheckpointAndRegistration(t *testing.T) { + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got) + } + if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v, want deterministic shape validator", spec) + } registry := pipeline.NewValidatorRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) diff --git a/internal/modules/dnd/validate/spells/source_refs/validator.go b/internal/modules/dnd/validate/spells/source_refs/validator.go index 2a1f109..7a1290a 100644 --- a/internal/modules/dnd/validate/spells/source_refs/validator.go +++ b/internal/modules/dnd/validate/spells/source_refs/validator.go @@ -8,33 +8,45 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" ) -const Key = "extract/dnd/spells/source_refs" -const ReasonCode = "invalid_source_refs" +const ( + Key = "extract/dnd/spells/source_refs" + ReasonCode = "invalid_source_refs" + policy = "dnd.spells.validator.source_refs.v1" +) type Options struct{} type Validator struct{} var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } func (v *Validator) Name() string { return Key } func (v *Validator) ExecutionClass() contracts.ExecutionClass { return contracts.ExecutionClassDeterministic } +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) { if err := spellshape.Validate(req.Value); err != nil { - return rejection(err.Error()), nil + return contracts.ValidationResult{Approved: true}, nil } + issues := make([]string, 0) for spellIndex, spell := range req.Value.SpellCasts { for refIndex, ref := range spell.SourceRefs { if err := source.ValidateRef(req.Source, ref); err != nil { - return rejection(fmt.Sprintf("spell_casts[%d].source_refs[%d]: %v", spellIndex, refIndex, err)), nil + issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error()))) } } } + if len(issues) > 0 { + return rejection(diagnostics.Aggregate("invalid spell source references", issues)), nil + } return contracts.ValidationResult{Approved: true}, nil } func Spec() pipeline.ValidatorSpec { diff --git a/internal/modules/dnd/validate/spells/source_refs/validator_test.go b/internal/modules/dnd/validate/spells/source_refs/validator_test.go index df96ed2..0addc37 100644 --- a/internal/modules/dnd/validate/spells/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/spells/source_refs/validator_test.go @@ -2,7 +2,9 @@ package sourcerefs import ( "context" + "strings" "testing" + "unicode/utf8" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" @@ -46,7 +48,37 @@ func TestValidatorRejectsMissingSourceDocument(t *testing.T) { } } -func TestSpecAndRegister(t *testing.T) { +func TestValidatorDefersMalformedShape(t *testing.T) { + value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Cure Wounds"}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: value}) + if err != nil || !result.Approved || result.ReasonCode != "" || result.Message != "" { + t.Fatalf("Validate() = %#v, %v; want shape deferral", result, err) + } +} + +func TestValidatorAggregatesAndBoundsInvalidSourceReferences(t *testing.T) { + value := requestWithValue(validDocument(), source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}).Value + value.SpellCasts[0].SourceRefs = make([]source.SourceRef, 24) + for index := range value.SpellCasts[0].SourceRefs { + value.SpellCasts[0].SourceRefs[index] = source.SourceRef{SourceID: "session", StartUnitID: 99 + index, EndUnitID: 99 + index} + } + value.SpellCasts[0].SourceRefs[0].SourceID = strings.Repeat("火", 220) + "\n\t" + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) { + t.Fatalf("Validate() = %#v, %v; want bounded aggregate rejection", result, err) + } + if strings.Count(result.Message, "spell_casts[") != 20 || !strings.Contains(result.Message, "source_refs[19]") || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, "…") { + t.Fatalf("message = %q, want all bounded aggregate diagnostics", result.Message) + } +} + +func TestValidatorSpecCheckpointAndRegistration(t *testing.T) { + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got) + } + if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v, want deterministic source-reference validator", spec) + } registry := pipeline.NewValidatorRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator.go b/internal/modules/dnd/validate/spells/source_relatedness/validator.go index 72b6666..aafa0d9 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator.go @@ -12,19 +12,26 @@ import ( spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" ) -const Key = "extract/dnd/spells/source_relatedness" -const WarningReasonCode = "spell_not_near_source" +const ( + Key = "extract/dnd/spells/source_relatedness" + WarningReasonCode = "spell_not_near_source" + policy = "dnd.spells.validator.source_relatedness.v1" +) type Options struct{} type Validator struct{} var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } func (v *Validator) Name() string { return Key } func (v *Validator) ExecutionClass() contracts.ExecutionClass { return contracts.ExecutionClassDeterministic } +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) { if err := spellshape.Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: true}, nil diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go index bc6de79..e0304a8 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go @@ -90,7 +90,13 @@ func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) { } } -func TestSpecAndRegister(t *testing.T) { +func TestValidatorSpecCheckpointAndRegistration(t *testing.T) { + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got) + } + if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v, want deterministic relatedness validator", spec) + } registry := pipeline.NewValidatorRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err)