2026-08-05 20:00:20 +00:00
// Package shape validates required D&D item-occurrence candidate fields.
2026-07-25 21:52:59 +00:00
package shape
import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
2026-08-05 20:00:20 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemoccurrences"
2026-07-25 21:52:59 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
const (
2026-08-05 20:00:20 +00:00
Key = "extract/dnd/item-occurrences/shape"
ReasonCode = "invalid_item_occurrence_shape"
2026-08-29 01:24:45 +00:00
policy = "dnd.item_occurrences.shape.v3"
2026-07-25 21:52:59 +00:00
)
type Options struct { }
type Validator struct { }
2026-08-05 19:55:34 +00:00
var _ contracts . TypedValidator [ dnd . ItemOccurrenceList ] = ( * Validator ) ( nil )
2026-07-25 21:52:59 +00:00
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 } }
}
2026-08-05 19:55:34 +00:00
func ( v * Validator ) Validate ( _ context . Context , req contracts . TypedValidationRequest [ dnd . ItemOccurrenceList ] ) ( contracts . ValidationResult , error ) {
2026-08-28 14:37:22 +00:00
assessment := assess ( req . Value )
if len ( assessment . operatorIssues ) != 0 {
return contracts . ValidationResult {
Approved : false ,
ReasonCode : ReasonCode ,
Message : assessment . operatorMessage ( ) ,
CorrectionGuidance : assessment . correctionGuidance ( ) ,
} , nil
2026-07-25 21:52:59 +00:00
}
return contracts . ValidationResult { Approved : true } , nil
}
2026-08-05 20:00:20 +00:00
// Validate returns one bounded error for every owned item-occurrence shape issue.
2026-08-05 19:55:34 +00:00
func Validate ( value dnd . ItemOccurrenceList ) error {
2026-08-28 14:37:22 +00:00
assessment := assess ( value )
if len ( assessment . operatorIssues ) == 0 {
2026-07-25 21:52:59 +00:00
return nil
}
2026-08-28 14:37:22 +00:00
return fmt . Errorf ( "%s" , assessment . operatorMessage ( ) )
2026-07-25 21:52:59 +00:00
}
2026-08-28 14:37:22 +00:00
type validationAssessment struct {
2026-08-29 01:24:45 +00:00
operatorIssues [ ] string
corrections diagnostics . Corrections
2026-08-28 14:37:22 +00:00
}
func assess ( value dnd . ItemOccurrenceList ) validationAssessment {
2026-08-29 01:24:45 +00:00
var assessment validationAssessment
2026-08-05 19:55:34 +00:00
if value . Occurrences == nil {
2026-08-28 14:37:22 +00:00
assessment . operatorIssues = append ( assessment . operatorIssues , "occurrences must be present" )
assessment . addCorrection ( "occurrences" , "item-occurrence list" , "Return an `occurrences` array; use an empty array when the transcript establishes no occurrences." , "" )
return assessment
2026-07-25 21:52:59 +00:00
}
2026-08-05 20:00:20 +00:00
for index , occurrence := range value . Occurrences {
2026-08-05 19:55:34 +00:00
prefix := fmt . Sprintf ( "occurrences[%d]" , index )
2026-08-28 14:37:22 +00:00
context := occurrenceContext ( occurrence )
2026-08-05 20:00:20 +00:00
if strings . TrimSpace ( occurrence . ItemID ) == "" {
2026-08-28 14:37:22 +00:00
assessment . operatorIssues = append ( assessment . operatorIssues , prefix + ".item_id must not be empty: " + diagnostics . Quote ( occurrence . ItemID ) )
assessment . addCorrection ( "item-name" , "item name" , "Select a contextual item name from the supplied item registry for every occurrence." , context )
2026-08-05 19:55:34 +00:00
}
2026-08-05 20:00:20 +00:00
if strings . TrimSpace ( occurrence . Name ) == "" {
2026-08-28 14:37:22 +00:00
assessment . operatorIssues = append ( assessment . operatorIssues , prefix + ".name must not be empty: " + diagnostics . Quote ( occurrence . Name ) )
assessment . addCorrection ( "item-name" , "item name" , "Select a contextual item name from the supplied item registry for every occurrence." , context )
2026-07-25 21:52:59 +00:00
}
2026-08-05 20:00:20 +00:00
if ! itemoccurrences . SupportedKind ( occurrence . Kind ) {
2026-08-28 14:37:22 +00:00
assessment . operatorIssues = append ( assessment . operatorIssues , prefix + ".kind is unsupported: " + diagnostics . Quote ( string ( occurrence . Kind ) ) )
assessment . addCorrection ( "kind" , "occurrence kind" , "Set `kind` to exactly one of `discovered`, `acquired`, `lost`, `consumed`, or `transferred`." , context )
2026-08-05 20:00:20 +00:00
} else if ! itemoccurrences . ValidHolderCombination ( occurrence . Kind , occurrence . From , occurrence . To ) {
2026-08-28 14:37:22 +00:00
assessment . operatorIssues = append ( assessment . operatorIssues , prefix + holderOperatorIssue ( occurrence ) )
assessment . addCorrection ( "holders:" + string ( occurrence . Kind ) , string ( occurrence . Kind ) + " holders" , holderCorrection ( occurrence . Kind ) , context )
2026-07-25 21:52:59 +00:00
}
2026-08-05 20:00:20 +00:00
if occurrence . Quantity != nil && * occurrence . Quantity < 1 {
2026-08-28 14:37:22 +00:00
assessment . operatorIssues = append ( assessment . operatorIssues , prefix + ".quantity must be positive when present" )
assessment . addCorrection ( "quantity" , "quantity" , "Set `quantity` to a positive integer when the transcript states one, or to JSON null when it does not." , context )
2026-07-25 21:52:59 +00:00
}
2026-08-05 20:00:20 +00:00
if len ( occurrence . SourceRefs ) == 0 {
2026-08-28 14:37:22 +00:00
assessment . operatorIssues = append ( assessment . operatorIssues , prefix + ".source_refs must contain at least one reference" )
assessment . addCorrection ( "source-refs" , "source references" , "Provide at least one transcript source range that directly supports every occurrence." , context )
}
}
return assessment
}
func ( assessment * validationAssessment ) addCorrection ( key , label , rule , record string ) {
if record != "" {
2026-08-29 01:24:45 +00:00
record = "Affected " + label + " record: " + record
2026-08-28 14:37:22 +00:00
}
2026-08-29 01:24:45 +00:00
assessment . corrections . Add ( key , rule , record )
2026-08-28 14:37:22 +00:00
}
func ( assessment validationAssessment ) operatorMessage ( ) string {
return diagnostics . Aggregate ( "invalid item occurrence shape" , assessment . operatorIssues )
}
func ( assessment validationAssessment ) correctionGuidance ( ) string {
2026-08-29 01:24:45 +00:00
return assessment . corrections . Guidance ( "Correct every rejected item occurrence and return the complete replacement list" )
2026-08-28 14:37:22 +00:00
}
func holderOperatorIssue ( occurrence dnd . ItemOccurrence ) string {
return fmt . Sprintf (
".from and .to are incompatible with %s: expected %s; got from %s and to %s" ,
diagnostics . Quote ( string ( occurrence . Kind ) ) , holderExpectation ( occurrence . Kind ) ,
holderDisplay ( occurrence . From ) , holderDisplay ( occurrence . To ) ,
)
}
func holderExpectation ( kind dnd . ItemOccurrenceKind ) string {
switch kind {
case dnd . ItemOccurrenceKindDiscovered :
return "both holders absent"
case dnd . ItemOccurrenceKindAcquired :
return "from absent and to present"
case dnd . ItemOccurrenceKindLost :
return "from present and to absent"
case dnd . ItemOccurrenceKindConsumed :
return "from present and to absent"
case dnd . ItemOccurrenceKindTransferred :
2026-08-29 01:24:45 +00:00
return "distinct named party-member holders"
2026-08-28 14:37:22 +00:00
default :
return "a supported holder combination"
}
}
func holderCorrection ( kind dnd . ItemOccurrenceKind ) string {
switch kind {
case dnd . ItemOccurrenceKindDiscovered :
return "For `discovered` occurrences, set both `from` and `to` to JSON null."
case dnd . ItemOccurrenceKindAcquired :
return "For `acquired` occurrences, set `from` to JSON null and `to` to `party` or the named party member gaining possession."
case dnd . ItemOccurrenceKindLost :
return "For `lost` occurrences, set `from` to `party` or the named party member losing possession and set `to` to JSON null."
case dnd . ItemOccurrenceKindConsumed :
return "For `consumed` occurrences, set `from` to `party` or the named party member consuming the item and set `to` to JSON null."
case dnd . ItemOccurrenceKindTransferred :
return "For `transferred` occurrences, set `from` and `to` to two distinct named party members; never use `party` for either holder."
default :
return "Use the holder combination required by the selected supported occurrence kind."
}
}
func occurrenceContext ( occurrence dnd . ItemOccurrence ) string {
name := itemoccurrences . DisplayValue ( occurrence . Name )
context := "item " + diagnostics . Quote ( name )
if name == "" {
context = "item with a blank contextual name"
}
2026-08-29 01:24:45 +00:00
context += " " + diagnostics . SourceRange ( occurrence . SourceRefs )
2026-08-28 14:37:22 +00:00
return context + " with from " + holderDisplay ( occurrence . From ) + " and to " + holderDisplay ( occurrence . To )
}
func holderDisplay ( value string ) string {
value = itemoccurrences . DisplayValue ( value )
if value == "" {
return "JSON null"
2026-07-25 21:52:59 +00:00
}
2026-08-28 14:37:22 +00:00
return diagnostics . Quote ( value )
2026-07-25 21:52:59 +00:00
}
func Spec ( ) pipeline . ValidatorSpec {
return pipeline . ValidatorSpec { Key : Key , ExecutionClass : contracts . ExecutionClassDeterministic }
}
func Register ( registry * pipeline . ValidatorRegistry ) error {
2026-08-05 19:55:34 +00:00
return pipeline . RegisterTypedValidatorBuilder ( registry , dnd . ItemOccurrenceListKind , Spec ( ) , validateOptions , func ( request pipeline . BuildRequest ) ( contracts . TypedValidator [ dnd . ItemOccurrenceList ] , error ) {
2026-07-25 21:52:59 +00:00
options , err := DecodeOptions ( request . Options )
if err != nil {
return nil , err
}
return New ( options ) , nil
} )
}
func DecodeOptions ( options map [ string ] any ) ( Options , error ) {
if err := pipeline . RejectUnknownOptions ( options ) ; err != nil {
return Options { } , err
}
return Options { } , nil
}
func validateOptions ( options map [ string ] any ) error { _ , err := DecodeOptions ( options ) ; return err }