Closes the last "Go primary" backlog item in docs/ARCHITECTURE_COMPARISON.md. Go now owns the entire validator path end-to-end — no Rust dep for staffing safety net. Architecture: cmd/validatord on :3221 hosts both endpoints. Calls chatd directly for the iterate loop's LLM hop (no gateway self-loopback like the Rust shape). Gateway proxies /v1/validate + /v1/iterate to validatord. What's in: - internal/validator/playbook.go — 3rd validator kind (PRD checks: fill: prefix, endorsed_names ≤ target_count×2, fingerprint required) - internal/validator/lookup_jsonl.go — JSONL roster loader (Parquet deferred; producer one-liner documented in package comment) - internal/validator/iterate.go — ExtractJSON helper + Iterate orchestrator with ChatCaller seam for unit tests - cmd/validatord/main.go — HTTP routes, roster load, chat client - internal/shared/config.go — ValidatordConfig + gateway URL field - lakehouse.toml — [validatord] section - cmd/gateway/main.go — proxy routes for /v1/validate + /v1/iterate Smoke: 5/5 PASS through gateway :3110: ✓ playbook happy path ✓ playbook missing fingerprint → 422 schema/fingerprint ✓ phantom candidate W-PHANTOM → 422 consistency ✓ unknown kind → 400 ✓ roster loaded with 3 records go test ./... green across 33 packages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
3.5 KiB
Go
133 lines
3.5 KiB
Go
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
|
||
}
|