diff --git a/crates/truth/src/devops.rs b/crates/truth/src/devops.rs new file mode 100644 index 0000000..3c6f7a6 --- /dev/null +++ b/crates/truth/src/devops.rs @@ -0,0 +1,49 @@ +//! DevOps task-class rules — scaffold for the long-horizon phase. +//! +//! Phase 42 PRD: "Terraform/Ansible rule shapes are scaffolded but +//! unpopulated until the long-horizon phase. Keeps the dispatcher +//! signature stable so no refactor needed later." +//! +//! This module is intentionally minimal. It registers no rules yet. +//! The `devops_rules` function exists so callers can compose it onto +//! a store (e.g. `devops_rules(staffing_rules(TruthStore::new()))`) +//! without branching on whether the DevOps phase has landed. +//! +//! When the long-horizon phase fleshes out the DevOps rule set, the +//! implementations drop in here — same `RuleCondition` primitives, same +//! `TruthStore::evaluate` contract, zero upstream refactor. + +use crate::TruthStore; + +/// Register DevOps rules on the store. Currently a no-op scaffold — +/// no rules are added. Safe to compose with other rule-set functions. +/// +/// Planned task classes (not yet populated): +/// - `devops.terraform_plan` — `terraform validate` + pre-plan +/// sanity checks (no destroys without confirm flag, etc.) +/// - `devops.ansible_playbook` — `ansible-lint` + privileged-task +/// gates (no `become: true` on untagged hosts) +/// - `devops.shell_command` — whitelist / blocklist for +/// AI-generated shell invocations (covers what Phase 42 +/// queryd SQL gate does for SQL — same idea, shell surface) +pub fn devops_rules(store: TruthStore) -> TruthStore { + // Intentionally empty. See module-level doc for the phased rollout. + store +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn devops_rules_is_a_noop_for_now() { + // Scaffold guarantee: composing devops_rules onto an empty + // store must not add any rules. Future long-horizon work will + // populate this and the assertion shifts to counting the + // expected additions. + let store = devops_rules(TruthStore::new()); + assert_eq!(store.get_rules("devops.terraform_plan").len(), 0); + assert_eq!(store.get_rules("devops.ansible_playbook").len(), 0); + assert_eq!(store.get_rules("devops.shell_command").len(), 0); + } +} diff --git a/crates/truth/src/lib.rs b/crates/truth/src/lib.rs index 930f59d..6a48b72 100644 --- a/crates/truth/src/lib.rs +++ b/crates/truth/src/lib.rs @@ -1,6 +1,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +pub mod staffing; +pub mod devops; + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TruthRule { pub id: String, @@ -204,65 +207,19 @@ pub fn sql_query_guard_store() -> TruthStore { store } +/// Phase 42 default store: staffing rules + DevOps scaffold composed +/// onto an empty TruthStore. Per the PRD: "Staffing rules ship first; +/// Terraform/Ansible rule shapes are scaffolded but unpopulated until +/// the long-horizon phase." The composition order is irrelevant here +/// (DevOps is empty) but preserved so the shape matches the PRD's +/// expected "compose on top" pattern. +/// +/// Moved out of inline in-function rule registration (2026-04-24) to +/// land the Phase 42 module split the PRD called for: `staffing.rs` + +/// `devops.rs` each owns their task-class rule sets. Behavior unchanged +/// for existing callers. pub fn default_truth_store() -> TruthStore { - let mut store = TruthStore::new(); - - 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 + devops::devops_rules(staffing::staffing_rules(TruthStore::new())) } #[cfg(test)] diff --git a/crates/truth/src/staffing.rs b/crates/truth/src/staffing.rs new file mode 100644 index 0000000..f8ff7aa --- /dev/null +++ b/crates/truth/src/staffing.rs @@ -0,0 +1,125 @@ +//! 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"), + } + } +}