2026-07-03 16:56:00 +00:00
package config
import (
"fmt"
2026-07-17 08:21:24 +00:00
"sort"
2026-07-03 16:56:00 +00:00
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func ( c Config ) Validate ( ) error {
2026-07-28 16:38:05 +00:00
if err := validatePromptKit ( c . PromptKit ) ; err != nil {
2026-07-03 16:56:00 +00:00
return err
}
2026-07-18 12:42:10 +00:00
if err := validateStateSurfaces ( c ) ; err != nil {
2026-07-03 16:56:00 +00:00
return err
}
2026-09-13 16:26:18 +00:00
if err := validateTotalLLMConcurrency ( c . Concurrency . TotalLLM ) ; err != nil {
return err
2026-07-03 16:56:00 +00:00
}
2026-07-17 08:21:24 +00:00
if err := validateStageWorkers ( c . Concurrency ) ; err != nil {
return err
}
2026-07-05 18:03:23 +00:00
return validatePipelineProfiles ( c . Pipelines )
2026-07-03 16:56:00 +00:00
}
2026-07-17 08:21:24 +00:00
func validateStageWorkers ( cfg ConcurrencyConfig ) error {
keys := make ( [ ] string , 0 , len ( cfg . StageWorkers ) )
for key := range cfg . StageWorkers {
keys = append ( keys , key )
}
sort . Strings ( keys )
for _ , key := range keys {
2026-09-13 16:26:18 +00:00
if err := validateStageWorkerKey ( key ) ; err != nil {
return err
2026-07-17 08:21:24 +00:00
}
}
extractWorkers , ok := cfg . StageWorkers [ "extract" ]
if ! ok {
extractWorkers = cfg . TotalLLM
}
2026-09-13 16:26:18 +00:00
return validateExtractWorkerCount ( extractWorkers , cfg . TotalLLM )
2026-07-17 08:21:24 +00:00
}
2026-07-28 16:38:05 +00:00
func validatePromptKit ( cfg PromptKitConfig ) error {
2026-09-13 16:26:18 +00:00
if err := validatePromptKitProfileSources ( cfg . ProfileDir , cfg . ProfileFile ) ; err != nil {
return err
2026-07-03 16:56:00 +00:00
}
2026-09-25 21:56:29 +00:00
if err := validatePromptKitTransientRetries ( cfg . TransientRetries ) ; err != nil {
return err
}
2026-07-30 05:09:54 +00:00
if cfg . LocalBackend == nil {
return nil
}
2026-09-13 16:26:18 +00:00
return validatePromptKitLocalBackend ( cfg . LocalBackend . Endpoint , cfg . LocalBackend . ConcurrencyLimit )
2026-07-03 16:56:00 +00:00
}
2026-07-18 12:42:10 +00:00
func validateStateSurfaces ( cfg Config ) error {
2026-09-13 16:26:18 +00:00
if err := validateRequiredPath ( "output.directory" , cfg . Output . Directory ) ; err != nil {
return err
}
if err := validateRequiredPath ( "debug.directory" , cfg . Debug . Directory ) ; err != nil {
return err
2026-07-18 00:07:53 +00:00
}
2026-09-13 16:26:18 +00:00
if err := validateChunkPlanCacheMode ( string ( cfg . Cache . ChunkPlans . Mode ) ) ; err != nil {
return err
2026-07-18 00:07:53 +00:00
}
2026-09-13 16:26:18 +00:00
if err := validateOptionalPath ( "cache.chunk_plans.directory" , cfg . Cache . ChunkPlans . Directory ) ; err != nil {
return err
2026-07-18 12:42:10 +00:00
}
2026-09-13 16:26:18 +00:00
if err := validateOptionalPath ( "cache.checkpoints.directory" , cfg . Cache . Checkpoints . Directory ) ; err != nil {
return err
2026-07-08 02:18:59 +00:00
}
return nil
}
2026-07-05 18:03:23 +00:00
func validatePipelineProfiles ( profiles map [ string ] pipeline . PipelineProfile ) error {
2026-07-03 16:56:00 +00:00
seen := make ( map [ string ] struct { } , len ( profiles ) )
for rawID , profile := range profiles {
id := strings . TrimSpace ( rawID )
if id == "" {
return fmt . Errorf ( "pipeline id must not be empty" )
}
if _ , ok := seen [ id ] ; ok {
return fmt . Errorf ( "pipeline id %q is duplicated after trimming" , id )
}
seen [ id ] = struct { } { }
if profile . ID != "" && strings . TrimSpace ( profile . ID ) != id {
return fmt . Errorf ( "pipeline %q profile id %q does not match map key" , id , profile . ID )
}
2026-08-25 19:47:42 +00:00
if err := validateStructuredOutputRepairAttempts ( fmt . Sprintf ( "pipeline %q" , id ) , profile . StructuredOutputRepairAttempts ) ; err != nil {
return err
}
2026-08-26 23:34:06 +00:00
if profile . ValidationPolicy != nil {
if err := profile . ValidationPolicy . Validate ( ) ; err != nil {
return fmt . Errorf ( "pipeline %q validation_policy: %w" , id , err )
}
}
2026-07-05 18:03:23 +00:00
if err := validateBinding ( id , "" , "input" , profile . Input , false ) ; err != nil {
2026-07-03 16:56:00 +00:00
return err
}
2026-07-05 18:03:23 +00:00
if err := validateBinding ( id , "" , "chunk" , profile . Chunk , true ) ; err != nil {
2026-07-03 16:56:00 +00:00
return err
}
2026-07-05 18:03:23 +00:00
if err := validateBinding ( id , "" , "output" , profile . Output , false ) ; err != nil {
2026-07-03 16:56:00 +00:00
return err
}
2026-07-05 14:21:36 +00:00
if err := validateReferenceMap ( id , "" , profile . References ) ; err != nil {
return err
}
2026-07-21 21:05:28 +00:00
explicitSteps := profile . Steps != nil
if len ( profile . Artifacts ) > 0 && explicitSteps {
return fmt . Errorf ( "pipeline %q must not declare both artifacts and steps" , id )
}
steps := profile . Steps
if ! explicitSteps {
steps = [ ] pipeline . PipelineStepProfile { { ID : "default" , Artifacts : profile . Artifacts } }
}
if explicitSteps && len ( steps ) == 0 {
return fmt . Errorf ( "pipeline %q must declare at least one ordered step" , id )
}
seenSteps := make ( map [ string ] struct { } , len ( steps ) )
seenLanes := make ( map [ string ] struct { } )
for index , step := range steps {
stepID := strings . TrimSpace ( step . ID )
if stepID == "" {
return fmt . Errorf ( "pipeline %q step[%d] id must not be empty" , id , index )
2026-07-03 16:56:00 +00:00
}
2026-07-21 21:05:28 +00:00
if _ , ok := seenSteps [ stepID ] ; ok {
return fmt . Errorf ( "pipeline %q step id %q is duplicated after trimming" , id , stepID )
2026-07-03 16:56:00 +00:00
}
2026-07-21 21:05:28 +00:00
seenSteps [ stepID ] = struct { } { }
if explicitSteps {
if err := validateReferenceMapForContext ( id , "" , "step " + stepID , step . References , true ) ; err != nil {
return err
}
2026-07-03 16:56:00 +00:00
}
2026-07-21 21:05:28 +00:00
for rawLaneID , lane := range step . Artifacts {
laneID := strings . TrimSpace ( rawLaneID )
if laneID == "" {
return fmt . Errorf ( "pipeline %q artifact lane id must not be empty" , id )
}
if _ , ok := seenLanes [ laneID ] ; ok {
if ! explicitSteps {
return fmt . Errorf ( "pipeline %q artifact lane id %q is duplicated after trimming" , id , laneID )
}
return fmt . Errorf ( "pipeline %q artifact lane id %q is duplicated across steps" , id , laneID )
}
seenLanes [ laneID ] = struct { } { }
if err := validateReferenceMapForContext ( id , laneID , "" , lane . References , true ) ; err != nil {
return err
}
if err := validateBinding ( id , laneID , "extract" , lane . Extract , true ) ; err != nil {
return err
}
if err := validateBinding ( id , laneID , "merge" , lane . Merge , true ) ; err != nil {
return err
}
if err := validateBinding ( id , laneID , "normalize" , lane . Normalize , true ) ; err != nil {
return err
}
if len ( lane . Validators ) > 0 {
return fmt . Errorf ( "pipeline %q lane %q validators are not supported at artifact lane level; use extract.validators, merge.validators, or normalize.validators" , id , laneID )
}
2026-07-03 16:56:00 +00:00
}
}
}
return nil
}
2026-07-05 16:18:00 +00:00
func validateBinding (
pipelineID string ,
laneID string ,
slot string ,
binding pipeline . ModuleBinding ,
referencesAllowed bool ,
) error {
2026-08-26 23:34:06 +00:00
if binding . ValidationPolicy != nil {
switch slot {
case "chunk" , "extract" , "merge" , "normalize" :
default :
return fmt . Errorf ( "%s validation_policy is not supported" , referenceContext ( pipelineID , laneID , slot ) )
}
if err := binding . ValidationPolicy . Validate ( ) ; err != nil {
return fmt . Errorf ( "%s validation_policy: %w" , referenceContext ( pipelineID , laneID , slot ) , err )
}
}
2026-08-25 19:47:42 +00:00
if err := validateStructuredOutputRepairAttempts ( referenceContext ( pipelineID , laneID , slot ) , binding . StructuredOutputRepairAttempts ) ; err != nil {
return err
}
2026-07-05 18:03:23 +00:00
if err := validateBindingLLMProfile ( pipelineID , laneID , slot , binding ) ; err != nil {
2026-07-05 16:18:00 +00:00
return err
}
2026-07-07 19:10:55 +00:00
if binding . Retries < 0 {
if laneID != "" {
return fmt . Errorf ( "pipeline %q lane %q %s retries must be greater than or equal to zero" , pipelineID , laneID , slot )
}
return fmt . Errorf ( "pipeline %q %s retries must be greater than or equal to zero" , pipelineID , slot )
}
2026-07-07 21:21:12 +00:00
if err := validateValidatorOverride ( pipelineID , laneID , slot , binding . Validators ) ; err != nil {
return err
}
2026-07-05 16:18:00 +00:00
if len ( binding . References ) == 0 {
return nil
}
if ! referencesAllowed {
if laneID != "" {
return fmt . Errorf ( "pipeline %q lane %q %s references are not supported" , pipelineID , laneID , slot )
}
return fmt . Errorf ( "pipeline %q %s references are not supported" , pipelineID , slot )
}
2026-07-21 21:05:28 +00:00
return validateReferenceMapForContext ( pipelineID , laneID , slot , binding . References , true )
2026-07-05 16:18:00 +00:00
}
2026-08-25 19:47:42 +00:00
func validateStructuredOutputRepairAttempts ( context string , attempts * int ) error {
if attempts != nil && ( * attempts < 0 || * attempts > 3 ) {
return fmt . Errorf ( "%s structured_output_repair_attempts must be between zero and three" , context )
}
return nil
}
2026-07-07 21:21:12 +00:00
func validateValidatorOverride ( pipelineID string , laneID string , slot string , override pipeline . ValidatorOverride ) error {
if ! override . Set {
return nil
}
switch slot {
case "chunk" , "extract" , "merge" , "normalize" :
default :
return fmt . Errorf ( "%s validators are not supported" , referenceContext ( pipelineID , laneID , slot ) )
}
for i , validator := range override . Validators {
context := fmt . Sprintf ( "%s validators[%d]" , referenceContext ( pipelineID , laneID , slot ) , i )
2026-08-25 19:47:42 +00:00
if err := validateStructuredOutputRepairAttempts ( context , validator . StructuredOutputRepairAttempts ) ; err != nil {
return err
}
2026-07-07 21:21:12 +00:00
if strings . TrimSpace ( validator . Module ) == "" {
return fmt . Errorf ( "%s module must not be empty" , context )
}
if len ( validator . References ) > 0 {
return fmt . Errorf ( "%s references are not supported" , context )
}
if validator . Validators . Set {
return fmt . Errorf ( "%s nested validators are not supported" , context )
}
2026-08-26 23:34:06 +00:00
if validator . ValidationPolicy != nil {
return fmt . Errorf ( "%s validation_policy is not supported" , context )
}
2026-08-26 23:42:11 +00:00
if validator . Retries < 0 {
return fmt . Errorf ( "%s retries must be greater than or equal to zero" , context )
2026-07-07 21:21:12 +00:00
}
if validator . LLMProfile != "" && strings . TrimSpace ( validator . LLMProfile ) == "" {
return fmt . Errorf ( "%s llm_profile must not be empty when set" , context )
}
}
return nil
}
2026-07-21 21:05:28 +00:00
func validateReferenceMap ( pipelineID string , laneID string , references map [ string ] pipeline . ReferenceSource ) error {
return validateReferenceMapForContext ( pipelineID , laneID , "" , references , false )
2026-07-05 16:18:00 +00:00
}
2026-07-21 21:05:28 +00:00
func validateReferenceMapForContext ( pipelineID string , laneID string , slot string , references map [ string ] pipeline . ReferenceSource , generatedAllowed bool ) error {
2026-07-05 14:21:36 +00:00
seen := make ( map [ string ] struct { } , len ( references ) )
2026-07-21 21:05:28 +00:00
for rawSlotName , source := range references {
2026-07-05 14:21:36 +00:00
slotName := strings . TrimSpace ( rawSlotName )
if slotName == "" {
2026-07-05 16:18:00 +00:00
return fmt . Errorf ( "%s reference slot name must not be empty" , referenceContext ( pipelineID , laneID , slot ) )
2026-07-05 14:21:36 +00:00
}
if _ , ok := seen [ slotName ] ; ok {
2026-07-05 16:18:00 +00:00
return fmt . Errorf ( "%s reference slot %q is duplicated after trimming" , referenceContext ( pipelineID , laneID , slot ) , slotName )
2026-07-05 14:21:36 +00:00
}
seen [ slotName ] = struct { } { }
2026-07-21 21:05:28 +00:00
if source . Artifact != nil {
if ! generatedAllowed {
return fmt . Errorf ( "%s reference slot %q must use an external path" , referenceContext ( pipelineID , laneID , slot ) , slotName )
}
if strings . TrimSpace ( source . Artifact . Step ) == "" || strings . TrimSpace ( source . Artifact . Lane ) == "" {
return fmt . Errorf ( "%s reference slot %q artifact selector step and lane must not be empty" , referenceContext ( pipelineID , laneID , slot ) , slotName )
}
if strings . TrimSpace ( source . Path ) != "" {
return fmt . Errorf ( "%s reference slot %q must contain exactly one source form" , referenceContext ( pipelineID , laneID , slot ) , slotName )
}
continue
}
if strings . TrimSpace ( source . Path ) == "" {
2026-07-05 16:18:00 +00:00
return fmt . Errorf ( "%s reference slot %q source must not be empty" , referenceContext ( pipelineID , laneID , slot ) , slotName )
2026-07-05 14:21:36 +00:00
}
}
return nil
}
2026-07-05 16:18:00 +00:00
func referenceContext ( pipelineID string , laneID string , slot string ) string {
if laneID != "" && slot != "" {
return fmt . Sprintf ( "pipeline %q lane %q %s" , pipelineID , laneID , slot )
}
if laneID != "" {
return fmt . Sprintf ( "pipeline %q lane %q" , pipelineID , laneID )
}
if slot != "" {
return fmt . Sprintf ( "pipeline %q %s" , pipelineID , slot )
}
return fmt . Sprintf ( "pipeline %q" , pipelineID )
}
2026-07-03 16:56:00 +00:00
func validateBindingLLMProfile (
pipelineID string ,
laneID string ,
slot string ,
binding pipeline . ModuleBinding ,
) error {
2026-07-05 18:03:23 +00:00
if binding . LLMProfile != "" && strings . TrimSpace ( binding . LLMProfile ) == "" {
if laneID != "" {
return fmt . Errorf ( "pipeline %q lane %q %s llm_profile must not be empty when set" , pipelineID , laneID , slot )
2026-07-03 16:56:00 +00:00
}
2026-07-05 18:03:23 +00:00
return fmt . Errorf ( "pipeline %q %s llm_profile must not be empty when set" , pipelineID , slot )
2026-07-03 16:56:00 +00:00
}
2026-07-05 18:03:23 +00:00
return nil
2026-07-03 16:56:00 +00:00
}