//! /v1/validate — gateway-side artifact validation endpoint. //! //! Phase 43 v3 part 2: makes the validator crate network-callable. //! Any caller (scrum loop, test harness, future agent) can POST a //! generated artifact and get back a Report (success) or //! ValidationError (failure with structured field/reason). //! //! Request shape: //! POST /v1/validate //! { //! "kind": "fill" | "email" | "playbook", //! "artifact": { ... }, //! "context": { ... } // optional — folded into artifact._context //! } //! //! Response on success: 200 + Report JSON //! Response on failure: 422 + ValidationError JSON //! Response on bad request: 400 + plain-text error //! //! The shared WorkerLookup is loaded once at gateway startup from //! workers_500k.parquet (path configurable via LH_WORKERS_PARQUET //! env, defaults to data/datasets/workers_500k.parquet). Falls back //! to an empty InMemoryWorkerLookup if the file is missing — the //! validators will still run schema/length/PII checks but worker- //! existence checks will all fail (Consistency error), which is the //! correct behavior when the roster isn't configured. use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use serde::Deserialize; use validator::{ Artifact, Validator, ValidationError, staffing::{ fill::FillValidator, email::EmailValidator, playbook::PlaybookValidator, }, }; #[derive(Deserialize)] pub struct ValidateRequest { /// `"fill" | "email" | "playbook"` — picks which validator runs. pub kind: String, /// The artifact JSON (free-form; shape depends on `kind`). pub artifact: serde_json::Value, /// Optional context bag — merged into `artifact._context` so the /// validator can read fields like `target_count`, `city`, /// `client_id`, `candidate_id` without callers having to embed /// `_context` in the artifact themselves. #[serde(default)] pub context: Option, } pub async fn validate( State(state): State, Json(req): Json, ) -> impl IntoResponse { // Merge context into artifact under `_context` so validators can // pull contract metadata uniformly. let mut artifact_value = req.artifact; if let Some(ctx) = req.context { if let Some(obj) = artifact_value.as_object_mut() { obj.insert("_context".to_string(), ctx); } } // Dispatch. let workers = state.validate_workers.clone(); let result: Result = match req.kind.as_str() { "fill" => FillValidator::new(workers).validate(&Artifact::FillProposal(artifact_value)), "email" => EmailValidator::new(workers).validate(&Artifact::EmailDraft(artifact_value)), "playbook" => PlaybookValidator.validate(&Artifact::Playbook(artifact_value)), other => return ( StatusCode::BAD_REQUEST, format!("unknown kind '{other}' — expected fill | email | playbook"), ).into_response(), }; match result { Ok(report) => (StatusCode::OK, Json(report)).into_response(), Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, Json(e)).into_response(), } }