lakehouse/crates/truth/src/staffing.rs
root 049a4b69fb truth: split staffing + devops into dedicated modules (Phase 42 PRD)
Phase 42 PRD (docs/CONTROL_PLANE_PRD.md:137) specified:
  - crates/truth/src/staffing.rs — staffing rule shapes
  - crates/truth/src/devops.rs — scaffold for DevOps long-horizon

PHASES.md marked Phase 42 done, but the rule sets lived inline in
default_truth_store() in lib.rs. Worked, but doesn't match the PRD's
module separation — and that separation matters when the long-horizon
phase fleshes out devops rules: "Keeps the dispatcher signature stable
so no refactor needed later."

Fix: extract staffing_rules() into staffing.rs (5 rules, unchanged
behavior) + create devops.rs with an empty scaffold. default_truth_store
becomes a one-line composition:
    devops::devops_rules(staffing::staffing_rules(TruthStore::new()))

4 new tests in the submodules cover:
  - staffing_rules registers expected count (regression guard)
  - blacklisted worker fails the client-not-blacklisted rule
  - missing deadline fires Reject via FieldEmpty condition
  - devops scaffold is a no-op for now

Total truth tests: 28 → 32. Workspace warnings still at 0.

Still open from Phase 42 (flagged, not in this commit):
  - `truth/` dir at repo root for file-backed rule loading (TOML/YAML).
    Rules are in-code today; loader work is a separate feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:25:54 -05:00

126 lines
4.8 KiB
Rust

//! 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"),
}
}
}