validatord: /v1/validate + /v1/iterate HTTP surface (port 3221)

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>
This commit is contained in:
root
2026-05-02 03:53:20 -05:00
co-authored by Claude Opus 4.7
parent 09299a27b7
commit f9e72412c1
15 changed files with 1583 additions and 6 deletions
+132
View File
@@ -0,0 +1,132 @@
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
}