checkpoint: matrix-agent-validated (2026-04-25)
Architectural snapshot of the lakehouse codebase at the point where the
full matrix-driven agent loop with Mem0 versioning + deletion was
validated end-to-end.
WHAT THIS REPO IS
A clean single-commit snapshot of the lakehouse code. Heavy test data
(.parquet datasets, vector indexes) excluded — see REPLICATION.md for
regen path. Full lakehouse history at git.agentview.dev/profit/lakehouse.
WHAT WAS PROVEN
- Vector retrieval across multi-corpora matrix (chicago_permits + entity
briefs + sec_tickers + distilled procedural + llm_team runs)
- Observer hand-review (cloud + heuristic fallback) gating each candidate
- Local-model agent loop (qwen3.5:latest) with tool use + scratchpad
- Playbook seal on success → next-iter retrieval surfaces it as preamble
- Mem0 versioning + deletion in pathway_memory:
* UPSERT: ADD on new workflow, UPDATE bumps replay_count on identical
* REVISE: chains versions, parent.superseded_at + superseded_by stamped
* RETIRE: marks specific trace retired with reason, excluded from retrieval
* HISTORY: walks chain root→tip, cycle-safe
KEY DIRECTORIES
- crates/vectord/src/pathway_memory.rs — Mem0 ops live here
- crates/vectord/src/playbook_memory.rs — original Mem0 reference
- tests/agent_test/ — local-model agent harness + PRD + session archives
- scripts/dump_raw_corpus.sh — MinIO bucket dump (raw test corpus)
- scripts/vectorize_raw_corpus.ts — corpus → vector indexes
- scripts/analyze_chicago_contracts.ts — real inference pipeline
- scripts/seal_agent_playbook.ts — Mem0 upsert from agent traces
Replication: see REPLICATION.md for Debian 13 clean install + cloud-only
adaptation (no local Ollama).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "truth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub mod staffing;
|
||||
pub mod devops;
|
||||
pub mod loader;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TruthRule {
|
||||
pub id: String,
|
||||
pub task_class: String,
|
||||
pub description: String,
|
||||
pub condition: RuleCondition,
|
||||
pub action: RuleAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum RuleCondition {
|
||||
Always,
|
||||
FieldEquals { field: String, value: String },
|
||||
FieldMismatch { field: String, value: String },
|
||||
FieldEmpty { field: String },
|
||||
FieldGreater { field: String, threshold: i64 },
|
||||
// Case-insensitive substring scan — true if the field value contains
|
||||
// ANY of `needles`. Added for SQL/command guards where rules of the
|
||||
// form "sql must not contain DROP/DELETE/TRUNCATE" need to express
|
||||
// enforcement as a passing precondition being absent.
|
||||
FieldContainsAny { field: String, needles: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum RuleAction {
|
||||
Pass,
|
||||
Reject { message: String },
|
||||
Redact { fields: Vec<String> },
|
||||
Block { message: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TruthStore {
|
||||
rules: HashMap<String, Vec<TruthRule>>,
|
||||
}
|
||||
|
||||
impl TruthStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_rule(&mut self, rule: TruthRule) {
|
||||
self.rules
|
||||
.entry(rule.task_class.clone())
|
||||
.or_default()
|
||||
.push(rule);
|
||||
}
|
||||
|
||||
/// All rule IDs across every task class. Used by the file loader
|
||||
/// to detect duplicate-ID collisions before registering new rules.
|
||||
pub fn all_rule_ids(&self) -> std::collections::HashSet<String> {
|
||||
self.rules
|
||||
.values()
|
||||
.flat_map(|v| v.iter().map(|r| r.id.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_rules(&self, task_class: &str) -> Vec<&TruthRule> {
|
||||
self.rules
|
||||
.get(task_class)
|
||||
.map(|v| v.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Legacy API: returns the list of actions registered for a task class
|
||||
/// without evaluating conditions. Retained for backward compatibility
|
||||
/// with callers that only want the action catalog. New callers should
|
||||
/// prefer `evaluate()`, which actually walks `RuleCondition` against
|
||||
/// a context and reports per-rule pass/fail.
|
||||
pub fn check(&self, task_class: &str) -> Vec<RuleAction> {
|
||||
let rules = self.get_rules(task_class);
|
||||
rules
|
||||
.into_iter()
|
||||
.map(|r| r.action.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Evaluate every rule registered for `task_class` against `ctx`,
|
||||
/// returning one `RuleOutcome` per rule. `passed = true` means the
|
||||
/// rule's `condition` held; the rule's action is still attached so
|
||||
/// callers can distinguish "passed and therefore no-op" (RuleAction::Pass)
|
||||
/// from "passed and apply Redact". `passed = false` means the condition
|
||||
/// failed — callers should treat the attached action as the enforcement
|
||||
/// response (Reject/Block).
|
||||
///
|
||||
/// Fixed P42-001 (2026-04-23): previously `check()` returned all actions
|
||||
/// unconditionally — the `RuleCondition` field was ignored. Now every
|
||||
/// rule is actually walked against the provided context.
|
||||
pub fn evaluate(&self, task_class: &str, ctx: &serde_json::Value) -> Vec<RuleOutcome> {
|
||||
self.get_rules(task_class)
|
||||
.into_iter()
|
||||
.map(|r| RuleOutcome {
|
||||
rule_id: r.id.clone(),
|
||||
passed: evaluate_condition(&r.condition, ctx),
|
||||
action: r.action.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of evaluating one rule against a context. `passed` reports
|
||||
/// whether the condition held; `action` is the rule's declared action
|
||||
/// regardless (callers decide how to apply it based on `passed`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuleOutcome {
|
||||
pub rule_id: String,
|
||||
pub passed: bool,
|
||||
pub action: RuleAction,
|
||||
}
|
||||
|
||||
fn evaluate_condition(cond: &RuleCondition, ctx: &serde_json::Value) -> bool {
|
||||
match cond {
|
||||
RuleCondition::Always => true,
|
||||
RuleCondition::FieldEquals { field, value } => {
|
||||
field_as_string(ctx, field)
|
||||
.map(|s| s == *value)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
RuleCondition::FieldMismatch { field, value } => {
|
||||
field_as_string(ctx, field)
|
||||
.map(|s| s != *value)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
RuleCondition::FieldEmpty { field } => {
|
||||
match lookup(ctx, field) {
|
||||
None => true,
|
||||
Some(v) => v.is_null() || v.as_str().map(|s| s.is_empty()).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
RuleCondition::FieldGreater { field, threshold } => {
|
||||
lookup(ctx, field)
|
||||
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
|
||||
.map(|n| n > *threshold)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
RuleCondition::FieldContainsAny { field, needles } => {
|
||||
match field_as_string(ctx, field) {
|
||||
None => false,
|
||||
Some(s) => {
|
||||
let haystack = s.to_ascii_lowercase();
|
||||
needles.iter().any(|n| haystack.contains(&n.to_ascii_lowercase()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a dot-separated path through a serde_json::Value. `"worker.status"`
|
||||
/// → `ctx["worker"]["status"]`. Returns None if any segment is missing or
|
||||
/// a non-object is encountered mid-path.
|
||||
fn lookup<'a>(ctx: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
|
||||
let mut cur = ctx;
|
||||
for seg in path.split('.') {
|
||||
cur = cur.get(seg)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
fn field_as_string(ctx: &serde_json::Value, path: &str) -> Option<String> {
|
||||
lookup(ctx, path).and_then(|v| match v {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimal SQL guard — rejects destructive verbs (DROP/TRUNCATE/DELETE).
|
||||
/// queryd/src/service.rs loads this into its `QueryState` and evaluates
|
||||
/// every `/sql` request against it before hitting the DataFusion engine.
|
||||
/// This is the P42-002 enforcement point flagged across scrum iters 3-5
|
||||
/// ("raw SQL forwarded without schema or policy gate").
|
||||
///
|
||||
/// Intentionally narrow: it's a safety net, not a full SQL parser. If
|
||||
/// callers need richer AST-aware enforcement they should extend this with
|
||||
/// structured rules rather than new needles.
|
||||
pub fn sql_query_guard_store() -> TruthStore {
|
||||
let mut store = TruthStore::new();
|
||||
store.add_rule(TruthRule {
|
||||
id: "no-destructive-sql".to_string(),
|
||||
task_class: "sql_query".to_string(),
|
||||
description: "SQL must not contain destructive verbs".to_string(),
|
||||
condition: RuleCondition::FieldContainsAny {
|
||||
field: "sql".to_string(),
|
||||
needles: vec![
|
||||
"drop table".to_string(),
|
||||
"drop schema".to_string(),
|
||||
"drop database".to_string(),
|
||||
"truncate".to_string(),
|
||||
"delete from".to_string(),
|
||||
],
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "destructive SQL rejected by truth.sql_query_guard".to_string(),
|
||||
},
|
||||
});
|
||||
store.add_rule(TruthRule {
|
||||
id: "sql-not-empty".to_string(),
|
||||
task_class: "sql_query".to_string(),
|
||||
description: "SQL must not be empty".to_string(),
|
||||
condition: RuleCondition::FieldEmpty {
|
||||
field: "sql".to_string(),
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "empty SQL rejected".to_string(),
|
||||
},
|
||||
});
|
||||
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 {
|
||||
devops::devops_rules(staffing::staffing_rules(TruthStore::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truth_store_new_is_empty() {
|
||||
let store = TruthStore::new();
|
||||
assert!(store.rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_inserts_into_correct_task_class() {
|
||||
let mut store = TruthStore::new();
|
||||
store.add_rule(TruthRule {
|
||||
id: "test-rule".to_string(),
|
||||
task_class: "test.task".to_string(),
|
||||
description: "Test rule".to_string(),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
let rules = store.get_rules("test.task");
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(rules[0].id, "test-rule");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_rules_returns_empty_for_unknown_class() {
|
||||
let store = TruthStore::new();
|
||||
let rules = store.get_rules("unknown.class");
|
||||
assert!(rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_returns_actions_for_task_class() {
|
||||
let mut store = TruthStore::new();
|
||||
store.add_rule(TruthRule {
|
||||
id: "a1".to_string(),
|
||||
task_class: "test".to_string(),
|
||||
description: "A1".to_string(),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
store.add_rule(TruthRule {
|
||||
id: "a2".to_string(),
|
||||
task_class: "test".to_string(),
|
||||
description: "A2".to_string(),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Reject {
|
||||
message: "test reject".to_string(),
|
||||
},
|
||||
});
|
||||
let actions = store.check("test");
|
||||
assert_eq!(actions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_condition_serialize_always() {
|
||||
let cond = RuleCondition::Always;
|
||||
let json = serde_json::to_string(&cond).unwrap();
|
||||
assert!(json.contains(r#""type":"Always"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_condition_serialize_field_equals() {
|
||||
let cond = RuleCondition::FieldEquals {
|
||||
field: "foo".to_string(),
|
||||
value: "bar".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&cond).unwrap();
|
||||
assert!(json.contains(r#""type":"FieldEquals""#));
|
||||
assert!(json.contains(r#""field":"foo""#));
|
||||
assert!(json.contains(r#""value":"bar""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_action_serialize_redact() {
|
||||
let action = RuleAction::Redact {
|
||||
fields: vec!["ssn".to_string()],
|
||||
};
|
||||
let json = serde_json::to_string(&action).unwrap();
|
||||
assert!(json.contains(r#""type":"Redact""#));
|
||||
assert!(json.contains("ssn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_action_serialize_reject() {
|
||||
let action = RuleAction::Reject {
|
||||
message: "test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&action).unwrap();
|
||||
assert!(json.contains(r#""type":"Reject""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_truth_store_has_staffing_rules() {
|
||||
let store = default_truth_store();
|
||||
let fill_rules = store.get_rules("staffing.fill");
|
||||
assert!(!fill_rules.is_empty());
|
||||
let any_rules = store.get_rules("staffing.any");
|
||||
assert!(!any_rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_rules_same_task_class() {
|
||||
let mut store = TruthStore::new();
|
||||
for i in 0..5 {
|
||||
store.add_rule(TruthRule {
|
||||
id: format!("rule-{}", i),
|
||||
task_class: "test".to_string(),
|
||||
description: format!("Rule {}", i),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
}
|
||||
let rules = store.get_rules("test");
|
||||
assert_eq!(rules.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truth_rule_clone_preserves_data() {
|
||||
let rule = TruthRule {
|
||||
id: "clone-test".to_string(),
|
||||
task_class: "clone.task".to_string(),
|
||||
description: "Clone test".to_string(),
|
||||
condition: RuleCondition::FieldEquals {
|
||||
field: "x".to_string(),
|
||||
value: "y".to_string(),
|
||||
},
|
||||
action: RuleAction::Block {
|
||||
message: "blocked".to_string(),
|
||||
},
|
||||
};
|
||||
let cloned = rule.clone();
|
||||
assert_eq!(cloned.id, rule.id);
|
||||
assert_eq!(cloned.condition, rule.condition);
|
||||
assert_eq!(cloned.action, rule.action);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_greater_condition_parse() {
|
||||
let json = r#"{"type":"FieldGreater","field":"count","threshold":10}"#;
|
||||
let cond: RuleCondition = serde_json::from_str(json).unwrap();
|
||||
match cond {
|
||||
RuleCondition::FieldGreater { field, threshold } => {
|
||||
assert_eq!(field, "count");
|
||||
assert_eq!(threshold, 10);
|
||||
}
|
||||
_ => panic!("Expected FieldGreater"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_action_blocks_with_message() {
|
||||
let action = RuleAction::Block {
|
||||
message: "Rate limited".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&action).unwrap();
|
||||
assert!(json.contains("Rate limited"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store_check_returns_empty() {
|
||||
let store = TruthStore::new();
|
||||
let actions = store.check("empty.class");
|
||||
assert!(actions.is_empty());
|
||||
}
|
||||
|
||||
// ── P42-001 evaluate() tests — actually walk RuleCondition ──
|
||||
|
||||
fn fill_store() -> TruthStore {
|
||||
let mut s = TruthStore::new();
|
||||
s.add_rule(TruthRule {
|
||||
id: "active".into(),
|
||||
task_class: "t".into(),
|
||||
description: "must be active".into(),
|
||||
condition: RuleCondition::FieldEquals {
|
||||
field: "worker.status".into(),
|
||||
value: "active".into(),
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "worker not active".into(),
|
||||
},
|
||||
});
|
||||
s.add_rule(TruthRule {
|
||||
id: "deadline".into(),
|
||||
task_class: "t".into(),
|
||||
description: "deadline required".into(),
|
||||
condition: RuleCondition::FieldEmpty {
|
||||
field: "contract.deadline".into(),
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "missing deadline".into(),
|
||||
},
|
||||
});
|
||||
s.add_rule(TruthRule {
|
||||
id: "budget".into(),
|
||||
task_class: "t".into(),
|
||||
description: "budget positive".into(),
|
||||
condition: RuleCondition::FieldGreater {
|
||||
field: "contract.budget".into(),
|
||||
threshold: 0,
|
||||
},
|
||||
action: RuleAction::Block {
|
||||
message: "budget must be positive".into(),
|
||||
},
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_equals_pass_on_match() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({"worker": {"status": "active"}});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
|
||||
assert!(active.passed, "active condition should hold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_equals_fail_on_mismatch() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({"worker": {"status": "terminated"}});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
|
||||
assert!(!active.passed, "terminated should fail active condition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_equals_fail_on_missing() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
|
||||
assert!(!active.passed, "missing worker.status should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_empty_pass_when_absent() {
|
||||
let s = fill_store();
|
||||
// FieldEmpty passes when the field is missing/null/empty string.
|
||||
// Deadline rule says "field empty means action fires" — so passed=true
|
||||
// here means the rule's condition held (deadline IS empty).
|
||||
let ctx = serde_json::json!({});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let deadline = o.iter().find(|r| r.rule_id == "deadline").unwrap();
|
||||
assert!(deadline.passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_empty_fail_when_present() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({"contract": {"deadline": "2026-05-01"}});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let deadline = o.iter().find(|r| r.rule_id == "deadline").unwrap();
|
||||
assert!(!deadline.passed, "non-empty deadline should fail FieldEmpty check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_field_greater_pass_and_fail() {
|
||||
let s = fill_store();
|
||||
let ctx_ok = serde_json::json!({"contract": {"budget": 100}});
|
||||
let ctx_bad = serde_json::json!({"contract": {"budget": 0}});
|
||||
let ok = s.evaluate("t", &ctx_ok);
|
||||
let bad = s.evaluate("t", &ctx_bad);
|
||||
assert!(ok.iter().find(|r| r.rule_id == "budget").unwrap().passed);
|
||||
assert!(!bad.iter().find(|r| r.rule_id == "budget").unwrap().passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_always_condition_passes_unconditionally() {
|
||||
let mut s = TruthStore::new();
|
||||
s.add_rule(TruthRule {
|
||||
id: "always".into(),
|
||||
task_class: "x".into(),
|
||||
description: "".into(),
|
||||
condition: RuleCondition::Always,
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
let o = s.evaluate("x", &serde_json::json!(null));
|
||||
assert!(o[0].passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_preserves_action_regardless_of_outcome() {
|
||||
let s = fill_store();
|
||||
let ctx = serde_json::json!({"worker": {"status": "active"}});
|
||||
let o = s.evaluate("t", &ctx);
|
||||
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
|
||||
// Action is attached whether the rule passed or not — the consumer
|
||||
// decides how to use it.
|
||||
assert_eq!(
|
||||
active.action,
|
||||
RuleAction::Reject {
|
||||
message: "worker not active".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_on_unknown_task_class_returns_empty() {
|
||||
let s = fill_store();
|
||||
let o = s.evaluate("nonexistent", &serde_json::json!({}));
|
||||
assert!(o.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_still_returns_actions_unconditionally_for_back_compat() {
|
||||
// Legacy API should still behave the same — no condition walking.
|
||||
let s = fill_store();
|
||||
let actions = s.check("t");
|
||||
assert_eq!(actions.len(), 3, "check returns one action per rule regardless of condition");
|
||||
}
|
||||
|
||||
fn sql_guard_store() -> TruthStore {
|
||||
let mut s = TruthStore::new();
|
||||
s.add_rule(TruthRule {
|
||||
id: "no-destructive".into(),
|
||||
task_class: "sql_query".into(),
|
||||
description: "SQL must not contain destructive verbs".into(),
|
||||
condition: RuleCondition::FieldContainsAny {
|
||||
field: "sql".into(),
|
||||
needles: vec![
|
||||
"drop table".into(),
|
||||
"drop schema".into(),
|
||||
"truncate".into(),
|
||||
"delete from".into(),
|
||||
],
|
||||
},
|
||||
action: RuleAction::Reject {
|
||||
message: "destructive SQL rejected".into(),
|
||||
},
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_contains_any_matches_case_insensitively() {
|
||||
let s = sql_guard_store();
|
||||
let ctx = serde_json::json!({"sql": "SELECT * FROM t; DROP TABLE users;"});
|
||||
let o = s.evaluate("sql_query", &ctx);
|
||||
assert!(o[0].passed, "condition holds when needle present (case-insensitive)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_contains_any_is_false_when_no_needle_matches() {
|
||||
let s = sql_guard_store();
|
||||
let ctx = serde_json::json!({"sql": "SELECT count(*) FROM workers"});
|
||||
let o = s.evaluate("sql_query", &ctx);
|
||||
assert!(!o[0].passed, "benign SELECT should not match destructive needles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_contains_any_false_when_field_missing() {
|
||||
let s = sql_guard_store();
|
||||
let ctx = serde_json::json!({});
|
||||
let o = s.evaluate("sql_query", &ctx);
|
||||
assert!(!o[0].passed, "missing field → condition cannot hold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_contains_any_empty_needles_list_never_matches() {
|
||||
let mut s = TruthStore::new();
|
||||
s.add_rule(TruthRule {
|
||||
id: "empty".into(),
|
||||
task_class: "x".into(),
|
||||
description: "".into(),
|
||||
condition: RuleCondition::FieldContainsAny {
|
||||
field: "sql".into(),
|
||||
needles: vec![],
|
||||
},
|
||||
action: RuleAction::Pass,
|
||||
});
|
||||
let o = s.evaluate("x", &serde_json::json!({"sql": "anything"}));
|
||||
assert!(!o[0].passed, "no needles → any::<bool> is false");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! File-backed TruthRule loader (Phase 42 PRD).
|
||||
//!
|
||||
//! PRD: "truth/ dir at repo root — rule files, versioned in git."
|
||||
//! This module walks a directory, parses every `*.toml` file it finds,
|
||||
//! and registers the rules into a caller-supplied store. Rule IDs must
|
||||
//! be unique across the combined set — duplicate-ID collisions are
|
||||
//! load-time errors.
|
||||
//!
|
||||
//! The TOML format matches the shape at `truth/README.md`. The same
|
||||
//! `RuleCondition` + `RuleAction` enums used by the in-code registrars
|
||||
//! deserialize directly from `condition = { type = "FieldEquals", ... }`
|
||||
//! thanks to `#[serde(tag = "type")]`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{TruthRule, TruthStore};
|
||||
|
||||
/// Deserialization wrapper — a TOML file is a list of [[rule]] blocks.
|
||||
#[derive(Deserialize)]
|
||||
struct RuleFile {
|
||||
#[serde(default)]
|
||||
rule: Vec<TruthRule>,
|
||||
}
|
||||
|
||||
/// Load every `*.toml` file in `dir` and add its rules to `store`.
|
||||
/// Returns the number of rules loaded across all files.
|
||||
///
|
||||
/// Errors:
|
||||
/// - directory doesn't exist or can't be read
|
||||
/// - any `.toml` file fails to parse
|
||||
/// - any rule ID collides with an existing rule (same ID already
|
||||
/// registered in the store)
|
||||
///
|
||||
/// Non-goals: recursive walk (flat dir only), hot reload (one-shot load).
|
||||
pub fn load_from_dir(store: &mut TruthStore, dir: impl AsRef<Path>) -> Result<usize, String> {
|
||||
let dir = dir.as_ref();
|
||||
let entries = fs::read_dir(dir)
|
||||
.map_err(|e| format!("read_dir {}: {e}", dir.display()))?;
|
||||
|
||||
let mut loaded_ids = store.all_rule_ids();
|
||||
let mut count = 0usize;
|
||||
|
||||
let mut paths: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("toml"))
|
||||
.collect();
|
||||
// Deterministic order — alphabetical by filename. Matters when a
|
||||
// cross-file ID collision happens; the earlier filename wins
|
||||
// nothing (both error), but the error message is reproducible.
|
||||
paths.sort();
|
||||
|
||||
for path in paths {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
let file: RuleFile = toml::from_str(&raw)
|
||||
.map_err(|e| format!("parse {}: {e}", path.display()))?;
|
||||
for rule in file.rule {
|
||||
if !loaded_ids.insert(rule.id.clone()) {
|
||||
return Err(format!(
|
||||
"duplicate rule id '{}' from {}",
|
||||
rule.id,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
store.add_rule(rule);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn write_file(dir: &Path, name: &str, content: &str) {
|
||||
let path = dir.join(name);
|
||||
let mut f = fs::File::create(&path).unwrap();
|
||||
f.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_rules_from_toml_files() {
|
||||
let tmp = tempdir_for("loader_test");
|
||||
write_file(&tmp, "a.toml", r#"
|
||||
[[rule]]
|
||||
id = "a-rule"
|
||||
task_class = "test"
|
||||
description = "test rule"
|
||||
action = { type = "Pass" }
|
||||
|
||||
[rule.condition]
|
||||
type = "Always"
|
||||
"#);
|
||||
let mut store = TruthStore::new();
|
||||
let n = load_from_dir(&mut store, &tmp).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(store.get_rules("test").len(), 1);
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_rule_ids() {
|
||||
let tmp = tempdir_for("dup_ids");
|
||||
write_file(&tmp, "a.toml", r#"
|
||||
[[rule]]
|
||||
id = "same"
|
||||
task_class = "t"
|
||||
description = ""
|
||||
action = { type = "Pass" }
|
||||
[rule.condition]
|
||||
type = "Always"
|
||||
"#);
|
||||
write_file(&tmp, "b.toml", r#"
|
||||
[[rule]]
|
||||
id = "same"
|
||||
task_class = "t"
|
||||
description = ""
|
||||
action = { type = "Pass" }
|
||||
[rule.condition]
|
||||
type = "Always"
|
||||
"#);
|
||||
let mut store = TruthStore::new();
|
||||
let err = load_from_dir(&mut store, &tmp).unwrap_err();
|
||||
assert!(err.contains("duplicate"), "got: {err}");
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_with_in_code_rule_is_rejected() {
|
||||
// Existing in-store IDs count as "already registered." Operator
|
||||
// can't shadow an in-code rule by file without changing the ID.
|
||||
let tmp = tempdir_for("dup_in_code");
|
||||
write_file(&tmp, "conflict.toml", r#"
|
||||
[[rule]]
|
||||
id = "worker-active"
|
||||
task_class = "staffing.fill"
|
||||
description = "file attempt"
|
||||
action = { type = "Pass" }
|
||||
[rule.condition]
|
||||
type = "Always"
|
||||
"#);
|
||||
// staffing_rules registers "worker-active"
|
||||
let mut store = crate::staffing::staffing_rules(TruthStore::new());
|
||||
let err = load_from_dir(&mut store, &tmp).unwrap_err();
|
||||
assert!(err.contains("duplicate") && err.contains("worker-active"));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_toml_files() {
|
||||
let tmp = tempdir_for("skip_non_toml");
|
||||
write_file(&tmp, "a.toml", r#"
|
||||
[[rule]]
|
||||
id = "x"
|
||||
task_class = "t"
|
||||
description = ""
|
||||
action = { type = "Pass" }
|
||||
[rule.condition]
|
||||
type = "Always"
|
||||
"#);
|
||||
write_file(&tmp, "README.md", "not a toml file");
|
||||
let mut store = TruthStore::new();
|
||||
let n = load_from_dir(&mut store, &tmp).unwrap();
|
||||
assert_eq!(n, 1); // README.md ignored
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_dir_returns_error() {
|
||||
let mut store = TruthStore::new();
|
||||
let err = load_from_dir(&mut store, "/nonexistent/path/here").unwrap_err();
|
||||
assert!(err.contains("read_dir"));
|
||||
}
|
||||
|
||||
fn tempdir_for(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("truth_loader_{}_{}", tag,
|
||||
std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user