Closes the Phase 43 PRD's "any caller can validate" surface. The
validator crate (FillValidator + EmailValidator + PlaybookValidator
+ WorkerLookup) is now reachable over HTTP at /v1/validate.
Request/response:
POST /v1/validate
{"kind":"fill"|"email"|"playbook", "artifact":{...}, "context":{...}?}
→ 200 + Report on success
→ 422 + ValidationError on validation failure
→ 400 on bad kind
Boot-time wiring (main.rs):
- Load workers_500k.parquet into a shared Arc<dyn WorkerLookup>
- Path overridable via LH_WORKERS_PARQUET env
- Missing file: warn + fall back to empty InMemoryWorkerLookup so the
endpoint stays live (validators just fail Consistency on every
worker-existence check, which is the correct behavior when the
roster isn't configured)
- Boot log line: "workers parquet loaded from <path>" or
"workers parquet at <path> not found"
- Live boot timing: 500K rows loaded in ~1.4s
V1State gains `validate_workers: Arc<dyn validator::WorkerLookup>`.
The `_context` JSON key is auto-injected from `request.context` so
callers can either embed `_context` directly in `artifact` or split
it cleanly via the `context` field.
Verified live (gateway + 500K worker snapshot):
POST {kind:"fill", phantom W-FAKE-99999} → 422 Consistency
("does not exist in
worker roster")
POST {kind:"fill", real W-1, "Anyone"} → 200 OK + Warning
("differs from
roster name 'Donald
Green'")
POST {kind:"email", body has 123-45-6789} → 422 Policy ("SSN-
shaped sequence")
POST {kind:"nonsense"} → 400 Bad Request
The "0→85% with iteration" thesis can now run end-to-end on real
staffing data: an executor emits a fill_proposal, posts to
/v1/validate, gets a structured ValidationError on phantom IDs or
inactive workers, observer-corrects, retries. Closure of that loop
in a scrum harness is the next commit (separate scope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
83 lines
3.2 KiB
Rust
83 lines
3.2 KiB
Rust
//! /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<serde_json::Value>,
|
|
}
|
|
|
|
pub async fn validate(
|
|
State(state): State<super::V1State>,
|
|
Json(req): Json<ValidateRequest>,
|
|
) -> 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<validator::Report, ValidationError> = 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(),
|
|
}
|
|
}
|