package pipeline import ( "context" "errors" "fmt" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) type validationOutcome string const ( validationApproved validationOutcome = "approved" validationRejected validationOutcome = "rejected" validationFailed validationOutcome = "failed" validationSkipped validationOutcome = "skipped" ) const defaultCorrectionGuidance = "Correct the candidate to satisfy the validator requirements." // validationRecord captures the settled result of one configured validator. // Its fields remain private so reports cannot expose mutable warning storage. type validationRecord struct { validatorName string outcome validationOutcome attemptCount int reasonCode string message string diagnosticPath string warnings []contracts.Warning correctionGuidance string failure error } func (record validationRecord) clone() validationRecord { record.warnings = cloneWarnings(record.warnings) return record } // validationReport is the immutable, ordered result of one validator chain. type validationReport struct { records []validationRecord } func (report validationReport) Records() []validationRecord { records := make([]validationRecord, len(report.records)) for index, record := range report.records { records[index] = record.clone() } return records } func (report validationReport) Warnings() []contracts.Warning { var warnings []contracts.Warning for _, record := range report.records { if record.outcome == validationApproved || record.outcome == validationRejected { warnings = append(warnings, cloneWarnings(record.warnings)...) } } return warnings } func (report validationReport) FirstRejection() *validationRecord { for _, record := range report.records { if record.outcome == validationRejected { clone := record.clone() return &clone } } return nil } func (report validationReport) FirstFailure() *validationRecord { for _, record := range report.records { if record.outcome == validationFailed { clone := record.clone() return &clone } } return nil } func (report validationReport) CorrectionGuidance() string { seen := make(map[string]struct{}) parts := make([]string, 0, len(report.records)) used := 0 for _, record := range report.records { if record.outcome != validationRejected { continue } guidance := strings.TrimSpace(record.correctionGuidance) if guidance == "" { guidance = defaultCorrectionGuidance } if _, exists := seen[guidance]; exists { continue } separator := 0 if len(parts) > 0 { separator = 1 } if used+separator+len(guidance) > contracts.MaxCorrectionGuidanceBytes { break } seen[guidance] = struct{}{} parts = append(parts, guidance) used += separator + len(guidance) } return strings.Join(parts, "\n") } type validationInvocation struct { result contracts.ValidationResult skipped bool reason string message string } func skippedValidation(reason, message string) validationInvocation { return validationInvocation{skipped: true, reason: strings.TrimSpace(reason), message: strings.TrimSpace(message)} } type validationInvoker func(context.Context, preparedValidator, int) (validationInvocation, error) type validationFrameworkError struct{ err error } func (err validationFrameworkError) Error() string { return err.err.Error() } func (err validationFrameworkError) Unwrap() error { return err.err } func fatalValidationError(err error) error { if err == nil { return nil } return validationFrameworkError{err: err} } func executeValidationChain(ctx context.Context, chain preparedValidatorChain, invoke validationInvoker) (validationReport, error) { if ctx == nil { return validationReport{}, errors.New("validator execution context must not be nil") } if invoke == nil { return validationReport{}, errors.New("validator invocation must not be nil") } report := validationReport{records: make([]validationRecord, 0, len(chain.validators))} for index, validator := range chain.validators { validator.position = index + 1 if err := ctx.Err(); err != nil { return validationReport{}, err } binding := validator.resolved.Binding attemptLimit := 1 if validator.resolved.ExecutionClass == contracts.ExecutionClassLLMBacked { attemptLimit += binding.Retries } for attempt := 1; attempt <= attemptLimit; attempt++ { if err := ctx.Err(); err != nil { return validationReport{}, err } invocation, err := invoke(ctx, validator, attempt) if err != nil { var frameworkErr validationFrameworkError if errors.As(err, &frameworkErr) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return validationReport{}, err } if attempt == attemptLimit { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator failed after retry exhaustion", failure: err}) } continue } if invocation.skipped { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationSkipped, attemptCount: attempt, reasonCode: invocation.reason, message: invocation.message}) break } if err := contracts.ValidateValidationResult(invocation.result); err != nil { if attempt == attemptLimit { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator returned an invalid result", failure: err}) } continue } if invocation.result.Approved { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationApproved, attemptCount: attempt, warnings: cloneWarnings(invocation.result.Warnings), diagnosticPath: invocation.result.DiagnosticArtifactPath}) break } reason := invocation.result.ReasonCode if reason == "" { reason = "output_rejected" } message := invocation.result.Message if message == "" { message = "output rejected" } report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationRejected, attemptCount: attempt, reasonCode: reason, message: message, diagnosticPath: invocation.result.DiagnosticArtifactPath, warnings: cloneWarnings(invocation.result.Warnings), correctionGuidance: invocation.result.CorrectionGuidance}) break } } return report, nil } func validatorFailureError(record validationRecord) error { if record.failure != nil { return record.failure } return fmt.Errorf("validator %q failed after %d attempt(s)", record.validatorName, record.attemptCount) } func validatorAttemptPath(base string, attempt int) string { if attempt == 1 { return base } return fmt.Sprintf("%s-validator-%02d", base, attempt) }