2026-07-03 16:56:00 +00:00
package config
import (
"fmt"
2026-07-30 05:09:54 +00:00
"net/url"
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-17 08:21:24 +00:00
c . Concurrency . recomputeStageWorkerDefaults ( )
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
}
if c . Concurrency . TotalLLM <= 0 {
return fmt . Errorf ( "total LLM concurrency must be greater than zero" )
}
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 {
if strings . TrimSpace ( key ) == "" {
return fmt . Errorf ( "concurrency.stage_workers key must not be empty" )
}
if key != "extract" {
return fmt . Errorf ( "concurrency.stage_workers key %q is not supported" , key )
}
}
extractWorkers , ok := cfg . StageWorkers [ "extract" ]
if ! ok {
extractWorkers = cfg . TotalLLM
}
if extractWorkers < 1 || extractWorkers > cfg . TotalLLM {
return fmt . Errorf ( "concurrency.stage_workers.extract must be between 1 and concurrency.total_llm (%d)" , cfg . TotalLLM )
}
return nil
}
2026-07-28 16:38:05 +00:00
func validatePromptKit ( cfg PromptKitConfig ) error {
2026-07-05 18:03:23 +00:00
if strings . TrimSpace ( cfg . ProfileDir ) != "" && strings . TrimSpace ( cfg . ProfileFile ) != "" {
2026-07-28 16:38:05 +00:00
return fmt . Errorf ( "promptkit profile_dir and profile_file are mutually exclusive" )
2026-07-03 16:56:00 +00:00
}
2026-07-30 05:09:54 +00:00
if cfg . LocalBackend == nil {
return nil
}
endpoint := strings . TrimSpace ( cfg . LocalBackend . Endpoint )
if endpoint == "" {
return fmt . Errorf ( "promptkit.local_backend.endpoint must not be empty when set" )
}
parsed , err := url . Parse ( endpoint )
if err != nil ||
( ! strings . EqualFold ( parsed . Scheme , "http" ) && ! strings . EqualFold ( parsed . Scheme , "https" ) ) ||
! parsed . IsAbs ( ) ||
parsed . Hostname ( ) == "" ||
parsed . User != nil ||
parsed . RawQuery != "" ||
parsed . ForceQuery ||
strings . Contains ( endpoint , "#" ) {
return fmt . Errorf ( "promptkit.local_backend.endpoint must be an absolute HTTP or HTTPS URL with a host and no user information, query, or fragment" )
}
if cfg . LocalBackend . ConcurrencyLimit < 0 {
return fmt . Errorf ( "promptkit.local_backend.concurrency_limit must not be negative" )
}
2026-07-03 16:56:00 +00:00
return nil
}
2026-07-18 12:42:10 +00:00
func validateStateSurfaces ( cfg Config ) error {
if strings . TrimSpace ( cfg . Output . Directory ) == "" {
return fmt . Errorf ( "output.directory must not be empty" )
2026-07-18 00:07:53 +00:00
}
2026-07-18 12:42:10 +00:00
if strings . TrimSpace ( cfg . Debug . Directory ) == "" {
return fmt . Errorf ( "debug.directory must not be empty" )
2026-07-18 00:07:53 +00:00
}
2026-07-18 12:42:10 +00:00
if err := cfg . Cache . ChunkPlans . Mode . Validate ( ) ; err != nil {
return fmt . Errorf ( "cache.chunk_plans.mode: %w" , err )
}
for name , value := range map [ string ] string {
"output.directory" : cfg . Output . Directory ,
"cache.chunk_plans.directory" : cfg . Cache . ChunkPlans . Directory ,
"cache.checkpoints.directory" : cfg . Cache . Checkpoints . Directory ,
"debug.directory" : cfg . Debug . Directory ,
} {
if strings . ContainsRune ( value , '\x00' ) {
return fmt . Errorf ( "%s must not contain NUL" , name )
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
}