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>
This commit is contained in:
parent
ed85620558
commit
049a4b69fb
49
crates/truth/src/devops.rs
Normal file
49
crates/truth/src/devops.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,9 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub mod staffing;
|
||||||
|
pub mod devops;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct TruthRule {
|
pub struct TruthRule {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@ -204,65 +207,19 @@ pub fn sql_query_guard_store() -> TruthStore {
|
|||||||
store
|
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 {
|
pub fn default_truth_store() -> TruthStore {
|
||||||
let mut store = TruthStore::new();
|
devops::devops_rules(staffing::staffing_rules(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
125
crates/truth/src/staffing.rs
Normal file
125
crates/truth/src/staffing.rs
Normal file
@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user