//! Fill-proposal validator (Phase 43 v2 — real consistency checks). //! //! PRD checks: //! - Schema compliance (propose_done shape: `{fills: [{candidate_id, name}]}`) //! - Completeness (endorsed count == target_count) //! - Worker existence (every candidate_id present in workers roster) //! - Status check (worker.status == "active") //! - Client blacklist (worker NOT in client.blacklisted_clients) //! - Geo/role match (worker city/state/role matches contract) //! //! The contract metadata (target_count, city, state, role, client_id) //! travels alongside the JSON payload under a `_context` key: //! `{"_context": {"target_count": 2, "city": "Toledo", "state": "OH", //! "role": "Welder", "client_id": "CLI-00099"}, "fills": [...]}`. //! This keeps the Validator trait signature stable while letting the //! validator cross-check fills against contract truth. //! //! Worker-existence + status + geo + blacklist all share a single //! lookup trait (`WorkerLookup`) so the validator stays decoupled //! from queryd / parquet / catalogd transport details. use crate::{ Artifact, Report, Validator, ValidationError, WorkerLookup, WorkerRecord, }; use std::sync::Arc; use std::time::Instant; pub struct FillValidator { workers: Arc, } impl FillValidator { pub fn new(workers: Arc) -> Self { Self { workers } } } #[derive(Debug, Default)] struct FillContext { target_count: Option, city: Option, state: Option, role: Option, client_id: Option, } fn extract_context(value: &serde_json::Value) -> FillContext { let ctx_obj = value.get("_context").and_then(|c| c.as_object()); let ctx = match ctx_obj { Some(o) => o, None => return FillContext::default(), }; FillContext { target_count: ctx.get("target_count").and_then(|v| v.as_u64()).map(|n| n as usize), city: ctx.get("city").and_then(|v| v.as_str()).map(String::from), state: ctx.get("state").and_then(|v| v.as_str()).map(String::from), role: ctx.get("role").and_then(|v| v.as_str()).map(String::from), client_id: ctx.get("client_id").and_then(|v| v.as_str()).map(String::from), } } fn eq_ci(a: &str, b: &str) -> bool { a.trim().eq_ignore_ascii_case(b.trim()) } impl Validator for FillValidator { fn name(&self) -> &'static str { "staffing.fill" } fn validate(&self, artifact: &Artifact) -> Result { let started = Instant::now(); let value = match artifact { Artifact::FillProposal(v) => v, other => return Err(ValidationError::Schema { field: "artifact".into(), reason: format!("FillValidator expects FillProposal, got {other:?}"), }), }; // ── Schema check ── let fills = value.get("fills").and_then(|f| f.as_array()).ok_or( ValidationError::Schema { field: "fills".into(), reason: "expected top-level `fills` array".into(), }, )?; for (i, fill) in fills.iter().enumerate() { if fill.get("candidate_id").is_none() { return Err(ValidationError::Schema { field: format!("fills[{i}].candidate_id"), reason: "missing".into(), }); } if fill.get("name").is_none() { return Err(ValidationError::Schema { field: format!("fills[{i}].name"), reason: "missing".into(), }); } } let ctx = extract_context(value); // ── Completeness: count match ── if let Some(target) = ctx.target_count { if fills.len() != target { return Err(ValidationError::Completeness { reason: format!( "endorsed count {} != target_count {target}", fills.len() ), }); } } // ── Cross-roster checks ── let mut findings: Vec = vec![]; let mut seen_ids = std::collections::HashSet::new(); for (i, fill) in fills.iter().enumerate() { let candidate_id = fill.get("candidate_id").and_then(|v| v.as_str()).unwrap_or(""); let proposed_name = fill.get("name").and_then(|v| v.as_str()).unwrap_or(""); // Duplicate-ID guard inside one fill. if !seen_ids.insert(candidate_id.to_string()) { return Err(ValidationError::Consistency { reason: format!( "duplicate candidate_id {candidate_id:?} appears multiple times in fills" ), }); } // Worker existence — the gate that catches phantom IDs the // model fabricates. This is the load-bearing check for // the 0→85% pattern. let worker: WorkerRecord = match self.workers.find(candidate_id) { Some(w) => w, None => return Err(ValidationError::Consistency { reason: format!( "fills[{i}].candidate_id {candidate_id:?} does not exist in worker roster" ), }), }; // Status — only "active" workers can be endorsed. if !eq_ci(&worker.status, "active") { return Err(ValidationError::Consistency { reason: format!( "fills[{i}] worker {candidate_id:?} has status {:?}, expected \"active\"", worker.status ), }); } // Client blacklist. if let Some(client) = ctx.client_id.as_deref() { if worker.blacklisted_clients.iter().any(|b| eq_ci(b, client)) { return Err(ValidationError::Policy { reason: format!( "fills[{i}] worker {candidate_id:?} blacklisted for client {client:?}" ), }); } } // Geo / role match — warn-level when missing context, hard // fail on mismatch with explicit contract values. if let (Some(want_city), Some(have_city)) = (ctx.city.as_deref(), worker.city.as_deref()) { if !eq_ci(want_city, have_city) { return Err(ValidationError::Consistency { reason: format!( "fills[{i}] worker {candidate_id:?} city {have_city:?} doesn't match contract city {want_city:?}" ), }); } } if let (Some(want_state), Some(have_state)) = (ctx.state.as_deref(), worker.state.as_deref()) { if !eq_ci(want_state, have_state) { return Err(ValidationError::Consistency { reason: format!( "fills[{i}] worker {candidate_id:?} state {have_state:?} doesn't match contract state {want_state:?}" ), }); } } if let (Some(want_role), Some(have_role)) = (ctx.role.as_deref(), worker.role.as_deref()) { if !eq_ci(want_role, have_role) { return Err(ValidationError::Consistency { reason: format!( "fills[{i}] worker {candidate_id:?} role {have_role:?} doesn't match contract role {want_role:?}" ), }); } } // Name-mismatch is a warning, not an error — recruiters // sometimes send updated names through the proposal layer // before the roster is updated. if !proposed_name.is_empty() && !eq_ci(proposed_name, &worker.name) { findings.push(crate::Finding { field: format!("fills[{i}].name"), severity: crate::Severity::Warning, message: format!( "proposed name {proposed_name:?} differs from roster name {:?} for {candidate_id:?}", worker.name ), }); } } Ok(Report { findings, elapsed_ms: started.elapsed().as_millis() as u64, }) } } #[cfg(test)] mod tests { use super::*; use crate::InMemoryWorkerLookup; use serde_json::json; fn lookup(records: Vec) -> Arc { Arc::new(InMemoryWorkerLookup::from_records(records)) } fn worker(id: &str, name: &str, status: &str, city: &str, state: &str, role: &str) -> WorkerRecord { WorkerRecord { candidate_id: id.into(), name: name.into(), status: status.into(), city: Some(city.into()), state: Some(state.into()), role: Some(role.into()), blacklisted_clients: vec![], } } #[test] fn wrong_artifact_type_fails_schema() { let v = FillValidator::new(lookup(vec![])); let r = v.validate(&Artifact::EmailDraft(json!({}))); assert!(matches!(r, Err(ValidationError::Schema { .. }))); } #[test] fn missing_fills_array_fails_schema() { let v = FillValidator::new(lookup(vec![])); let r = v.validate(&Artifact::FillProposal(json!({}))); assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field == "fills")); } #[test] fn fill_without_candidate_id_fails() { let v = FillValidator::new(lookup(vec![])); let r = v.validate(&Artifact::FillProposal(json!({"fills": [{"name": "Jane"}]}))); assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field.contains("candidate_id"))); } #[test] fn well_formed_proposal_with_real_workers_passes() { let v = FillValidator::new(lookup(vec![ worker("W-1", "Jane Doe", "active", "Toledo", "OH", "Welder"), worker("W-2", "John Smith", "active", "Toledo", "OH", "Welder"), ])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 2, "city": "Toledo", "state": "OH", "role": "Welder"}, "fills": [ {"candidate_id": "W-1", "name": "Jane Doe"}, {"candidate_id": "W-2", "name": "John Smith"} ] }))); assert!(r.is_ok(), "expected pass, got {:?}", r); } #[test] fn phantom_candidate_id_fails_consistency() { let v = FillValidator::new(lookup(vec![worker("W-1", "Jane", "active", "Toledo", "OH", "Welder")])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 1, "city": "Toledo", "state": "OH", "role": "Welder"}, "fills": [{"candidate_id": "W-FAKE-99999", "name": "Imaginary"}] }))); match r { Err(ValidationError::Consistency { reason }) => assert!(reason.contains("does not exist")), other => panic!("expected Consistency error, got {other:?}"), } } #[test] fn inactive_worker_fails_consistency() { let v = FillValidator::new(lookup(vec![worker("W-1", "Jane", "inactive", "Toledo", "OH", "Welder")])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 1}, "fills": [{"candidate_id": "W-1", "name": "Jane"}] }))); match r { Err(ValidationError::Consistency { reason }) => assert!(reason.contains("inactive")), other => panic!("expected Consistency error, got {other:?}"), } } #[test] fn wrong_city_fails_consistency() { let v = FillValidator::new(lookup(vec![worker("W-1", "Jane", "active", "Cincinnati", "OH", "Welder")])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 1, "city": "Toledo", "state": "OH", "role": "Welder"}, "fills": [{"candidate_id": "W-1", "name": "Jane"}] }))); match r { Err(ValidationError::Consistency { reason }) => assert!(reason.to_lowercase().contains("city")), other => panic!("expected Consistency error, got {other:?}"), } } #[test] fn wrong_role_fails_consistency() { let v = FillValidator::new(lookup(vec![worker("W-1", "Jane", "active", "Toledo", "OH", "Driver")])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 1, "city": "Toledo", "state": "OH", "role": "Welder"}, "fills": [{"candidate_id": "W-1", "name": "Jane"}] }))); match r { Err(ValidationError::Consistency { reason }) => assert!(reason.to_lowercase().contains("role")), other => panic!("expected Consistency error, got {other:?}"), } } #[test] fn count_mismatch_fails_completeness() { let v = FillValidator::new(lookup(vec![ worker("W-1", "Jane", "active", "Toledo", "OH", "Welder"), ])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 2, "city": "Toledo", "state": "OH", "role": "Welder"}, "fills": [{"candidate_id": "W-1", "name": "Jane"}] }))); assert!(matches!(r, Err(ValidationError::Completeness { .. }))); } #[test] fn duplicate_candidate_id_fails_consistency() { let v = FillValidator::new(lookup(vec![ worker("W-1", "Jane", "active", "Toledo", "OH", "Welder"), ])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 2, "city": "Toledo", "state": "OH", "role": "Welder"}, "fills": [ {"candidate_id": "W-1", "name": "Jane"}, {"candidate_id": "W-1", "name": "Jane"} ] }))); match r { Err(ValidationError::Consistency { reason }) => assert!(reason.contains("duplicate")), other => panic!("expected Consistency error, got {other:?}"), } } #[test] fn blacklisted_worker_fails_policy() { let mut w = worker("W-1", "Jane", "active", "Toledo", "OH", "Welder"); w.blacklisted_clients = vec!["CLI-00099".into()]; let v = FillValidator::new(lookup(vec![w])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 1, "city": "Toledo", "state": "OH", "role": "Welder", "client_id": "CLI-00099"}, "fills": [{"candidate_id": "W-1", "name": "Jane"}] }))); assert!(matches!(r, Err(ValidationError::Policy { .. }))); } #[test] fn name_mismatch_is_warning_not_error() { let v = FillValidator::new(lookup(vec![ worker("W-1", "Jane Doe", "active", "Toledo", "OH", "Welder"), ])); let r = v.validate(&Artifact::FillProposal(json!({ "_context": {"target_count": 1, "city": "Toledo", "state": "OH", "role": "Welder"}, "fills": [{"candidate_id": "W-1", "name": "Janet Doe"}] }))); let report = r.expect("name mismatch should be warning, not error"); assert_eq!(report.findings.len(), 1); assert_eq!(report.findings[0].severity, crate::Severity::Warning); assert!(report.findings[0].message.contains("differs from roster")); } }