//! Staffing task-class rules for the TruthStore. //! //! Phase 42 PRD: "Staffing rules ship first. Terraform/Ansible rule //! shapes are scaffolded but unpopulated until the long-horizon phase." //! This module owns the staffing rule set; `devops.rs` holds the //! matching scaffold for the DevOps long-horizon. //! //! Rules registered here live under the task classes `staffing.fill` //! (fill proposals), `staffing.rescue` (rescue escalations), and //! `staffing.any` (rules that apply across all staffing task classes — //! PII redaction being the canonical example). //! //! All rules are evaluated via the `TruthStore::evaluate` walk, which //! pairs each rule's `RuleCondition` against a caller-supplied JSON //! context and emits a `RuleOutcome { passed, action }` per rule. //! Downstream enforcement (router gate, SQL gate, execution-loop gate) //! decides how to apply the action — `Reject` / `Block` shortcircuit, //! `Redact` mutates, `Pass` is informational. use crate::{RuleAction, RuleCondition, TruthRule, TruthStore}; /// Register the staffing rule set on an existing store. Returns the /// store for chaining if the caller wants to fold other rule sets on /// top (e.g. `staffing_rules(devops_rules(TruthStore::new()))`). pub fn staffing_rules(mut store: TruthStore) -> TruthStore { store.add_rule(TruthRule { id: "worker-active".to_string(), task_class: "staffing.fill".to_string(), description: "Worker must be active".to_string(), condition: RuleCondition::FieldEquals { field: "worker.status".to_string(), value: "active".to_string(), }, action: RuleAction::Pass, }); store.add_rule(TruthRule { id: "client-not-blacklisted".to_string(), task_class: "staffing.fill".to_string(), description: "Worker cannot be blacklisted for client".to_string(), condition: RuleCondition::FieldEquals { field: "worker.client_blacklisted".to_string(), value: "false".to_string(), }, action: RuleAction::Pass, }); store.add_rule(TruthRule { id: "deadline-required".to_string(), task_class: "staffing.fill".to_string(), description: "Contract must have deadline".to_string(), condition: RuleCondition::FieldEmpty { field: "contract.deadline".to_string(), }, action: RuleAction::Reject { message: "Contract deadline is required".to_string(), }, }); store.add_rule(TruthRule { id: "budget-required".to_string(), task_class: "staffing.fill".to_string(), description: "Budget must be non-negative".to_string(), condition: RuleCondition::FieldGreater { field: "contract.budget_per_hour_max".to_string(), threshold: 0, }, action: RuleAction::Pass, }); store.add_rule(TruthRule { id: "pii-redact".to_string(), task_class: "staffing.any".to_string(), description: "Redact PII before cloud calls".to_string(), condition: RuleCondition::Always, action: RuleAction::Redact { fields: vec!["ssn".to_string(), "salary".to_string()], }, }); store } #[cfg(test)] mod tests { use super::*; #[test] fn staffing_rules_registers_five_rules() { // 4 staffing.fill rules + 1 staffing.any rule = 5 total. // Regression guard: if someone adds a rule to this module // without updating the count, this test surfaces it. let store = staffing_rules(TruthStore::new()); let fill = store.get_rules("staffing.fill").len(); let any = store.get_rules("staffing.any").len(); assert_eq!(fill, 4); assert_eq!(any, 1); } #[test] fn blacklisted_worker_fails_the_rule() { let store = staffing_rules(TruthStore::new()); let ctx = serde_json::json!({ "worker": { "client_blacklisted": "true" } }); let outcomes = store.evaluate("staffing.fill", &ctx); let blk = outcomes.iter().find(|o| o.rule_id == "client-not-blacklisted").unwrap(); assert!(!blk.passed, "blacklisted worker must fail the rule"); } #[test] fn missing_deadline_fires_reject_via_empty_condition() { let store = staffing_rules(TruthStore::new()); // FieldEmpty passes when the field is missing — and the rule's // action is Reject, so enforcement should fire. let ctx = serde_json::json!({}); let outcomes = store.evaluate("staffing.fill", &ctx); let deadline = outcomes.iter().find(|o| o.rule_id == "deadline-required").unwrap(); assert!(deadline.passed); match &deadline.action { RuleAction::Reject { message } => assert!(message.contains("deadline")), _ => panic!("expected Reject action"), } } }