root f9e72412c1 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>
2026-05-02 03:53:20 -05:00

140 lines
5.4 KiB
Go

// gateway is the Lakehouse-Go HTTP ingress. D6 promotes the D1
// stub endpoints into real reverse-proxies fronting all four backing
// services (storaged, catalogd, ingestd, queryd) on a single bind.
//
// Routes:
// /v1/storage/* → storaged
// /v1/catalog/* → catalogd
// /v1/ingest → ingestd
// /v1/sql → queryd
//
// The /v1 prefix lives at the edge — internal services route on
// /storage, /catalog, /ingest, /sql. Per Kimi K2 finding from the
// D1 plan review: httputil.NewSingleHostReverseProxy preserves the
// inbound path by default, so the proxy helper strips /v1 in its
// Director before forwarding.
package main
import (
"flag"
"log/slog"
"net/url"
"os"
"github.com/go-chi/chi/v5"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/gateway"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
)
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)
}
upstreams := map[string]string{
"storaged_url": cfg.Gateway.StoragedURL,
"catalogd_url": cfg.Gateway.CatalogdURL,
"ingestd_url": cfg.Gateway.IngestdURL,
"queryd_url": cfg.Gateway.QuerydURL,
"vectord_url": cfg.Gateway.VectordURL,
"embedd_url": cfg.Gateway.EmbeddURL,
"pathwayd_url": cfg.Gateway.PathwaydURL,
"matrixd_url": cfg.Gateway.MatrixdURL,
"observerd_url": cfg.Gateway.ObserverdURL,
"chatd_url": cfg.Gateway.ChatdURL,
"validatord_url": cfg.Gateway.ValidatordURL,
}
for k, v := range upstreams {
if v == "" {
slog.Error("config", "err", "gateway."+k+" is required")
os.Exit(1)
}
}
// Per scrum O-WARN2 (Opus): url.Parse is permissive — a typo
// like "127.0.0.1:3211" (missing scheme) parses without error
// but produces empty Host, and every proxied request 502s. Fail
// fast at startup if scheme/host are missing so misconfigs
// surface in `systemctl status gateway` rather than at first traffic.
storagedURL := mustParseUpstream("storaged_url", cfg.Gateway.StoragedURL)
catalogdURL := mustParseUpstream("catalogd_url", cfg.Gateway.CatalogdURL)
ingestdURL := mustParseUpstream("ingestd_url", cfg.Gateway.IngestdURL)
querydURL := mustParseUpstream("queryd_url", cfg.Gateway.QuerydURL)
vectordURL := mustParseUpstream("vectord_url", cfg.Gateway.VectordURL)
embeddURL := mustParseUpstream("embedd_url", cfg.Gateway.EmbeddURL)
pathwaydURL := mustParseUpstream("pathwayd_url", cfg.Gateway.PathwaydURL)
matrixdURL := mustParseUpstream("matrixd_url", cfg.Gateway.MatrixdURL)
observerdURL := mustParseUpstream("observerd_url", cfg.Gateway.ObserverdURL)
chatdURL := mustParseUpstream("chatd_url", cfg.Gateway.ChatdURL)
validatordURL := mustParseUpstream("validatord_url", cfg.Gateway.ValidatordURL)
storagedProxy := gateway.NewProxyHandler(storagedURL)
catalogdProxy := gateway.NewProxyHandler(catalogdURL)
ingestdProxy := gateway.NewProxyHandler(ingestdURL)
querydProxy := gateway.NewProxyHandler(querydURL)
vectordProxy := gateway.NewProxyHandler(vectordURL)
embeddProxy := gateway.NewProxyHandler(embeddURL)
pathwaydProxy := gateway.NewProxyHandler(pathwaydURL)
matrixdProxy := gateway.NewProxyHandler(matrixdURL)
observerdProxy := gateway.NewProxyHandler(observerdURL)
chatdProxy := gateway.NewProxyHandler(chatdURL)
validatordProxy := gateway.NewProxyHandler(validatordURL)
if err := shared.Run("gateway", cfg.Gateway.Bind, func(r chi.Router) {
// Storage / catalog have multi-segment paths under their
// prefix (e.g. /v1/storage/get/<key>). chi's `*` wildcard
// captures the rest of the path.
r.Handle("/v1/storage/*", storagedProxy)
r.Handle("/v1/catalog/*", catalogdProxy)
// Ingest + sql are single endpoints. We accept any method
// (GET/POST/etc) and let the backing service decide. ingestd
// only accepts POST; queryd only accepts POST. Other methods
// will get the backend's 405.
r.Handle("/v1/ingest", ingestdProxy)
r.Handle("/v1/sql", querydProxy)
// Vector search routes — /v1/vectors/index, /v1/vectors/index/{name}/...
r.Handle("/v1/vectors/*", vectordProxy)
// Embedding service — /v1/embed
r.Handle("/v1/embed", embeddProxy)
// Pathway memory — /v1/pathway/*
r.Handle("/v1/pathway/*", pathwaydProxy)
// Matrix indexer — /v1/matrix/* (multi-corpus retrieve+merge per SPEC §3.4)
r.Handle("/v1/matrix/*", matrixdProxy)
// Observer — /v1/observer/* (autonomous-iteration witness loop)
r.Handle("/v1/observer/*", observerdProxy)
// Chat — /v1/chat (LLM dispatcher) + /v1/chat/providers
r.Handle("/v1/chat", chatdProxy)
r.Handle("/v1/chat/*", chatdProxy)
// Validator — /v1/validate (single-shot) + /v1/iterate (loop)
r.Handle("/v1/validate", validatordProxy)
r.Handle("/v1/iterate", validatordProxy)
}, cfg.Auth); err != nil {
slog.Error("server", "err", err)
os.Exit(1)
}
}
// mustParseUpstream parses an upstream URL string and validates that
// scheme + host are non-empty. Exits the process on failure — gateway
// can't function without a valid upstream so failing fast is the
// right call. Per scrum O-WARN2.
func mustParseUpstream(name, raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
slog.Error("config", "err", "parse "+name+": "+err.Error())
os.Exit(1)
}
if u.Scheme == "" || u.Host == "" {
slog.Error("config", "err", name+" must include scheme + host (got "+raw+")")
os.Exit(1)
}
return u
}