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
+313
View File
@@ -0,0 +1,313 @@
// validatord is the staffing-validator service daemon. Hosts:
//
// POST /validate — dispatch a single artifact to FillValidator,
// EmailValidator, or PlaybookValidator
// POST /iterate — generate→validate→correct loop (Phase 43 PRD).
// Calls chatd for the LLM hop and runs the
// validator in-process for the gate.
// GET /health — readiness (always 200; roster status reported
// in /validate responses)
//
// Per docs/SPEC.md and architecture_comparison.md "Go primary path":
// this closes the last bounded item — the now-Go-side validators get
// a network surface so any caller (TS code path, other daemons, agents)
// can validate artifacts via gateway /v1/validate or /v1/iterate.
//
// The roster (worker existence + city/state/role/blacklist) loads
// from a JSONL file at startup. Empty path = no roster, worker-existence
// checks fail Consistency. Production points this at a roster that's
// regenerated from workers_500k.parquet on a schedule.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/validator"
)
const maxRequestBytes = 4 << 20 // 4 MiB
func main() {
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
flag.Parse()
cfg, err := shared.LoadConfig(*configPath)
if err != nil {
slog.Error("config", "err", err)
os.Exit(1)
}
lookup, err := validator.LoadJSONLRoster(cfg.Validatord.RosterPath)
if err != nil {
slog.Error("roster load", "path", cfg.Validatord.RosterPath, "err", err)
os.Exit(1)
}
slog.Info("validatord roster",
"path", cfg.Validatord.RosterPath,
"records", lookup.Len(),
)
chatTimeout := time.Duration(cfg.Validatord.ChatTimeoutSecs) * time.Second
if chatTimeout <= 0 {
chatTimeout = 240 * time.Second
}
h := &handlers{
lookup: lookup,
chatdURL: cfg.Validatord.ChatdURL,
chatClient: &http.Client{Timeout: chatTimeout},
iterCfg: validator.IterateConfig{
DefaultMaxIterations: cfg.Validatord.DefaultMaxIterations,
DefaultMaxTokens: cfg.Validatord.DefaultMaxTokens,
},
}
if err := shared.Run("validatord", cfg.Validatord.Bind, h.register, cfg.Auth); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}
type handlers struct {
lookup validator.WorkerLookup
chatdURL string
chatClient *http.Client
iterCfg validator.IterateConfig
}
func (h *handlers) register(r chi.Router) {
r.Post("/validate", h.handleValidate)
r.Post("/iterate", h.handleIterate)
}
// validateRequest is the request body for POST /validate. Mirrors
// Rust's ValidateRequest in `crates/gateway/src/v1/validate.rs`.
type validateRequest struct {
Kind string `json:"kind"` // "fill" | "email" | "playbook"
Artifact map[string]any `json:"artifact"`
Context map[string]any `json:"context,omitempty"`
}
func (h *handlers) handleValidate(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
defer r.Body.Close()
var req validateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.Kind == "" {
http.Error(w, "kind is required", http.StatusBadRequest)
return
}
if req.Artifact == nil {
http.Error(w, "artifact is required", http.StatusBadRequest)
return
}
report, vErr, kindErr := h.runValidator(req.Kind, req.Artifact, req.Context)
switch {
case kindErr != nil:
http.Error(w, kindErr.Error(), http.StatusBadRequest)
case vErr != nil:
writeJSON(w, http.StatusUnprocessableEntity, vErr)
default:
writeJSON(w, http.StatusOK, report)
}
}
// runValidator dispatches by kind. Returns (Report, ValidationError, kindErr).
// kindErr is non-nil only for unknown kind strings (400).
func (h *handlers) runValidator(kind string, artifact, ctx map[string]any) (*validator.Report, *validator.ValidationError, error) {
merged := mergeContext(artifact, ctx)
a, kindErr := buildArtifact(kind, merged)
if kindErr != nil {
return nil, nil, kindErr
}
v, vErr := pickValidator(kind, h.lookup)
if vErr != nil {
return nil, nil, vErr
}
report, err := v.Validate(a)
if err != nil {
var ve *validator.ValidationError
if errors.As(err, &ve) {
return nil, ve, nil
}
// Validators only ever return ValidationError; an "any other
// error" path means the validator violated its own contract.
// Surface as 500 rather than silently coercing.
return nil, &validator.ValidationError{
Kind: validator.ErrSchema,
Reason: "internal validator error: " + err.Error(),
}, nil
}
return &report, nil, nil
}
// buildArtifact maps the kind string to the right Artifact union arm.
// Unknown kinds return a 400-friendly error.
func buildArtifact(kind string, body map[string]any) (validator.Artifact, error) {
switch kind {
case "fill":
return validator.Artifact{FillProposal: body}, nil
case "email":
return validator.Artifact{EmailDraft: body}, nil
case "playbook":
return validator.Artifact{Playbook: body}, nil
default:
return validator.Artifact{}, fmt.Errorf("unknown kind %q — expected fill | email | playbook", kind)
}
}
func pickValidator(kind string, lookup validator.WorkerLookup) (validator.Validator, error) {
switch kind {
case "fill":
return validator.NewFillValidator(lookup), nil
case "email":
return validator.NewEmailValidator(lookup), nil
case "playbook":
return validator.PlaybookValidator{}, nil
default:
return nil, fmt.Errorf("unknown kind %q", kind)
}
}
// mergeContext folds `context` into `artifact._context` so validators
// pull contract metadata uniformly. Caller-supplied artifact._context
// wins on key collision (caller knows their own contract).
func mergeContext(artifact, ctx map[string]any) map[string]any {
if ctx == nil {
return artifact
}
out := make(map[string]any, len(artifact)+1)
for k, v := range artifact {
out[k] = v
}
existing, _ := out["_context"].(map[string]any)
merged := make(map[string]any, len(ctx)+len(existing))
for k, v := range ctx {
merged[k] = v
}
for k, v := range existing {
merged[k] = v // existing wins
}
out["_context"] = merged
return out
}
func (h *handlers) handleIterate(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes)
defer r.Body.Close()
var req validator.IterateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.Kind == "" || req.Prompt == "" || req.Provider == "" || req.Model == "" {
http.Error(w, "kind, prompt, provider, and model are required", http.StatusBadRequest)
return
}
chat := h.chatCaller()
validate := func(kind string, artifact map[string]any) (validator.Report, error) {
report, vErr, kindErr := h.runValidator(kind, artifact, req.Context)
if kindErr != nil {
return validator.Report{}, &validator.ValidationError{
Kind: validator.ErrSchema,
Reason: kindErr.Error(),
}
}
if vErr != nil {
return validator.Report{}, vErr
}
return *report, nil
}
resp, fail, err := validator.Iterate(r.Context(), req, h.iterCfg, chat, validate)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if fail != nil {
writeJSON(w, http.StatusUnprocessableEntity, fail)
return
}
writeJSON(w, http.StatusOK, resp)
}
// chatCaller wires the iteration loop to chatd via HTTP. Builds the
// chat.Request shape, posts to ${chatdURL}/chat, returns the content
// string (no choices wrapper — chatd's response is already flat).
func (h *handlers) chatCaller() validator.ChatCaller {
return func(ctx context.Context, system, user, _, model string, temp *float64, maxTokens int) (string, error) {
messages := make([]map[string]string, 0, 2)
if system != "" {
messages = append(messages, map[string]string{"role": "system", "content": system})
}
messages = append(messages, map[string]string{"role": "user", "content": user})
body := map[string]any{
"model": model,
"messages": messages,
"max_tokens": maxTokens,
}
if temp != nil {
body["temperature"] = *temp
}
buf, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal chat req: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", h.chatdURL+"/chat", bytes.NewReader(buf))
if err != nil {
return "", fmt.Errorf("build chat req: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := h.chatClient.Do(req)
if err != nil {
return "", fmt.Errorf("chat hop: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return "", fmt.Errorf("chat %d: %s", resp.StatusCode, trim(string(raw), 300))
}
var parsed struct {
Content string `json:"content"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", fmt.Errorf("parse chat resp: %w", err)
}
return parsed.Content, nil
}
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
slog.Error("encode", "err", err)
}
}
func trim(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
+261
View File
@@ -0,0 +1,261 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/validator"
)
// newTestRouter builds the validatord router with an explicit lookup
// + a fake chatd URL. Tests that exercise /iterate need a live mock
// chatd (constructed inline per-test).
func newTestRouter(lookup validator.WorkerLookup, chatdURL string) http.Handler {
h := &handlers{
lookup: lookup,
chatdURL: chatdURL,
chatClient: &http.Client{Timeout: 5 * time.Second},
iterCfg: validator.IterateConfig{
DefaultMaxIterations: 3,
DefaultMaxTokens: 4096,
},
}
r := chi.NewRouter()
h.register(r)
return r
}
// ─── /validate ─────────────────────────────────────────────────
func TestValidate_RejectsUnknownKind(t *testing.T) {
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
body := []byte(`{"kind":"unknown","artifact":{}}`)
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for unknown kind, got %d (body=%s)", w.Code, w.Body.String())
}
}
func TestValidate_RejectsMissingArtifact(t *testing.T) {
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
body := []byte(`{"kind":"playbook"}`)
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing artifact, got %d", w.Code)
}
}
func TestValidate_PlaybookHappyPath(t *testing.T) {
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
body := []byte(`{
"kind": "playbook",
"artifact": {
"operation": "fill: Welder x2 in Toledo, OH",
"endorsed_names": ["W-1","W-2"],
"target_count": 2,
"fingerprint": "abc123"
}
}`)
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
}
var report validator.Report
if err := json.Unmarshal(w.Body.Bytes(), &report); err != nil {
t.Fatalf("decode response: %v", err)
}
if report.ElapsedMs < 0 {
t.Errorf("elapsed_ms negative: %d", report.ElapsedMs)
}
}
func TestValidate_PlaybookSchemaErrorReturns422(t *testing.T) {
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
body := []byte(`{
"kind": "playbook",
"artifact": {
"operation": "wrong_prefix: foo",
"endorsed_names": ["a"],
"fingerprint": "x"
}
}`)
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d (body=%s)", w.Code, w.Body.String())
}
var ve validator.ValidationError
if err := json.Unmarshal(w.Body.Bytes(), &ve); err != nil {
t.Fatalf("decode: %v", err)
}
if ve.Kind != validator.ErrSchema {
t.Errorf("kind = %v, want schema", ve.Kind)
}
}
func TestValidate_FillRoutesThroughLookup(t *testing.T) {
city := "Toledo"
lookup := validator.NewInMemoryWorkerLookup([]validator.WorkerRecord{
{CandidateID: "W-1", Name: "Ada", Status: "active", City: &city},
})
r := newTestRouter(lookup, "")
// Candidate that doesn't exist in lookup → consistency failure.
body := []byte(`{
"kind": "fill",
"artifact": {
"fills": [{"candidate_id":"W-PHANTOM","name":"Nobody"}]
},
"context": {"target_count": 1, "city": "Toledo", "client_id": "C-1"}
}`)
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for phantom candidate, got %d (body=%s)", w.Code, w.Body.String())
}
}
func TestValidate_ContextMergedIntoArtifactContext(t *testing.T) {
// _context.target_count from the request `context` block must
// reach the FillValidator's completeness check. Without the
// merge, target_count would default to 0 and any non-empty fills
// list would fail Completeness.
city := "Toledo"
role := "Welder"
lookup := validator.NewInMemoryWorkerLookup([]validator.WorkerRecord{
{CandidateID: "W-1", Name: "Ada", Status: "active", City: &city, Role: &role},
})
r := newTestRouter(lookup, "")
body := []byte(`{
"kind": "fill",
"artifact": {"fills":[{"candidate_id":"W-1","name":"Ada"}]},
"context": {"target_count": 1, "city": "Toledo", "role": "Welder", "client_id": "C-1"}
}`)
req := httptest.NewRequest("POST", "/validate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with context merged, got %d (body=%s)", w.Code, w.Body.String())
}
}
// ─── /iterate ──────────────────────────────────────────────────
// fakeChatd returns a stand-in chatd HTTP server that emits the given
// content string for every /chat call. Caller closes the server.
func fakeChatd(t *testing.T, content string) *httptest.Server {
t.Helper()
mux := chi.NewRouter()
mux.Post("/chat", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "test-model",
"content": content,
"provider": "test",
"latency_ms": 1,
})
})
return httptest.NewServer(mux)
}
func TestIterate_RejectsMissingFields(t *testing.T) {
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "")
body := []byte(`{"kind":"playbook","prompt":"x"}`) // missing provider+model
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestIterate_HappyPath_ReturnsAcceptedArtifact(t *testing.T) {
server := fakeChatd(t, `{"operation":"fill: Welder x1 in Toledo, OH","endorsed_names":["W-1"],"target_count":1,"fingerprint":"abc"}`)
defer server.Close()
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), server.URL)
body, _ := json.Marshal(map[string]any{
"kind": "playbook",
"prompt": "produce a playbook artifact",
"provider": "ollama",
"model": "qwen3.5:latest",
})
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
}
var resp validator.IterateResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Iterations != 1 {
t.Errorf("iterations = %d, want 1", resp.Iterations)
}
if resp.Artifact["operation"] != "fill: Welder x1 in Toledo, OH" {
t.Errorf("artifact.operation: %v", resp.Artifact["operation"])
}
}
func TestIterate_MaxIterReturns422WithHistory(t *testing.T) {
// Always returns a no-JSON response, so iterate exhausts retries.
server := fakeChatd(t, "no json here, just prose")
defer server.Close()
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), server.URL)
body, _ := json.Marshal(map[string]any{
"kind": "playbook",
"prompt": "produce X",
"provider": "ollama",
"model": "x",
"max_iterations": 2,
})
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d (body=%s)", w.Code, w.Body.String())
}
var fail validator.IterateFailure
if err := json.Unmarshal(w.Body.Bytes(), &fail); err != nil {
t.Fatalf("decode: %v", err)
}
if fail.Iterations != 2 {
t.Errorf("iterations = %d, want 2", fail.Iterations)
}
for _, h := range fail.History {
if h.Status.Kind != "no_json" {
t.Errorf("expected all attempts to be no_json, got %v", h.Status.Kind)
}
}
}
func TestIterate_ChatdDownReturns502(t *testing.T) {
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "http://127.0.0.1:1") // unroutable
body, _ := json.Marshal(map[string]any{
"kind": "playbook",
"prompt": "X",
"provider": "ollama",
"model": "x",
})
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("expected 502, got %d (body=%s)", w.Code, w.Body.String())
}
}