package validator import ( "fmt" "strings" "time" ) // PlaybookValidator is the Go port of Rust's // `crates/validator/src/staffing/playbook.rs`. Sealed playbook // validation per Phase 25: // // - Operation must be a non-empty string starting with `fill:` // - endorsed_names must be a non-empty array, ≤ target_count × 2 // - fingerprint must be non-empty (validity-window requirement) // // PlaybookValidator is stateless — no WorkerLookup dependency, unlike // FillValidator and EmailValidator. The whole validation runs on the // artifact body alone. type PlaybookValidator struct{} // NewPlaybookValidator returns a zero-deps validator. Constructor for // symmetry with the other two; not strictly required. func NewPlaybookValidator() *PlaybookValidator { return &PlaybookValidator{} } // Name satisfies Validator. Matches Rust's "staffing.playbook" so // audit-log scrapes work across runtimes. func (PlaybookValidator) Name() string { return "staffing.playbook" } // Validate runs the four PRD checks. Errors abort the run; warnings // (none today) would attach to a passing Report. func (v PlaybookValidator) Validate(a Artifact) (Report, error) { started := time.Now() if a.Playbook == nil { return Report{}, &ValidationError{ Kind: ErrSchema, Field: "artifact", Reason: fmt.Sprintf("PlaybookValidator expects Playbook, got %s", a.Kind()), } } body := a.Playbook op, ok := stringField(body, "operation") if !ok { return Report{}, &ValidationError{ Kind: ErrSchema, Field: "operation", Reason: "missing or not a string", } } if !strings.HasPrefix(op, "fill:") { return Report{}, &ValidationError{ Kind: ErrSchema, Field: "operation", Reason: fmt.Sprintf("expected `fill: ...` prefix, got %q", op), } } endorsed, ok := body["endorsed_names"].([]any) if !ok { return Report{}, &ValidationError{ Kind: ErrSchema, Field: "endorsed_names", Reason: "missing or not an array", } } if len(endorsed) == 0 { return Report{}, &ValidationError{ Kind: ErrCompleteness, Reason: "endorsed_names must be non-empty", } } if target, ok := uintField(body, "target_count"); ok { max := target * 2 if uint64(len(endorsed)) > max { return Report{}, &ValidationError{ Kind: ErrCompleteness, Reason: fmt.Sprintf("endorsed_names (%d) exceeds target_count × 2 (%d)", len(endorsed), max), } } } if fp, _ := stringField(body, "fingerprint"); fp == "" { return Report{}, &ValidationError{ Kind: ErrSchema, Field: "fingerprint", Reason: "missing — required for Phase 25 validity window", } } return Report{Findings: []Finding{}, ElapsedMs: elapsed(started)}, nil } // stringField returns (val, true) if body[key] is a string, else // ("", false). Matches Rust's serde_json::Value::as_str() shape. func stringField(body map[string]any, key string) (string, bool) { v, ok := body[key] if !ok { return "", false } s, ok := v.(string) return s, ok } // uintField returns (val, true) if body[key] is a non-negative whole // number; matches Rust as_u64. JSON numbers come in as float64, which // is why we do the conversion explicitly. func uintField(body map[string]any, key string) (uint64, bool) { v, ok := body[key] if !ok || v == nil { return 0, false } switch t := v.(type) { case float64: if t < 0 { return 0, false } return uint64(t), true case int: if t < 0 { return 0, false } return uint64(t), true case int64: if t < 0 { return 0, false } return uint64(t), true } return 0, false }