// 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] }