349 lines
12 KiB
Go
349 lines
12 KiB
Go
package contracts
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
MaxDiagnosticReasonCodeBytes = 128
|
|
MaxDiagnosticScopeBytes = 512
|
|
MaxDiagnosticMessageBytes = 4 * 1024
|
|
MaxDiagnosticSamples = 3
|
|
MaxProducerDiagnosticGroups = 64
|
|
)
|
|
|
|
// DiagnosticDisposition identifies the operator significance of a producer
|
|
// finding. Warnings are reserved for process-level degradation or incomplete
|
|
// configured work.
|
|
type DiagnosticDisposition string
|
|
|
|
const (
|
|
DiagnosticDispositionWarning DiagnosticDisposition = "warning"
|
|
DiagnosticDispositionAdvisory DiagnosticDisposition = "advisory"
|
|
DiagnosticDispositionObservation DiagnosticDisposition = "observation"
|
|
)
|
|
|
|
// DiagnosticCategory gives a stable, bounded classification for a producer
|
|
// finding.
|
|
type DiagnosticCategory string
|
|
|
|
const (
|
|
DiagnosticCategoryConfiguration DiagnosticCategory = "configuration"
|
|
DiagnosticCategoryDegradation DiagnosticCategory = "degradation"
|
|
DiagnosticCategoryValidationIncomplete DiagnosticCategory = "validation_incomplete"
|
|
DiagnosticCategoryFallback DiagnosticCategory = "fallback"
|
|
DiagnosticCategoryDataQuality DiagnosticCategory = "data_quality"
|
|
DiagnosticCategoryNormalization DiagnosticCategory = "normalization"
|
|
)
|
|
|
|
// DiagnosticOriginStage identifies the framework operation that promoted a
|
|
// diagnostic. It is framework-owned rather than producer-owned.
|
|
type DiagnosticOriginStage string
|
|
|
|
const (
|
|
DiagnosticOriginStageReferences DiagnosticOriginStage = "references"
|
|
DiagnosticOriginStageChunk DiagnosticOriginStage = "chunk"
|
|
DiagnosticOriginStageExtract DiagnosticOriginStage = "extract"
|
|
DiagnosticOriginStageMerge DiagnosticOriginStage = "merge"
|
|
DiagnosticOriginStageNormalize DiagnosticOriginStage = "normalize"
|
|
)
|
|
|
|
// DiagnosticSample is a bounded, safe example of a diagnostic occurrence.
|
|
// Chunk identity is attached by the framework when it promotes a producer
|
|
// diagnostic into a final group.
|
|
type DiagnosticSample struct {
|
|
Scope string `json:"scope"`
|
|
Message string `json:"message"`
|
|
ChunkID string `json:"chunk_id,omitempty"`
|
|
ChunkIndex *int `json:"chunk_index,omitempty"`
|
|
}
|
|
|
|
// ProducerDiagnostic is the locally grouped form returned by one producer or
|
|
// validator. It intentionally has no pipeline origin.
|
|
type ProducerDiagnostic struct {
|
|
Disposition DiagnosticDisposition `json:"disposition"`
|
|
Category DiagnosticCategory `json:"category"`
|
|
ReasonCode string `json:"reason_code"`
|
|
OccurrenceCount int `json:"occurrence_count"`
|
|
Samples []DiagnosticSample `json:"samples"`
|
|
OmittedSampleCount int `json:"omitted_sample_count"`
|
|
}
|
|
|
|
// DiagnosticOrigin is framework-owned context used to distinguish findings
|
|
// from different pipeline locations during final aggregation.
|
|
type DiagnosticOrigin struct {
|
|
Stage DiagnosticOriginStage `json:"stage"`
|
|
StepID string `json:"step_id,omitempty"`
|
|
LaneID string `json:"lane_id,omitempty"`
|
|
ModuleKey string `json:"module_key,omitempty"`
|
|
ValidatorKey string `json:"validator_key,omitempty"`
|
|
}
|
|
|
|
// DiagnosticGroup is a producer diagnostic after framework origin enrichment.
|
|
type DiagnosticGroup struct {
|
|
Disposition DiagnosticDisposition `json:"disposition"`
|
|
Category DiagnosticCategory `json:"category"`
|
|
ReasonCode string `json:"reason_code"`
|
|
Origin DiagnosticOrigin `json:"origin"`
|
|
OccurrenceCount int `json:"occurrence_count"`
|
|
Samples []DiagnosticSample `json:"samples"`
|
|
OmittedSampleCount int `json:"omitted_sample_count"`
|
|
}
|
|
|
|
// DiagnosticCollection is the grouped collection supplied to later durable
|
|
// and presentation boundaries. Global aggregation policy is applied by the
|
|
// framework before it reaches those boundaries.
|
|
type DiagnosticCollection struct {
|
|
Groups []DiagnosticGroup `json:"groups"`
|
|
Truncated bool `json:"truncated"`
|
|
UnrepresentedOccurrenceCount int `json:"unrepresented_occurrence_count"`
|
|
}
|
|
|
|
// Validate checks a producer-local diagnostic against the public safety and
|
|
// classification contract.
|
|
func (diagnostic ProducerDiagnostic) Validate() error {
|
|
return validateDiagnostic(
|
|
diagnostic.Disposition,
|
|
diagnostic.Category,
|
|
diagnostic.ReasonCode,
|
|
diagnostic.OccurrenceCount,
|
|
diagnostic.Samples,
|
|
diagnostic.OmittedSampleCount,
|
|
false,
|
|
)
|
|
}
|
|
|
|
// ValidateProducerDiagnostics validates the complete set returned by one
|
|
// producer or validator result.
|
|
func ValidateProducerDiagnostics(diagnostics []ProducerDiagnostic) error {
|
|
if len(diagnostics) > MaxProducerDiagnosticGroups {
|
|
return errors.New("producer diagnostics exceed maximum group count")
|
|
}
|
|
for index, diagnostic := range diagnostics {
|
|
if err := diagnostic.Validate(); err != nil {
|
|
return fmt.Errorf("producer diagnostic %d: %w", index, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks framework-owned origin fields.
|
|
func (origin DiagnosticOrigin) Validate() error {
|
|
switch origin.Stage {
|
|
case DiagnosticOriginStageReferences, DiagnosticOriginStageChunk, DiagnosticOriginStageExtract, DiagnosticOriginStageMerge, DiagnosticOriginStageNormalize:
|
|
default:
|
|
return errors.New("diagnostic origin stage is invalid")
|
|
}
|
|
for _, field := range []struct {
|
|
name string
|
|
value string
|
|
}{
|
|
{name: "diagnostic origin step ID", value: origin.StepID},
|
|
{name: "diagnostic origin lane ID", value: origin.LaneID},
|
|
{name: "diagnostic origin module key", value: origin.ModuleKey},
|
|
{name: "diagnostic origin validator key", value: origin.ValidatorKey},
|
|
} {
|
|
if field.value != "" && (!utf8.ValidString(field.value) || strings.TrimSpace(field.value) == "") {
|
|
return fmt.Errorf("%s must be valid nonblank UTF-8 when present", field.name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks a final origin-enriched group.
|
|
func (group DiagnosticGroup) Validate() error {
|
|
if err := group.Origin.Validate(); err != nil {
|
|
return err
|
|
}
|
|
return validateDiagnostic(
|
|
group.Disposition,
|
|
group.Category,
|
|
group.ReasonCode,
|
|
group.OccurrenceCount,
|
|
group.Samples,
|
|
group.OmittedSampleCount,
|
|
true,
|
|
)
|
|
}
|
|
|
|
// Validate checks the collection shape without imposing later global
|
|
// aggregation limits.
|
|
func (collection DiagnosticCollection) Validate() error {
|
|
if collection.UnrepresentedOccurrenceCount < 0 {
|
|
return errors.New("diagnostic collection unrepresented occurrence count must not be negative")
|
|
}
|
|
if !collection.Truncated && collection.UnrepresentedOccurrenceCount != 0 {
|
|
return errors.New("diagnostic collection has unrepresented occurrences without truncation")
|
|
}
|
|
seen := make(map[diagnosticGroupKey]struct{}, len(collection.Groups))
|
|
for index, group := range collection.Groups {
|
|
if err := group.Validate(); err != nil {
|
|
return fmt.Errorf("diagnostic group %d: %w", index, err)
|
|
}
|
|
key := diagnosticGroupKeyFromGroup(group)
|
|
if _, exists := seen[key]; exists {
|
|
return errors.New("diagnostic collection contains duplicate group identity")
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CloneProducerDiagnostics returns independent diagnostic slice ownership.
|
|
func CloneProducerDiagnostics(diagnostics []ProducerDiagnostic) []ProducerDiagnostic {
|
|
if len(diagnostics) == 0 {
|
|
return nil
|
|
}
|
|
cloned := make([]ProducerDiagnostic, len(diagnostics))
|
|
for index, diagnostic := range diagnostics {
|
|
cloned[index] = cloneProducerDiagnostic(diagnostic)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
// CloneDiagnosticCollection returns independent collection ownership.
|
|
func CloneDiagnosticCollection(collection DiagnosticCollection) DiagnosticCollection {
|
|
collection.Groups = make([]DiagnosticGroup, len(collection.Groups))
|
|
for index, group := range collection.Groups {
|
|
collection.Groups[index] = cloneDiagnosticGroup(group)
|
|
}
|
|
return collection
|
|
}
|
|
|
|
func validateDiagnostic(disposition DiagnosticDisposition, category DiagnosticCategory, reasonCode string, occurrenceCount int, samples []DiagnosticSample, omittedSampleCount int, allowChunkContext bool) error {
|
|
if !diagnosticCategoryAllowed(disposition, category) {
|
|
return errors.New("diagnostic disposition and category combination is invalid")
|
|
}
|
|
if err := validateDiagnosticText(reasonCode, MaxDiagnosticReasonCodeBytes, "diagnostic reason code"); err != nil {
|
|
return err
|
|
}
|
|
if occurrenceCount <= 0 {
|
|
return errors.New("diagnostic occurrence count must be positive")
|
|
}
|
|
if len(samples) == 0 {
|
|
return errors.New("diagnostic samples must not be empty")
|
|
}
|
|
if len(samples) > MaxDiagnosticSamples {
|
|
return errors.New("diagnostic samples exceed maximum count")
|
|
}
|
|
seen := make(map[diagnosticSampleKey]struct{}, len(samples))
|
|
for index, sample := range samples {
|
|
if err := validateDiagnosticSample(sample, allowChunkContext); err != nil {
|
|
return fmt.Errorf("diagnostic sample %d: %w", index, err)
|
|
}
|
|
key := diagnosticSampleKeyFromSample(sample)
|
|
if _, exists := seen[key]; exists {
|
|
return errors.New("diagnostic samples must be distinct")
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
if occurrenceCount < len(samples) {
|
|
return errors.New("diagnostic occurrence count is smaller than sample count")
|
|
}
|
|
if omittedSampleCount != occurrenceCount-len(samples) {
|
|
return errors.New("diagnostic omitted sample count is inconsistent")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func diagnosticCategoryAllowed(disposition DiagnosticDisposition, category DiagnosticCategory) bool {
|
|
switch disposition {
|
|
case DiagnosticDispositionWarning:
|
|
return category == DiagnosticCategoryConfiguration || category == DiagnosticCategoryDegradation || category == DiagnosticCategoryValidationIncomplete || category == DiagnosticCategoryFallback
|
|
case DiagnosticDispositionAdvisory:
|
|
return category == DiagnosticCategoryDataQuality
|
|
case DiagnosticDispositionObservation:
|
|
return category == DiagnosticCategoryNormalization
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validateDiagnosticSample(sample DiagnosticSample, allowChunkContext bool) error {
|
|
if err := validateDiagnosticText(sample.Scope, MaxDiagnosticScopeBytes, "diagnostic sample scope"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateDiagnosticText(sample.Message, MaxDiagnosticMessageBytes, "diagnostic sample message"); err != nil {
|
|
return err
|
|
}
|
|
if !allowChunkContext && (sample.ChunkID != "" || sample.ChunkIndex != nil) {
|
|
return errors.New("producer diagnostic sample must not include framework chunk context")
|
|
}
|
|
if sample.ChunkID != "" && (!utf8.ValidString(sample.ChunkID) || strings.TrimSpace(sample.ChunkID) == "") {
|
|
return errors.New("diagnostic sample chunk ID must be valid nonblank UTF-8 when present")
|
|
}
|
|
if sample.ChunkIndex != nil && *sample.ChunkIndex < 0 {
|
|
return errors.New("diagnostic sample chunk index must not be negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDiagnosticText(value string, maximum int, name string) error {
|
|
if !utf8.ValidString(value) {
|
|
return fmt.Errorf("%s must be valid UTF-8", name)
|
|
}
|
|
if strings.TrimSpace(value) == "" {
|
|
return fmt.Errorf("%s must not be blank", name)
|
|
}
|
|
if len(value) > maximum {
|
|
return fmt.Errorf("%s exceeds maximum length", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cloneProducerDiagnostic(diagnostic ProducerDiagnostic) ProducerDiagnostic {
|
|
diagnostic.Samples = cloneDiagnosticSamples(diagnostic.Samples)
|
|
return diagnostic
|
|
}
|
|
|
|
func cloneDiagnosticGroup(group DiagnosticGroup) DiagnosticGroup {
|
|
group.Samples = cloneDiagnosticSamples(group.Samples)
|
|
return group
|
|
}
|
|
|
|
func cloneDiagnosticSamples(samples []DiagnosticSample) []DiagnosticSample {
|
|
if len(samples) == 0 {
|
|
return nil
|
|
}
|
|
cloned := make([]DiagnosticSample, len(samples))
|
|
for index, sample := range samples {
|
|
if sample.ChunkIndex != nil {
|
|
chunkIndex := *sample.ChunkIndex
|
|
sample.ChunkIndex = &chunkIndex
|
|
}
|
|
cloned[index] = sample
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
type diagnosticSampleKey struct {
|
|
scope string
|
|
message string
|
|
chunkID string
|
|
chunkIndex int
|
|
hasChunkIndex bool
|
|
}
|
|
|
|
func diagnosticSampleKeyFromSample(sample DiagnosticSample) diagnosticSampleKey {
|
|
key := diagnosticSampleKey{scope: sample.Scope, message: sample.Message, chunkID: sample.ChunkID}
|
|
if sample.ChunkIndex != nil {
|
|
key.chunkIndex = *sample.ChunkIndex
|
|
key.hasChunkIndex = true
|
|
}
|
|
return key
|
|
}
|
|
|
|
type diagnosticGroupKey struct {
|
|
disposition DiagnosticDisposition
|
|
category DiagnosticCategory
|
|
reasonCode string
|
|
origin DiagnosticOrigin
|
|
}
|
|
|
|
func diagnosticGroupKeyFromGroup(group DiagnosticGroup) diagnosticGroupKey {
|
|
return diagnosticGroupKey{disposition: group.Disposition, category: group.Category, reasonCode: group.ReasonCode, origin: group.Origin}
|
|
}
|