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:
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user