diff --git a/CHANGELOG.md b/CHANGELOG.md index 48cfd23..d3317e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added (RFL-172 T7+T8 — truth-catalog quality suite and docs) +- Proptest slug invariants inline in `truth-catalog::converge` (determinism, + idempotency, output conforms to `TruthKey` grammar); `slug` promoted to + `pub(crate)` for test reach. +- `truth-catalog/tests/orchestration_coverage.rs` — fixture-gherkin coverage of + `prepare_candidates`: `IrreversibleCannotRace` refusal, single-shot alternate + exclusion (`SingleShotRequested`), `max_candidates` cap, primary-template + determinism across calls. Restores the orchestration coverage that T4's + content move retired (the old tests depended on the global `TRUTHS` slice). +- `truth-catalog/tests/property_tests.rs` — proptest suite: `TruthKey` + parse/display roundtrip, uppercase rejection, catalog find/by_kind/for_module + consistency over a synthetic multi-kind fixture slice. +- `truth-catalog/tests/negative_tests.rs` — TruthKey grammar rejections (empty, + uppercase, underscore, space, leading/trailing/consecutive hyphens, non-ASCII, + period, slash; error carries input verbatim), `PackResolver` `UnknownModule` + Err path with preserved message text, `catalog.find(unknown)` → None. +- trybuild compile-fail guards in `truth-catalog/tests/compile_fail/`: + bare `&str` cannot coerce to `&TruthKey` (newtype gate), and + `capability_registry` is not dep-resolvable from truth-catalog (the T3/T4 + global-reach edge cannot be silently re-introduced). +- `truth-catalog/tests/soak.rs` (`#[ignore]`) — 10k and 100k + parse+find+build cycles with pack-id/binding determinism assertions. + Proof runs: 10k ≈ 57 ms, 100k ≈ 348 ms. +- `crm-truths/tests/catalog_mount.rs` — mounting-layer injection test (Item 0): + `CRM_CATALOG` resolves `qualify-inbound-lead` / `score-inbound-fit`, returns + None for unknown keys, and every `TRUTHS` key parses as a valid `TruthKey`. + Pins the catalog half of the desktop chain (`apps/desktop` → + `workbench-backend` → `crm_truths::find_truth`). `apps/crm-helm/` confirmed + orphaned (no cargo edge to `helm-governed-jobs`). +- Crate-level rustdoc for `truth-catalog` documenting the mechanism/content + inversion, core types, injection traits, and RFL-171/172 lineage; + `cargo doc -p truth-catalog --no-deps` → 0 warnings. +- `kb/Architecture/Truths Layer.md` — mechanism/content split section (Seam B) + and catalog location updated from `prio-truths` to + `crm-truths`-over-`truth-catalog`. + ### Added (RFL-171 T9 — quality wave for helm-event-substrate) - Soak test (`#[ignore]`, `SOAK_ITERS` env, default 100 000): publish/subscribe cycles through `EventHub` backed by `InMemoryEventLog`; proves monotone diff --git a/Cargo.lock b/Cargo.lock index 24d7928..44f40f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2099,6 +2099,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crm-truths" +version = "0.2.1" +dependencies = [ + "axiom-truth", + "capability-core", + "capability-registry", + "chrono", + "converge-kernel", + "converge-model", + "organism-pack", + "organism-runtime", + "serde", + "serde_json", + "thiserror 2.0.18", + "truth-catalog", + "uuid", +] + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -4198,6 +4217,7 @@ dependencies = [ "tonic 0.12.3", "tower 0.5.3", "tracing", + "truth-catalog", "uuid", ] @@ -11681,17 +11701,17 @@ name = "truth-catalog" version = "0.2.1" dependencies = [ "axiom-truth", - "capability-core", - "capability-registry", "chrono", "converge-kernel", "converge-manifold-adapters", "converge-model", "organism-pack", "organism-runtime", + "proptest", "serde", "serde_json", "thiserror 2.0.18", + "trybuild", "uuid", ] @@ -13301,6 +13321,7 @@ dependencies = [ "capability-core", "capability-registry", "chrono", + "crm-truths", "helm-module-contracts", "organism-domain", "organism-runtime", diff --git a/Cargo.toml b/Cargo.toml index 91f119f..f91c0d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ # Capability and truth crates. "crates/capability-core", "crates/capability-registry", + "crates/crm-truths", "crates/prio-catalog", "crates/prio-identity", "crates/prio-parties", diff --git a/crates/crm-truths/Cargo.toml b/crates/crm-truths/Cargo.toml new file mode 100644 index 0000000..22b61ad --- /dev/null +++ b/crates/crm-truths/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "crm-truths" +description = "CRM truth content over the truth-catalog mechanism — app-side forever, never foundation." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +truth-catalog = { version = "0.2.1", path = "../truth-catalog" } +capability-core = { version = "0.2.1", path = "../capability-core" } +capability-registry = { version = "0.2.1", path = "../capability-registry" } +axiom-truth.workspace = true +converge-kernel.workspace = true +converge-model.workspace = true +organism-pack.workspace = true +organism-runtime.workspace = true +chrono.workspace = true +thiserror.workspace = true +serde_json.workspace = true +serde.workspace = true + +[dev-dependencies] +uuid.workspace = true diff --git a/crates/truth-catalog/examples/real-truth-resolution.rs b/crates/crm-truths/examples/real-truth-resolution.rs similarity index 96% rename from crates/truth-catalog/examples/real-truth-resolution.rs rename to crates/crm-truths/examples/real-truth-resolution.rs index b2c12e4..5a48485 100644 --- a/crates/truth-catalog/examples/real-truth-resolution.rs +++ b/crates/crm-truths/examples/real-truth-resolution.rs @@ -1,4 +1,4 @@ -use truth_catalog::{all_truths, converge_binding_for_truth}; +use crm_truths::{all_truths, converge_binding_for_truth}; fn main() { let requested_keys = std::env::args().skip(1).collect::>(); diff --git a/crates/crm-truths/src/evaluators.rs b/crates/crm-truths/src/evaluators.rs new file mode 100644 index 0000000..c21bf45 --- /dev/null +++ b/crates/crm-truths/src/evaluators.rs @@ -0,0 +1,494 @@ +use converge_kernel::{Context, ContextKey, CriterionEvaluator, CriterionResult}; +use converge_model::{Criterion, FactId, TruthDefinition as ConvergeTruth}; +use truth_catalog::{TruthConvergeBinding, to_converge_truth}; +use crate::resolver::CrmPackResolver; +use crate::find_truth; + +pub struct EvaluateAcquisitionTargetEvaluator; +pub struct QualifyInboundLeadEvaluator; +pub struct ActivateSubscriptionEvaluator; +pub struct RefillPrepaidAiCreditsEvaluator; +pub struct UpgradeSubscriptionPlanEvaluator; +pub struct SuspendServiceOnPaymentFailureEvaluator; +pub struct ReconcileModelUsageAgainstCustomerLedgerEvaluator; +pub struct ScoreInboundFitEvaluator; +pub struct PlanOutboundCampaignEvaluator; +pub struct MatchRenewalContextEvaluator; +pub struct ScheduleStrategicMeetingsEvaluator; +pub struct MonitorBrandSignalEvaluator; +pub struct MatchVisualToTaglineEvaluator; + +impl CriterionEvaluator for QualifyInboundLeadEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + match criterion.id.as_str() { + "outcome.lead-is-explicitly-qualified-or-disqualified" => { + if let Some(fact_id) = + find_fact_id(context, ContextKey::Evaluations, "lead:qualification") + { + CriterionResult::Met { + evidence: vec![FactId::new(fact_id)], + } + } else { + CriterionResult::Unmet { + reason: "lead qualification fact is missing".to_string(), + } + } + } + "outcome.next-owner-and-next-step-are-recorded" => { + let owner = find_fact_id(context, ContextKey::Strategies, "lead:owner"); + let next_step = find_fact_id(context, ContextKey::Strategies, "lead:next-step"); + match (owner, next_step) { + (Some(owner), Some(next_step)) => CriterionResult::Met { + evidence: vec![FactId::new(owner), FactId::new(next_step)], + }, + (None, Some(_)) => CriterionResult::Unmet { + reason: "lead owner fact is missing".to_string(), + }, + (Some(_), None) => CriterionResult::Unmet { + reason: "lead next-step fact is missing".to_string(), + }, + (None, None) => CriterionResult::Unmet { + reason: "lead owner and next-step facts are missing".to_string(), + }, + } + } + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for ScoreInboundFitEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + match criterion.id.as_str() { + "outcome.a-governed-fit-score-is-recorded-for-the-inbound-lead" => { + require_fact(context, ContextKey::Evaluations, "lead:fit-score") + } + "outcome.the-score-cites-attributable-behavioral-evidence" => { + require_fact(context, ContextKey::Signals, "lead:fit-evidence") + } + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for ActivateSubscriptionEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + if let Some(review_fact) = find_fact_id( + context, + ContextKey::Evaluations, + "subscription:manual-review-required", + ) { + return CriterionResult::Blocked { + reason: format!("manual review is required before activation ({review_fact})"), + approval_ref: Some(review_fact.into()), + }; + } + + match criterion.id.as_str() { + "outcome.subscription-becomes-active-with-an-explicit-plan" => require_fact( + context, + ContextKey::Strategies, + "subscription:activation-ready", + ), + "outcome.entitlements-and-financial-opening-state-are-aligned" => { + let entitlements = find_fact_id( + context, + ContextKey::Signals, + "subscription:entitlement-preview", + ); + let balance = find_fact_id( + context, + ContextKey::Evaluations, + "subscription:opening-balance", + ); + match (entitlements, balance) { + (Some(entitlements), Some(balance)) => CriterionResult::Met { + evidence: vec![FactId::new(entitlements), FactId::new(balance)], + }, + (None, Some(_)) => CriterionResult::Unmet { + reason: "subscription entitlement preview fact is missing".to_string(), + }, + (Some(_), None) => CriterionResult::Unmet { + reason: "subscription opening-balance fact is missing".to_string(), + }, + (None, None) => CriterionResult::Unmet { + reason: "subscription entitlement preview and opening-balance facts are missing" + .to_string(), + }, + } + } + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for RefillPrepaidAiCreditsEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + if let Some(review_fact) = find_fact_id( + context, + ContextKey::Evaluations, + "credit-top-up:manual-review-required", + ) { + return CriterionResult::Blocked { + reason: format!("manual review is required before refill ({review_fact})"), + approval_ref: Some(review_fact.into()), + }; + } + + match criterion.id.as_str() { + "outcome.confirmed-top-up-appears-in-the-ledger" => { + let payment = find_fact_id(context, ContextKey::Evaluations, "payment:confirmed"); + let grant = + find_fact_id(context, ContextKey::Strategies, "credit-top-up:grant-ready"); + match (payment, grant) { + (Some(payment), Some(grant)) => CriterionResult::Met { + evidence: vec![FactId::new(payment), FactId::new(grant)], + }, + (None, Some(_)) => CriterionResult::Unmet { + reason: "payment confirmation fact is missing".to_string(), + }, + (Some(_), None) => CriterionResult::Unmet { + reason: "credit grant plan fact is missing".to_string(), + }, + (None, None) => CriterionResult::Unmet { + reason: "payment confirmation and credit grant plan facts are missing" + .to_string(), + }, + } + } + "outcome.entitlement-balance-increases-for-the-correct-account" => require_fact( + context, + ContextKey::Signals, + "credit-top-up:entitlement-adjustment", + ), + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for UpgradeSubscriptionPlanEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + if let Some(review_fact) = find_fact_id( + context, + ContextKey::Evaluations, + "subscription:plan-change-manual-review-required", + ) { + return CriterionResult::Blocked { + reason: format!("manual review is required before plan change ({review_fact})"), + approval_ref: Some(review_fact.into()), + }; + } + + match criterion.id.as_str() { + "outcome.subscription-moves-to-the-target-plan-on-an-explicit-date" => require_fact( + context, + ContextKey::Strategies, + "subscription:plan-change-ready", + ), + "outcome.entitlements-and-commercial-delta-stay-aligned" => { + let entitlements = find_fact_id( + context, + ContextKey::Signals, + "subscription:plan-change-entitlements", + ); + let delta = find_fact_id( + context, + ContextKey::Evaluations, + "subscription:plan-change-delta", + ); + match (entitlements, delta) { + (Some(entitlements), Some(delta)) => CriterionResult::Met { + evidence: vec![FactId::new(entitlements), FactId::new(delta)], + }, + (None, Some(_)) => CriterionResult::Unmet { + reason: "subscription plan-change entitlement preview fact is missing" + .to_string(), + }, + (Some(_), None) => CriterionResult::Unmet { + reason: "subscription commercial delta fact is missing".to_string(), + }, + (None, None) => CriterionResult::Unmet { + reason: "subscription plan-change entitlement preview and commercial delta facts are missing" + .to_string(), + }, + } + } + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for SuspendServiceOnPaymentFailureEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + if let Some(review_fact) = find_fact_id( + context, + ContextKey::Evaluations, + "subscription:suspension-manual-review-required", + ) { + return CriterionResult::Blocked { + reason: format!("manual review is required before suspension ({review_fact})"), + approval_ref: Some(review_fact.into()), + }; + } + + match criterion.id.as_str() { + "outcome.service-state-matches-payment-policy" => { + let suspended = find_fact_id( + context, + ContextKey::Strategies, + "subscription:suspension-ready", + ); + let deferred = find_fact_id( + context, + ContextKey::Strategies, + "subscription:suspension-deferred", + ); + let impact = find_fact_id( + context, + ContextKey::Signals, + "subscription:entitlement-impact", + ); + match (suspended.or(deferred), impact) { + (Some(state), Some(impact)) => CriterionResult::Met { + evidence: vec![FactId::new(state), FactId::new(impact)], + }, + (None, Some(_)) => CriterionResult::Unmet { + reason: "subscription suspension policy decision fact is missing" + .to_string(), + }, + (Some(_), None) => CriterionResult::Unmet { + reason: "subscription entitlement impact fact is missing".to_string(), + }, + (None, None) => CriterionResult::Unmet { + reason: "subscription suspension policy decision and entitlement impact facts are missing" + .to_string(), + }, + } + } + "outcome.customer-receives-a-clear-recovery-path" => require_fact( + context, + ContextKey::Strategies, + "subscription:recovery-path", + ), + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for ReconcileModelUsageAgainstCustomerLedgerEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + if let Some(review_fact) = find_fact_id( + context, + ContextKey::Evaluations, + "reconciliation:manual-review-required", + ) { + return CriterionResult::Blocked { + reason: format!( + "manual review is required before reconciliation can be accepted ({review_fact})" + ), + approval_ref: Some(review_fact.into()), + }; + } + + match criterion.id.as_str() { + "outcome.usage-and-financial-state-reconcile-cleanly" => { + require_fact(context, ContextKey::Evaluations, "reconciliation:clean") + } + "outcome.exceptions-are-recorded-and-routed" => { + if let Some(clean_fact) = + find_fact_id(context, ContextKey::Evaluations, "reconciliation:clean") + { + return CriterionResult::Met { + evidence: vec![FactId::new(clean_fact)], + }; + } + + let exception_fact = + find_fact_id(context, ContextKey::Evaluations, "reconciliation:exception"); + let route_fact = + find_fact_id(context, ContextKey::Strategies, "reconciliation:route"); + match (exception_fact, route_fact) { + (Some(exception_fact), Some(route_fact)) => CriterionResult::Met { + evidence: vec![FactId::new(exception_fact), FactId::new(route_fact)], + }, + (None, Some(_)) => CriterionResult::Unmet { + reason: "reconciliation exception fact is missing".to_string(), + }, + (Some(_), None) => CriterionResult::Unmet { + reason: "reconciliation route fact is missing".to_string(), + }, + (None, None) => CriterionResult::Unmet { + reason: "reconciliation outcome facts are missing from the converge context" + .to_string(), + }, + } + } + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for PlanOutboundCampaignEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + match criterion.id.as_str() { + "outcome.a-governed-outbound-campaign-plan-exists" => { + require_fact(context, ContextKey::Strategies, "campaign:plan") + } + "outcome.campaign-budget-status-is-explicit-and-queryable" => { + require_fact(context, ContextKey::Evaluations, "campaign:budget-status") + } + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for MatchRenewalContextEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + match criterion.id.as_str() { + "outcome.a-renewal-brief-is-attached-to-the-account-or-renewal-motion" => { + require_fact(context, ContextKey::Strategies, "renewal:brief") + } + "outcome.retrieved-renewal-signals-stay-traceable-to-their-source-artifacts" => { + require_any_fact(context, ContextKey::Signals, "renewal:signal:") + } + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for ScheduleStrategicMeetingsEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + if let Some(review_fact) = find_fact_id( + context, + ContextKey::Evaluations, + "meeting:human-confirmation-required", + ) { + return CriterionResult::Blocked { + reason: format!( + "human confirmation required before booking meetings ({review_fact})" + ), + approval_ref: Some(review_fact.into()), + }; + } + + match criterion.id.as_str() { + "outcome.a-ranked-meeting-slate-is-proposed-with-reasoning" => { + require_fact(context, ContextKey::Strategies, "meeting:slate") + } + "outcome.each-proposed-meeting-cites-strategy-alignment-evidence" => { + require_any_fact(context, ContextKey::Signals, "meeting:alignment:") + } + _ => CriterionResult::Indeterminate, + } + } +} + +impl CriterionEvaluator for MonitorBrandSignalEvaluator { + fn evaluate(&self, _criterion: &Criterion, _context: &dyn Context) -> CriterionResult { + CriterionResult::Blocked { + reason: "monitor-brand-signal runtime is not yet implemented".to_string(), + approval_ref: None, + } + } +} + +impl CriterionEvaluator for MatchVisualToTaglineEvaluator { + fn evaluate(&self, _criterion: &Criterion, _context: &dyn Context) -> CriterionResult { + CriterionResult::Blocked { + reason: "match-visual-to-tagline runtime is not yet implemented".to_string(), + approval_ref: None, + } + } +} + +impl CriterionEvaluator for EvaluateAcquisitionTargetEvaluator { + fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { + if let Some(contradiction_fact) = + find_fact_id(context, ContextKey::Evaluations, "dd:human-review-required") + { + return CriterionResult::Blocked { + reason: format!( + "material contradictions require human review before recommendation ({contradiction_fact})" + ), + approval_ref: Some(contradiction_fact.into()), + }; + } + + match criterion.id.as_str() { + "outcome.a-recommendation-is-produced-with-confidence-at-least-0-7" => { + require_fact(context, ContextKey::Proposals, "dd:synthesis") + } + "outcome.all-material-contradictions-are-surfaced-and-documented" => { + let has_contradictions = context + .get(ContextKey::Evaluations) + .iter() + .any(|f| f.id().starts_with("contradiction-")); + if has_contradictions { + let evidence = context + .get(ContextKey::Evaluations) + .iter() + .filter(|f| f.id().starts_with("contradiction-")) + .map(|f| f.id().clone()) + .collect::>(); + CriterionResult::Met { evidence } + } else { + CriterionResult::Met { evidence: vec![] } + } + } + "outcome.each-dd-dimension-cites-at-least-one-independent-source" => { + require_any_fact(context, ContextKey::Hypotheses, "hypothesis-") + } + _ => CriterionResult::Indeterminate, + } + } +} + +/// Look up and bind a truth by key using the CRM pack resolver. +#[must_use] +pub fn converge_binding_for_truth(truth_key: &str) -> Option { + find_truth(truth_key) + .and_then(|def| TruthConvergeBinding::build(def, &CrmPackResolver).ok()) +} + +/// Look up a truth by key and return it as a `ConvergeTruth` using the CRM pack resolver. +#[must_use] +pub fn converge_truth_definition(truth_key: &str) -> Option { + find_truth(truth_key) + .and_then(|def| to_converge_truth(def, &CrmPackResolver).ok()) +} + +fn find_fact_id(context: &dyn Context, key: ContextKey, fact_id: &str) -> Option { + context + .get(key) + .iter() + .find(|fact| fact.id().as_str() == fact_id) + .map(|fact| fact.id().to_string()) +} + +fn require_fact(context: &dyn Context, key: ContextKey, fact_id: &str) -> CriterionResult { + if let Some(fact_id) = find_fact_id(context, key, fact_id) { + CriterionResult::Met { + evidence: vec![FactId::new(fact_id)], + } + } else { + CriterionResult::Unmet { + reason: format!("{fact_id} fact is missing"), + } + } +} + +fn require_any_fact(context: &dyn Context, key: ContextKey, prefix: &str) -> CriterionResult { + let evidence = context + .get(key) + .iter() + .filter(|fact| fact.id().starts_with(prefix)) + .map(|fact| fact.id().clone()) + .collect::>(); + if evidence.is_empty() { + CriterionResult::Unmet { + reason: format!("no facts found with prefix {prefix}"), + } + } else { + CriterionResult::Met { evidence } + } +} diff --git a/crates/crm-truths/src/lib.rs b/crates/crm-truths/src/lib.rs new file mode 100644 index 0000000..da64971 --- /dev/null +++ b/crates/crm-truths/src/lib.rs @@ -0,0 +1,1150 @@ +//! CRM truth content over the truth-catalog mechanism — app-side forever, never foundation. +pub mod evaluators; +pub mod overlay; +pub mod recipes; +pub mod resolver; + +use converge_model::{ + TruthCatalog as ConvergeTruthCatalog, + TruthDefinition as ConvergeTruth, +}; +use truth_catalog::{TruthCatalog, TruthDefinition, TruthKey, TruthKind, TruthModuleTouch, to_converge_truth}; +use crate::resolver::CrmPackResolver; + +pub use evaluators::{ + ActivateSubscriptionEvaluator, + EvaluateAcquisitionTargetEvaluator, + MatchRenewalContextEvaluator, + MatchVisualToTaglineEvaluator, + MonitorBrandSignalEvaluator, + PlanOutboundCampaignEvaluator, + QualifyInboundLeadEvaluator, + ReconcileModelUsageAgainstCustomerLedgerEvaluator, + RefillPrepaidAiCreditsEvaluator, + ScheduleStrategicMeetingsEvaluator, + ScoreInboundFitEvaluator, + SuspendServiceOnPaymentFailureEvaluator, + UpgradeSubscriptionPlanEvaluator, + converge_binding_for_truth, + converge_truth_definition, +}; +pub use overlay::compile_intent_for_truth; +pub use recipes::{organism_binding_for_truth, display_pack_names_for_truth}; + +pub struct StaticTruthCatalog; + +impl ConvergeTruthCatalog for StaticTruthCatalog { + fn list_truths(&self) -> Vec { + all_truths() + .into_iter() + .map(|t| to_converge_truth(t, &CrmPackResolver).unwrap_or_else(|e| panic!("{e}"))) + .collect() + } +} + +pub const TRUTHS: &[TruthDefinition] = &[ + TruthDefinition { + key: "qualify-inbound-lead", + display_name: "Qualify inbound lead", + kind: TruthKind::Job, + summary: "Capture inbound demand, verify fit, and assign an explicit next commercial step.", + feature_path: "truths/jobs/qualify_inbound_lead.feature", + actor_roles: &["commercial-operator", "sales-agent"], + approval_points: &["manual handoff when fit or authority is ambiguous"], + desired_outcomes: &[ + "lead is explicitly qualified or disqualified", + "next owner and next step are recorded", + ], + guardrails: &[ + "qualification facts must cite attributable evidence", + "disqualification reason must be explicit and queryable", + ], + modules: &[ + TruthModuleTouch { + module_key: "parties", + responsibility: "persist organization, contact, and stakeholder context", + }, + TruthModuleTouch { + module_key: "opportunities", + responsibility: "create lead and opportunity state", + }, + TruthModuleTouch { + module_key: "conversations", + responsibility: "capture the inbound thread and follow-up context", + }, + TruthModuleTouch { + module_key: "facts", + responsibility: "promote verified qualification signals", + }, + TruthModuleTouch { + module_key: "intents", + responsibility: "frame the JTBD and success criteria", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/qualify_inbound_lead.feature" + )), + }, + TruthDefinition { + key: "score-inbound-fit", + display_name: "Score inbound fit", + kind: TruthKind::Job, + summary: "Use website behavior and inbound context to produce a governed fit score for a lead.", + feature_path: "truths/jobs/score_inbound_fit.feature", + actor_roles: &["growth-operator", "commercial-analyst", "runtime-agent"], + approval_points: &["manual review when the behavioral signal quality is weak"], + desired_outcomes: &[ + "a governed fit score is recorded for the inbound lead", + "the score cites attributable behavioral evidence", + ], + guardrails: &[ + "fit scoring must retain traceable behavioral provenance", + "weak or sparse signal quality must not be treated as high confidence", + ], + modules: &[ + TruthModuleTouch { + module_key: "parties", + responsibility: "anchor the score to an organization or contact context", + }, + TruthModuleTouch { + module_key: "metering", + responsibility: "supply attributable website and usage event history", + }, + TruthModuleTouch { + module_key: "opportunities", + responsibility: "make the commercial fit signal available to downstream lead handling", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/score_inbound_fit.feature" + )), + }, + TruthDefinition { + key: "plan-outbound-campaign", + display_name: "Plan outbound campaign", + kind: TruthKind::Job, + summary: "Assign prospects to reps and schedule campaign work under capacity and budget guardrails.", + feature_path: "truths/jobs/plan_outbound_campaign.feature", + actor_roles: &["growth-operator", "sales-manager", "runtime-agent"], + approval_points: &["manual approval when campaign spend exceeds the allocated budget"], + desired_outcomes: &[ + "a governed outbound campaign plan exists", + "campaign budget status is explicit and queryable", + ], + guardrails: &[ + "campaign plans must retain assignment rationale", + "budget overruns require an explicit approval path", + ], + modules: &[ + TruthModuleTouch { + module_key: "opportunities", + responsibility: "provide the prospect pool and expected commercial value", + }, + TruthModuleTouch { + module_key: "tasks", + responsibility: "translate campaign assignments into executable work", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "track the campaign plan and exception path", + }, + TruthModuleTouch { + module_key: "ledger", + responsibility: "govern budget consumption and auditability", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/plan_outbound_campaign.feature" + )), + }, + TruthDefinition { + key: "match-renewal-context", + display_name: "Match renewal context", + kind: TruthKind::Job, + summary: "Retrieve and converge the most relevant account history ahead of a contract renewal.", + feature_path: "truths/jobs/match_renewal_context.feature", + actor_roles: &["account-owner", "renewal-manager", "runtime-agent"], + approval_points: &["manual review when renewal terms fall outside the standard path"], + desired_outcomes: &[ + "a renewal brief is attached to the account or renewal motion", + "retrieved renewal signals stay traceable to their source artifacts", + ], + guardrails: &[ + "renewal retrieval must preserve source attribution", + "non-standard renewal terms require an explicit human gate", + ], + modules: &[ + TruthModuleTouch { + module_key: "parties", + responsibility: "anchor retrieval to the customer account and stakeholders", + }, + TruthModuleTouch { + module_key: "conversations", + responsibility: "supply call, email, and timeline context", + }, + TruthModuleTouch { + module_key: "documents", + responsibility: "store the resulting renewal brief and source artifacts", + }, + TruthModuleTouch { + module_key: "opportunities", + responsibility: "tie retrieved context to the renewal commercial motion", + }, + TruthModuleTouch { + module_key: "memory", + responsibility: "provide semantic retrieval and learned relevance", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/match_renewal_context.feature" + )), + }, + TruthDefinition { + key: "submit-expense-report", + display_name: "Submit expense report", + kind: TruthKind::Job, + summary: "Submit a reimbursable expense report with receipt evidence, policy review, and export-ready approval state.", + feature_path: "truths/jobs/submit_expense_report.feature", + actor_roles: &["employee", "finance-approver", "runtime-agent"], + approval_points: &[ + "manual review when OCR confidence or policy fit is ambiguous", + "manual approval when spend falls outside the allowed envelope", + ], + desired_outcomes: &[ + "expense report is submitted with attributable receipt evidence", + "approval route and export status are explicit and queryable", + ], + guardrails: &[ + "every claimed amount must remain attached to receipt evidence", + "out-of-policy or low-confidence extraction must open an explicit human gate", + ], + modules: &[ + TruthModuleTouch { + module_key: "expenses", + responsibility: "own the expense report, expense items, and export readiness state", + }, + TruthModuleTouch { + module_key: "documents", + responsibility: "persist receipt evidence and OCR output artifacts", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "track review, exception, and export status", + }, + TruthModuleTouch { + module_key: "approvals", + responsibility: "govern non-standard or elevated-risk spend decisions", + }, + TruthModuleTouch { + module_key: "policies", + responsibility: "apply reimbursement rules and policy thresholds", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/submit_expense_report.feature" + )), + }, + TruthDefinition { + key: "create-customer-workspace", + display_name: "Create customer workspace", + kind: TruthKind::Job, + summary: "Provision a customer workspace with the right commercial and access context.", + feature_path: "truths/jobs/create_customer_workspace.feature", + actor_roles: &["customer-ops", "revops", "runtime-agent"], + approval_points: &["exception approval before provisioning non-standard workspaces"], + desired_outcomes: &[ + "workspace exists with the correct owner", + "commercial plan and quotas are attached", + ], + guardrails: &[ + "provisioning cannot finish without a linked account", + "workspace activation must reference a commercial commitment", + ], + modules: &[ + TruthModuleTouch { + module_key: "parties", + responsibility: "anchor the workspace to the customer account", + }, + TruthModuleTouch { + module_key: "subscriptions", + responsibility: "bind the workspace to the purchased commitment", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "apply quotas and feature access", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "track the provisioning case and exceptions", + }, + TruthModuleTouch { + module_key: "approvals", + responsibility: "control exceptions or manual releases", + }, + TruthModuleTouch { + module_key: "intents", + responsibility: "keep the operator-facing job context explicit", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/create_customer_workspace.feature" + )), + }, + TruthDefinition { + key: "activate-subscription", + display_name: "Activate subscription", + kind: TruthKind::Job, + summary: "Turn an agreed commercial plan into an active subscription and entitlement state.", + feature_path: "truths/jobs/activate_subscription.feature", + actor_roles: &["revops", "billing-operator"], + approval_points: &["manual review for non-standard plan terms"], + desired_outcomes: &[ + "subscription becomes active with an explicit plan", + "entitlements and financial opening state are aligned", + ], + guardrails: &[ + "an active subscription must resolve to a valid catalog plan", + "activation events must remain auditable", + ], + modules: &[ + TruthModuleTouch { + module_key: "catalog", + responsibility: "resolve the plan and pricing definition", + }, + TruthModuleTouch { + module_key: "subscriptions", + responsibility: "persist subscription lifecycle state", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "derive usable access from the plan", + }, + TruthModuleTouch { + module_key: "ledger", + responsibility: "open the auditable commercial balance context", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "coordinate activation checks and handoffs", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/activate_subscription.feature" + )), + }, + TruthDefinition { + key: "refill-prepaid-ai-credits", + display_name: "Refill prepaid AI credits", + kind: TruthKind::Job, + summary: "Apply a top-up purchase to prepaid AI credit balances with financial traceability.", + feature_path: "truths/jobs/refill_prepaid_ai_credits.feature", + actor_roles: &["customer", "billing-operator", "runtime-agent"], + approval_points: &["manual review for unusual top-up size or risk signal"], + desired_outcomes: &[ + "confirmed top-up appears in the ledger", + "entitlement balance increases for the correct account", + ], + guardrails: &[ + "payment must be confirmed before any credit grant", + "top-up must remain linked to the customer account and commercial context", + ], + modules: &[ + TruthModuleTouch { + module_key: "parties", + responsibility: "link the purchase to the customer account", + }, + TruthModuleTouch { + module_key: "subscriptions", + responsibility: "resolve the active commercial commitment", + }, + TruthModuleTouch { + module_key: "payments", + responsibility: "confirm settlement state", + }, + TruthModuleTouch { + module_key: "ledger", + responsibility: "record the auditable credit grant", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "increase the usable prepaid balance", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/refill_prepaid_ai_credits.feature" + )), + }, + TruthDefinition { + key: "upgrade-subscription-plan", + display_name: "Upgrade subscription plan", + kind: TruthKind::Job, + summary: "Migrate a customer to a better plan while keeping pricing, access, and approval history coherent.", + feature_path: "truths/jobs/upgrade_subscription_plan.feature", + actor_roles: &["account-owner", "customer", "revops"], + approval_points: &["approval for price override or custom migration terms"], + desired_outcomes: &[ + "subscription moves to the target plan on an explicit date", + "entitlements and commercial delta stay aligned", + ], + guardrails: &[ + "target plan must exist in catalog", + "non-standard commercial deltas require explicit approval", + ], + modules: &[ + TruthModuleTouch { + module_key: "catalog", + responsibility: "resolve target plan and pricing metadata", + }, + TruthModuleTouch { + module_key: "subscriptions", + responsibility: "apply the lifecycle transition", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "swap access and quota state", + }, + TruthModuleTouch { + module_key: "approvals", + responsibility: "govern exceptional terms", + }, + TruthModuleTouch { + module_key: "ledger", + responsibility: "record financial deltas and adjustments", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/upgrade_subscription_plan.feature" + )), + }, + TruthDefinition { + key: "suspend-service-on-payment-failure", + display_name: "Suspend service on payment failure", + kind: TruthKind::Job, + summary: "Apply suspension policy when payment state fails while preserving controlled recovery paths.", + feature_path: "truths/jobs/suspend_service_on_payment_failure.feature", + actor_roles: &["billing-operator", "customer-success", "runtime-agent"], + approval_points: &["override approval before suspending strategic accounts"], + desired_outcomes: &[ + "service state matches payment policy", + "customer receives a clear recovery path", + ], + guardrails: &[ + "grace rules must be evaluated before suspension", + "reactivation path must remain explicit and auditable", + ], + modules: &[ + TruthModuleTouch { + module_key: "payments", + responsibility: "surface failed or overdue payment state", + }, + TruthModuleTouch { + module_key: "subscriptions", + responsibility: "apply subscription lifecycle suspension", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "reduce or pause access appropriately", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "run the suspension case and grace timers", + }, + TruthModuleTouch { + module_key: "parties", + responsibility: "keep customer ownership and communication routing intact", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/suspend_service_on_payment_failure.feature" + )), + }, + TruthDefinition { + key: "resolve-support-incident", + display_name: "Resolve support incident", + kind: TruthKind::Job, + summary: "Drive a customer issue from intake through diagnosis to a verified resolution or escalation.", + feature_path: "truths/jobs/resolve_support_incident.feature", + actor_roles: &["support-agent", "subject-matter-expert", "customer"], + approval_points: &["escalation approval for risky or customer-impacting workaround"], + desired_outcomes: &[ + "incident is resolved or deliberately escalated", + "root cause and customer-facing resolution are documented", + ], + guardrails: &[ + "resolution claims require evidence", + "customer-visible status must be updated before closure", + ], + modules: &[ + TruthModuleTouch { + module_key: "conversations", + responsibility: "hold the incident thread and external communications", + }, + TruthModuleTouch { + module_key: "tasks", + responsibility: "coordinate follow-ups and handoffs", + }, + TruthModuleTouch { + module_key: "documents", + responsibility: "store runbooks, notes, and attachments", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "track severity, SLA, and resolution state", + }, + TruthModuleTouch { + module_key: "facts", + responsibility: "promote verified diagnosis and remediation facts", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/resolve_support_incident.feature" + )), + }, + TruthDefinition { + key: "reconcile-model-usage-against-customer-ledger", + display_name: "Reconcile model usage against customer ledger", + kind: TruthKind::Job, + summary: "Align usage metering, financial balance, and entitlement burn-down without mutating history.", + feature_path: "truths/jobs/reconcile_model_usage_against_customer_ledger.feature", + actor_roles: &["finance-ops", "runtime-agent"], + approval_points: &["human review for unreconciled delta above threshold"], + desired_outcomes: &[ + "usage and financial state reconcile cleanly", + "exceptions are recorded and routed", + ], + guardrails: &[ + "reconciliation must preserve immutable ledger history", + "adjustments must remain traceable to evidence", + ], + modules: &[ + TruthModuleTouch { + module_key: "metering", + responsibility: "provide normalized usage events and consumption state", + }, + TruthModuleTouch { + module_key: "ledger", + responsibility: "provide auditable financial balance movements", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "compare usage against usable balance and quota state", + }, + TruthModuleTouch { + module_key: "subscriptions", + responsibility: "resolve commercial terms and billing period context", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "route reconciliation exceptions into operator review flows", + }, + TruthModuleTouch { + module_key: "audit", + responsibility: "preserve reconciliation provenance and evidence", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/reconcile_model_usage_against_customer_ledger.feature" + )), + }, + TruthDefinition { + key: "schedule-strategic-meetings", + display_name: "Schedule strategic meetings", + kind: TruthKind::Job, + summary: "Collapse multi-tool scheduling into a single intent: rank prospects by strategy alignment, resolve availability, and propose a concrete meeting slate with reasoning.", + feature_path: "truths/jobs/schedule_strategic_meetings.feature", + actor_roles: &["commercial-operator", "account-owner", "sales-agent"], + approval_points: &["human confirmation before any meeting is booked"], + desired_outcomes: &[ + "a ranked meeting slate is proposed with reasoning", + "each proposed meeting cites strategy alignment evidence", + ], + guardrails: &[ + "no meeting shall be auto-booked without human confirmation", + "candidate ranking must cite pipeline score and strategy context", + ], + modules: &[ + TruthModuleTouch { + module_key: "parties", + responsibility: "resolve prospect organizations, contacts, and relationship context", + }, + TruthModuleTouch { + module_key: "opportunities", + responsibility: "supply scored pipeline and commercial readiness signals", + }, + TruthModuleTouch { + module_key: "conversations", + responsibility: "provide communication history and scheduling preferences", + }, + TruthModuleTouch { + module_key: "tasks", + responsibility: "create bookable meeting tasks from the proposed slate", + }, + TruthModuleTouch { + module_key: "facts", + responsibility: "record strategy alignment evidence for each candidate", + }, + TruthModuleTouch { + module_key: "intents", + responsibility: "preserve the original free-text scheduling intent", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/schedule_strategic_meetings.feature" + )), + }, + TruthDefinition { + key: "detect-abnormal-token-burn", + display_name: "Detect abnormal token burn", + kind: TruthKind::Job, + summary: "Detect unusual usage patterns early and route a controlled mitigation path.", + feature_path: "truths/jobs/detect_abnormal_token_burn.feature", + actor_roles: &["runtime-agent", "customer-success"], + approval_points: &["operator approval before hard-limit intervention"], + desired_outcomes: &[ + "anomaly is explained with telemetry", + "a mitigation case is opened with recommended actions", + ], + guardrails: &[ + "automated intervention must respect policy thresholds", + "anomaly assertions must cite observed telemetry", + ], + modules: &[ + TruthModuleTouch { + module_key: "metering", + responsibility: "surface the usage anomaly signals", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "show quota and balance exposure", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "open and track the intervention case", + }, + TruthModuleTouch { + module_key: "memory", + responsibility: "provide historical context and comparable patterns", + }, + TruthModuleTouch { + module_key: "agent-ops", + responsibility: "track the detecting agents and validation chain", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/detect_abnormal_token_burn.feature" + )), + }, + TruthDefinition { + key: "renew-contract", + display_name: "Renew contract", + kind: TruthKind::Job, + summary: "Move a renewal from account context to approved commercial terms and current documents.", + feature_path: "truths/jobs/renew_contract.feature", + actor_roles: &["account-owner", "legal-operator", "customer"], + approval_points: &["approval for non-standard renewal terms"], + desired_outcomes: &[ + "renewal ends in accepted terms or an explicit no-renew decision", + "current commercial documents remain linked and versioned", + ], + guardrails: &[ + "renewal cannot close without explicit commercial terms", + "the current proposal or contract version must remain traceable", + ], + modules: &[ + TruthModuleTouch { + module_key: "parties", + responsibility: "provide account and stakeholder ownership context", + }, + TruthModuleTouch { + module_key: "catalog", + responsibility: "resolve current offerable plans and prices", + }, + TruthModuleTouch { + module_key: "opportunities", + responsibility: "carry renewal pipeline and forecast state", + }, + TruthModuleTouch { + module_key: "subscriptions", + responsibility: "link the renewal to the active commercial commitment", + }, + TruthModuleTouch { + module_key: "approvals", + responsibility: "govern non-standard commercial decisions", + }, + TruthModuleTouch { + module_key: "documents", + responsibility: "store proposal, quote, and contract artifacts", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/renew_contract.feature" + )), + }, + TruthDefinition { + key: "monitor-brand-signal", + display_name: "Monitor brand signal", + kind: TruthKind::Job, + summary: "Ingest external brand mentions, cluster them into narratives, and keep a governed brand-state projection current without auto-publishing anything.", + feature_path: "truths/jobs/monitor_brand_signal.feature", + actor_roles: &["brand-manager", "communications-lead", "runtime-agent"], + approval_points: &[ + "promote SignalIncident at severity high or above", + "revise risk thresholds on the BrandWatch policy", + ], + desired_outcomes: &[ + "brand-state projection is current within the watch cadence", + "every promoted incident cites traceable source evidence", + ], + guardrails: &[ + "mentions must retain source URL and retrieval timestamp", + "clustering must be reproducible from stored embeddings", + "sentiment must carry calibrated confidence, not a bare label", + "high-severity incidents cannot auto-promote", + "no outbound response may be generated by this truth", + ], + modules: &[ + TruthModuleTouch { + module_key: "parties", + responsibility: "anchor the BrandWatch to an organization, product, or executive", + }, + TruthModuleTouch { + module_key: "conversations", + responsibility: "store raw signal items and cluster membership", + }, + TruthModuleTouch { + module_key: "documents", + responsibility: "store narrative summaries and incident briefs", + }, + TruthModuleTouch { + module_key: "memory", + responsibility: "provide embeddings and semantic retrieval", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "run the watch cadence and incident lifecycle", + }, + TruthModuleTouch { + module_key: "approvals", + responsibility: "gate high-severity incident promotion", + }, + TruthModuleTouch { + module_key: "intents", + responsibility: "preserve the operator-facing BrandWatch context", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/monitor_brand_signal.feature" + )), + }, + TruthDefinition { + key: "match-visual-to-tagline", + display_name: "Match visual to tagline", + kind: TruthKind::Job, + summary: "Match brand-safe visuals with taglines at scale under typed multi-role approval, so campaign pairings ship only with brand, marketing, and storyteller sign-off.", + feature_path: "truths/jobs/match_visual_to_tagline.feature", + actor_roles: &[ + "campaign-owner", + "brand-manager", + "marketer", + "storyteller", + "legal-reviewer", + "runtime-agent", + ], + approval_points: &[ + "brand-manager approval of brand fit", + "marketer approval of audience fit", + "storyteller approval of narrative and copy", + "legal approval when a risk flag is raised", + ], + desired_outcomes: &[ + "a governed CampaignPairing fact exists for the brief", + "every chosen pairing cites brand, audience, and narrative evidence", + ], + guardrails: &[ + "taglines must comply with brand voice policy facts", + "synthetic visuals must be flagged and never auto-selected", + "risk-flagged pairings require legal approval", + "priors used in scoring must be traceable to an analytics source", + ], + modules: &[ + TruthModuleTouch { + module_key: "intents", + responsibility: "carry the CampaignBrief and desired outcome", + }, + TruthModuleTouch { + module_key: "documents", + responsibility: "store briefs, pairings, approvals, and rationale", + }, + TruthModuleTouch { + module_key: "memory", + responsibility: "supply brand guardrails, priors, and multimodal embeddings", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "run the multi-gate approval lifecycle", + }, + TruthModuleTouch { + module_key: "approvals", + responsibility: "enforce typed approval gates per role", + }, + TruthModuleTouch { + module_key: "parties", + responsibility: "anchor campaign owner and approving actors", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/match_visual_to_tagline.feature" + )), + }, + TruthDefinition { + key: "evaluate-acquisition-target", + display_name: "Evaluate acquisition target", + kind: TruthKind::Job, + summary: "Converge multi-source evidence into a structured acquisition recommendation with traceable evidence, contradiction surfacing, and honest stopping.", + feature_path: "truths/jobs/evaluate_acquisition_target.feature", + actor_roles: &["deal-lead", "investment-committee", "research-analyst"], + approval_points: &[ + "investment committee approval before recommendation leaves draft", + "human review when material contradictions are detected", + ], + desired_outcomes: &[ + "a recommendation is produced with confidence at least 0.7", + "all material contradictions are surfaced and documented", + "each DD dimension cites at least one independent source", + ], + guardrails: &[ + "no recommendation without adversarial review passing", + "contradictions must be surfaced, never resolved silently", + "human approval required before recommendation leaves draft", + ], + modules: &[ + TruthModuleTouch { + module_key: "facts", + responsibility: "hold research hypotheses and promoted findings", + }, + TruthModuleTouch { + module_key: "documents", + responsibility: "store source evidence, analysis pages, and the final brief", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "coordinate the DD lifecycle and approval gates", + }, + TruthModuleTouch { + module_key: "approvals", + responsibility: "enforce investment committee sign-off", + }, + TruthModuleTouch { + module_key: "audit", + responsibility: "preserve the full evidence and decision trail", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/jobs/evaluate_acquisition_target.feature" + )), + }, + TruthDefinition { + key: "top-up-requires-confirmed-payment", + display_name: "Top-up requires confirmed payment", + kind: TruthKind::Policy, + summary: "No prepaid balance increase may occur until settlement is confirmed.", + feature_path: "truths/policies/top_up_requires_confirmed_payment.feature", + actor_roles: &["billing-operator", "runtime-agent"], + approval_points: &["override approval for manual corrective grant"], + desired_outcomes: &[ + "credit grants only occur after confirmed settlement", + "manual overrides remain explicit and auditable", + ], + guardrails: &[ + "unconfirmed payment blocks credit application", + "override path must create provenance and rationale", + ], + modules: &[ + TruthModuleTouch { + module_key: "payments", + responsibility: "declare settlement state", + }, + TruthModuleTouch { + module_key: "ledger", + responsibility: "block or record the credit movement", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "avoid premature balance increase", + }, + TruthModuleTouch { + module_key: "policies", + responsibility: "own the cross-module guardrail", + }, + TruthModuleTouch { + module_key: "audit", + responsibility: "capture override evidence and decision trail", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/policies/top_up_requires_confirmed_payment.feature" + )), + }, + TruthDefinition { + key: "overdue-balance-blocks-entitlement-increase", + display_name: "Overdue balance blocks entitlement increase", + kind: TruthKind::Policy, + summary: "Customers with overdue obligations should not receive expanded access without exception handling.", + feature_path: "truths/policies/overdue_balance_blocks_entitlement_increase.feature", + actor_roles: &["finance-ops", "customer-success"], + approval_points: &["exception approval for temporary relief"], + desired_outcomes: &[ + "overdue customers do not receive expanded entitlements by default", + "temporary relief remains explicit and time-bound", + ], + guardrails: &[ + "overdue evaluation must use current payment state", + "exceptions must expire or be revisited explicitly", + ], + modules: &[ + TruthModuleTouch { + module_key: "payments", + responsibility: "surface overdue state", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "block entitlement expansion until resolved", + }, + TruthModuleTouch { + module_key: "policies", + responsibility: "define the blocking rule and exception policy", + }, + TruthModuleTouch { + module_key: "workflow", + responsibility: "run the exception path and follow-up timers", + }, + TruthModuleTouch { + module_key: "parties", + responsibility: "bind the exception to the customer account", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/policies/overdue_balance_blocks_entitlement_increase.feature" + )), + }, + TruthDefinition { + key: "promoted-fact-requires-traceable-evidence", + display_name: "Promoted fact requires traceable evidence", + kind: TruthKind::Policy, + summary: "Durable business truth must remain backed by evidence and provenance.", + feature_path: "truths/policies/promoted_fact_requires_traceable_evidence.feature", + actor_roles: &["analyst", "runtime-agent", "approver"], + approval_points: &["approval for low-confidence promotion"], + desired_outcomes: &[ + "every promoted fact links to evidence", + "low-confidence facts stay proposed until reviewed", + ], + guardrails: &[ + "unverifiable statements shall not become durable truth", + "provenance for promoted facts shall remain immutable", + ], + modules: &[ + TruthModuleTouch { + module_key: "facts", + responsibility: "hold proposed and promoted facts", + }, + TruthModuleTouch { + module_key: "documents", + responsibility: "store or link the supporting evidence", + }, + TruthModuleTouch { + module_key: "audit", + responsibility: "capture the promotion decision trail", + }, + TruthModuleTouch { + module_key: "policies", + responsibility: "enforce the promotion guardrail", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/policies/promoted_fact_requires_traceable_evidence.feature" + )), + }, + TruthDefinition { + key: "ledger-entry-is-immutable", + display_name: "Ledger entry is immutable", + kind: TruthKind::ModuleLocal, + summary: "Posted balance movements remain append-only, with corrections expressed as new entries.", + feature_path: "truths/modules/ledger_entry_is_immutable.feature", + actor_roles: &["finance-ops"], + approval_points: &[], + desired_outcomes: &[ + "original ledger entries remain unchanged", + "corrections are expressed as adjusting entries", + ], + guardrails: &[ + "posted ledger entries are append-only", + "correction chains must stay audit-linked", + ], + modules: &[ + TruthModuleTouch { + module_key: "ledger", + responsibility: "own immutable balance history", + }, + TruthModuleTouch { + module_key: "audit", + responsibility: "preserve the correction provenance chain", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/modules/ledger_entry_is_immutable.feature" + )), + }, + TruthDefinition { + key: "active-subscription-requires-plan", + display_name: "Active subscription requires plan", + kind: TruthKind::ModuleLocal, + summary: "A subscription cannot be active unless it resolves to a valid plan and entitlement source.", + feature_path: "truths/modules/active_subscription_requires_plan.feature", + actor_roles: &["revops", "runtime-agent"], + approval_points: &[], + desired_outcomes: &[ + "every active subscription maps to a valid plan", + "the entitlement source for active access is explicit", + ], + guardrails: &[ + "activation is blocked without a valid plan", + "entitlement template source must be explicit", + ], + modules: &[ + TruthModuleTouch { + module_key: "catalog", + responsibility: "provide the authoritative plan definition", + }, + TruthModuleTouch { + module_key: "subscriptions", + responsibility: "own subscription lifecycle validity", + }, + TruthModuleTouch { + module_key: "entitlements", + responsibility: "resolve access from the selected plan", + }, + ], + gherkin: include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/truths/modules/active_subscription_requires_plan.feature" + )), + }, +]; + +pub const CRM_CATALOG: TruthCatalog<'static> = TruthCatalog::new(TRUTHS); + +#[must_use] +pub fn all_truths() -> Vec { + CRM_CATALOG.all().to_vec() +} + +#[must_use] +pub fn truths_by_kind(kind: TruthKind) -> Vec { + CRM_CATALOG.by_kind(kind).into_iter().copied().collect() +} + +#[must_use] +pub fn truths_for_module(module_key: &str) -> Vec { + CRM_CATALOG.for_module(module_key).into_iter().copied().collect() +} + +#[must_use] +pub fn find_truth(key: &str) -> Option { + TruthKey::parse(key).ok().and_then(|k| CRM_CATALOG.find(&k).copied()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use capability_registry::MODULES; + + use super::{TRUTHS, TruthKind, converge_binding_for_truth, truths_by_kind}; + + #[test] + fn starter_catalog_has_eighteen_job_truths() { + assert_eq!(truths_by_kind(TruthKind::Job).len(), 18); + } + + #[test] + fn starter_catalog_spans_all_truth_classes() { + assert!(!truths_by_kind(TruthKind::Job).is_empty()); + assert!(!truths_by_kind(TruthKind::Policy).is_empty()); + assert!(!truths_by_kind(TruthKind::ModuleLocal).is_empty()); + } + + #[test] + fn every_referenced_module_exists_in_registry() { + let known_modules = MODULES + .iter() + .map(|module| module.key) + .collect::>(); + for truth in TRUTHS { + for touch in truth.modules { + assert!( + known_modules.contains(touch.module_key), + "unknown module '{}' in truth '{}'", + touch.module_key, + truth.key + ); + } + } + } + + #[test] + fn qualify_inbound_lead_maps_to_converge_binding() { + let binding = converge_binding_for_truth("qualify-inbound-lead") + .expect("binding should exist for starter truth"); + assert_eq!(binding.runtime, "converge"); + assert_eq!( + binding.pack_ids, + vec![ + "prio-relationship-pack", + "prio-commercial-pack", + "prio-work-pack", + "trust", + "knowledge", + ] + ); + assert_eq!(binding.intent.id.as_str(), "truth:qualify-inbound-lead"); + assert_eq!( + binding.intent.request, + "Qualify inbound lead: Capture inbound demand, verify fit, and assign an explicit next commercial step." + ); + assert_eq!( + binding.intent.active_packs, + vec![ + "prio-relationship-pack".to_string(), + "prio-commercial-pack".to_string(), + "prio-work-pack".to_string(), + "trust".to_string(), + "knowledge".to_string(), + ] + ); + assert_eq!(binding.intent.success_criteria.len(), 2); + assert_eq!(binding.intent.constraints.len(), 3); + } +} diff --git a/crates/crm-truths/src/overlay.rs b/crates/crm-truths/src/overlay.rs new file mode 100644 index 0000000..710dde4 --- /dev/null +++ b/crates/crm-truths/src/overlay.rs @@ -0,0 +1,67 @@ +use chrono::{Duration, Utc}; +use organism_pack::IntentPacket; +use truth_catalog::{TruthDefinition, resolve::IntentOverlay, intent_compile::{compile_intent_with_overlay, CompileTruthError}}; + +pub struct CrmIntentOverlay; + +impl IntentOverlay for CrmIntentOverlay { + fn apply(&self, truth: &TruthDefinition, intent: &mut IntentPacket) { + intent.expires = Utc::now() + Duration::hours(1); + match truth.key { + "qualify-inbound-lead" => { + intent.context = serde_json::json!({ + "pending": ["lead:inbound"], + "strategies": "next owner and route required", + }); + intent.constraints = vec!["lead_has_source".to_string()]; + } + "submit-expense-report" => { + intent.context = serde_json::json!({ + "expense": { + "receipt": "receipt:pending", + "category": "expense:travel", + "approval": "approval:route", + "budget": "budget_envelope:team-travel", + }, + "documents": ["receipt:pending"], + "evaluations": "approval review required", + }); + intent.constraints = vec![ + "approval_has_rationale".to_string(), + "no_spend_beyond_envelope".to_string(), + ]; + } + "evaluate-acquisition-target" => { + intent.context = serde_json::json!({ + "target": "company:pending", + "research": ["market", "competition", "technology", "financials", "team"], + "evaluations": "investment committee review required", + }); + intent.constraints = vec![ + "contradictions_flagged".to_string(), + "synthesis_requires_coverage".to_string(), + "hypothesis_has_source".to_string(), + ]; + intent.authority = vec!["investment-committee".to_string()]; + } + "plan-outbound-campaign" => { + intent.context = serde_json::json!({ + "campaign": "campaign:q3-pipeline", + "audience": "audience:target-accounts", + "budget": "budget:quarterly-outbound", + "evaluations": "attribution review", + }); + intent.constraints = vec!["budget_guardrails_enforced".to_string()]; + } + _ => {} + } + } +} + +/// Compile a [`TruthDefinition`] into an [`IntentPacket`] using the CRM overlay. +/// +/// # Errors +/// Returns [`CompileTruthError`] when axiom cannot parse or compile the truth source. +pub fn compile_intent_for_truth(truth: &TruthDefinition) -> Result { + compile_intent_with_overlay(truth, &CrmIntentOverlay) +} diff --git a/crates/crm-truths/src/recipes.rs b/crates/crm-truths/src/recipes.rs new file mode 100644 index 0000000..b9111ae --- /dev/null +++ b/crates/crm-truths/src/recipes.rs @@ -0,0 +1,117 @@ +use organism_pack::{DeclarativeBinding, IntentBinding, IntentResolver}; +use organism_runtime::{ + BudgetProbe, CredentialProbe, PackProbe, ReadinessProbe, Registry, + StructuralResolver, check_readiness, +}; +use truth_catalog::{TruthDefinition, TruthOrganismBinding, intent_compile::compile_intent_with_overlay}; +use crate::overlay::CrmIntentOverlay; + +#[must_use] +pub fn organism_binding_for_truth(truth_key: &str, registry: &Registry) -> Option { + crate::find_truth(truth_key).and_then(|truth| build_binding(truth, registry)) +} + +#[must_use] +pub fn display_pack_names_for_truth(truth_key: &str, registry: &Registry) -> Option> { + organism_binding_for_truth(truth_key, registry).map(|b| b.pack_names()) +} + +fn build_binding(truth: TruthDefinition, registry: &Registry) -> Option { + let (blueprint, baseline, readiness) = binding_recipe(truth)?; + let intent = compile_intent_with_overlay(&truth, &CrmIntentOverlay) + .expect("truth has axiom-compilable governance and a known overlay"); + let resolver = StructuralResolver::new(registry); + let binding = resolver.resolve(&intent, &baseline); + let pack_probe = PackProbe::new(registry); + let credential_probe = CredentialProbe::new().with_standard_checks(); + let probes: Vec<&dyn ReadinessProbe> = vec![&pack_probe, &credential_probe, &readiness]; + let readiness = check_readiness(&binding, &probes); + Some(TruthOrganismBinding { truth_key: truth.key, blueprint, binding, readiness }) +} + +fn binding_recipe(truth: TruthDefinition) -> Option<(Option<&'static str>, IntentBinding, BudgetProbe)> { + match truth.key { + "submit-expense-report" => { + let binding = DeclarativeBinding::new() + .pack( + "procurement", + "expense intake, reimbursement routing, and export readiness", + ) + .pack( + "autonomous_org", + "approval policy, spend governance, and exception handling", + ) + .capability("ocr", "extract receipt fields from uploaded evidence") + .invariant("approval_has_rationale") + .invariant("no_spend_beyond_envelope") + .build(); + Some(( + Some("procure_to_pay"), + binding, + BudgetProbe::new() + .with_token_budget(5_000) + .with_spend_budget(5.0), + )) + } + "qualify-inbound-lead" => { + let binding = DeclarativeBinding::new() + .pack("customers", "lead qualification workflow") + .pack( + "linkedin_research", + "external company and stakeholder enrichment", + ) + .invariant("lead_has_source") + .build(); + Some(( + Some("lead_to_cash"), + binding, + BudgetProbe::new() + .with_token_budget(8_000) + .with_spend_budget(8.0), + )) + } + "evaluate-acquisition-target" => { + let binding = DeclarativeBinding::new() + .pack( + "due_diligence", + "convergent research, fact extraction, gap detection, contradiction finding, synthesis", + ) + .pack("legal", "legal review of findings and contractual implications") + .pack( + "knowledge", + "persist confirmed findings to the knowledge base", + ) + .capability("web", "broad and deep web research for company intelligence") + .capability("llm", "fact extraction, gap detection, and synthesis") + .invariant("hypothesis_has_source") + .invariant("contradictions_flagged") + .invariant("synthesis_requires_coverage") + .build(); + Some(( + Some("diligence_to_decision"), + binding, + BudgetProbe::new() + .with_token_budget(20_000) + .with_spend_budget(20.0), + )) + } + "plan-outbound-campaign" => { + let binding = DeclarativeBinding::new() + .pack( + "growth_marketing", + "campaign planning, allocation, and channel execution", + ) + .pack("customers", "downstream lead handling and handoff") + .invariant("budget_guardrails_enforced") + .build(); + Some(( + Some("campaign_to_revenue"), + binding, + BudgetProbe::new() + .with_token_budget(6_000) + .with_spend_budget(6.0), + )) + } + _ => None, + } +} diff --git a/crates/crm-truths/src/resolver.rs b/crates/crm-truths/src/resolver.rs new file mode 100644 index 0000000..cd8afd9 --- /dev/null +++ b/crates/crm-truths/src/resolver.rs @@ -0,0 +1,38 @@ +use capability_core::ModuleSuite; +use capability_registry::find_module; +use truth_catalog::{TruthModuleTouch, resolve::{PackResolver, UnknownModule}}; + +const FOUNDATION_PACK_ID: &str = "prio-foundation-pack"; +const RELATIONSHIP_PACK_ID: &str = "prio-relationship-pack"; +const COMMERCIAL_PACK_ID: &str = "prio-commercial-pack"; +const REVENUE_PACK_ID: &str = "prio-revenue-pack"; +const WORK_PACK_ID: &str = "prio-work-pack"; +const TRUST_PACK_ID: &str = "trust"; +const KNOWLEDGE_PACK_ID: &str = "knowledge"; + +pub struct CrmPackResolver; + +impl PackResolver for CrmPackResolver { + fn pack_ids_for(&self, modules: &[TruthModuleTouch]) -> Result, UnknownModule> { + let mut pack_ids: Vec<&'static str> = Vec::new(); + for touch in modules { + let module = find_module(touch.module_key).ok_or_else(|| UnknownModule { + truth_key: String::new(), + module_key: touch.module_key.to_owned(), + })?; + let pack_id = match module.suite { + ModuleSuite::Foundation => FOUNDATION_PACK_ID, + ModuleSuite::RelationshipCore => RELATIONSHIP_PACK_ID, + ModuleSuite::CommercialCore => COMMERCIAL_PACK_ID, + ModuleSuite::UsageRevenueCore => REVENUE_PACK_ID, + ModuleSuite::WorkCore => WORK_PACK_ID, + ModuleSuite::TrustCore => TRUST_PACK_ID, + ModuleSuite::IntelligenceCore => KNOWLEDGE_PACK_ID, + }; + if !pack_ids.contains(&pack_id) { + pack_ids.push(pack_id); + } + } + Ok(pack_ids) + } +} diff --git a/crates/truth-catalog/tests/applet_manifest.rs b/crates/crm-truths/tests/applet_manifest.rs similarity index 54% rename from crates/truth-catalog/tests/applet_manifest.rs rename to crates/crm-truths/tests/applet_manifest.rs index 1986d76..ea1db05 100644 --- a/crates/truth-catalog/tests/applet_manifest.rs +++ b/crates/crm-truths/tests/applet_manifest.rs @@ -2,14 +2,9 @@ use axiom_truth::{ APPLET_MANIFEST_VERSION, AppletStatus, ConflictPolicy, EvidenceAuthority, applet_manifest_json_schema, parse_applet_manifest_json, }; -use truth_catalog::{find_truth, intent_compile::compile_intent_for_truth}; +use crm_truths::{CRM_CATALOG, compile_intent_for_truth, find_truth}; -// Vendored from the root repo's KB/02-product/applets/ (that repo is private, -// so CI cannot reach it via a relative include). If the canonical manifests -// change, re-copy them here — the schema/binding checks fail loudly on -// divergence. Same pattern as arena-tests' cross-extension-smoke fixtures. const ACTIVATE_SUBSCRIPTION: &str = include_str!("fixtures/activate-subscription.intent.json"); - const REFILL_PREPAID_AI_CREDITS: &str = include_str!("fixtures/refill-prepaid-ai-credits.intent.json"); @@ -64,3 +59,55 @@ fn revenue_applet_manifests_validate_and_bind_to_truth_catalog() { ); } } + +/// Behavior-preservation gate (RFL-172 T5, plan risk 2). +/// +/// Proves that `CrmIntentOverlay` — now injected at the mounting site rather +/// than called inside the mechanism — applies the same overlay fields to +/// `qualify-inbound-lead` that the old key-based `admit_truth_intent` produced. +/// +/// The compiled-intent payload emitted as `axiom.intent.compiled` by +/// `helm-governed-jobs` when the mounting binary injects `CrmIntentOverlay` +/// must match this shape byte-for-byte. +#[test] +fn qualify_inbound_lead_overlay_fields_match_crm_intent_overlay() { + use truth_catalog::TruthKey; + + let key = TruthKey::parse("qualify-inbound-lead").expect("valid key"); + let truth = CRM_CATALOG + .find(&key) + .copied() + .expect("qualify-inbound-lead is in CRM_CATALOG"); + + let intent = compile_intent_for_truth(&truth) + .expect("qualify-inbound-lead compiles with CrmIntentOverlay"); + + // Overlay must set a non-empty outcome from axiom source. + assert!(!intent.outcome.trim().is_empty(), "outcome is set from gherkin"); + + // CrmIntentOverlay sets context with lead-routing fields. + let context = &intent.context; + assert!( + context.get("pending").is_some(), + "context must have 'pending' (lead routing) — got: {context}" + ); + assert!( + context.get("strategies").is_some(), + "context must have 'strategies' (next-owner logic) — got: {context}" + ); + + // CrmIntentOverlay sets constraints for qualify-inbound-lead. + assert!( + intent.constraints.iter().any(|c| c == "lead_has_source"), + "constraints must include 'lead_has_source' — got: {:?}", + intent.constraints + ); + + // Expiry is set by the overlay (1 hour horizon). + let now = chrono::Utc::now(); + assert!( + intent.expires > now, + "intent expiry must be in the future — got: {:?}", + intent.expires + ); +} diff --git a/crates/crm-truths/tests/catalog_mount.rs b/crates/crm-truths/tests/catalog_mount.rs new file mode 100644 index 0000000..33807a8 --- /dev/null +++ b/crates/crm-truths/tests/catalog_mount.rs @@ -0,0 +1,88 @@ +//! Mounting-layer catalog injection test (RFL-172 Item 0). +//! +//! Verifies that `CRM_CATALOG` is correctly injected and queryable — +//! covering the canonical truth keys and the None path for absent keys. +use truth_catalog::TruthKey; + +#[test] +fn crm_catalog_injected_into_job_state_resolves_qualify_inbound_lead() { + let catalog = crm_truths::CRM_CATALOG; + let key: TruthKey = "qualify-inbound-lead".parse().expect("valid key"); + let truth = catalog.find(&key); + assert!(truth.is_some(), "qualify-inbound-lead must be in CRM_CATALOG"); + assert_eq!(truth.unwrap().key, "qualify-inbound-lead"); +} + +#[test] +fn crm_catalog_resolves_score_inbound_fit() { + let catalog = crm_truths::CRM_CATALOG; + let key: TruthKey = "score-inbound-fit".parse().expect("valid key"); + assert!(catalog.find(&key).is_some(), "score-inbound-fit must be in CRM_CATALOG"); +} + +#[test] +fn crm_catalog_resolves_plan_outbound_campaign() { + let catalog = crm_truths::CRM_CATALOG; + let key: TruthKey = "plan-outbound-campaign".parse().expect("valid key"); + assert!(catalog.find(&key).is_some(), "plan-outbound-campaign must be in CRM_CATALOG"); +} + +#[test] +fn crm_catalog_find_returns_none_for_nonexistent_key() { + let catalog = crm_truths::CRM_CATALOG; + let key: TruthKey = "no-such-truth".parse().expect("valid key"); + assert!( + catalog.find(&key).is_none(), + "no-such-truth must not appear in CRM_CATALOG" + ); +} + +#[test] +fn every_crm_truth_key_parses_as_truth_key() { + for truth in crm_truths::CRM_CATALOG.all() { + let result = TruthKey::parse(truth.key); + assert!( + result.is_ok(), + "truth key {:?} must parse as a valid TruthKey: {}", + truth.key, + result.unwrap_err() + ); + } +} + +#[test] +fn crm_catalog_all_returns_nonempty_slice() { + let all = crm_truths::CRM_CATALOG.all(); + assert!(!all.is_empty(), "CRM_CATALOG.all() must not be empty"); +} + +#[test] +fn crm_catalog_all_keys_are_unique() { + use std::collections::BTreeSet; + let all = crm_truths::CRM_CATALOG.all(); + let unique: BTreeSet<&str> = all.iter().map(|t| t.key).collect(); + assert_eq!( + unique.len(), + all.len(), + "CRM_CATALOG must not contain duplicate truth keys" + ); +} + +#[test] +fn crm_catalog_find_returns_matching_key_field() { + // For every truth in the catalog, find() by its own key must return it. + for truth in crm_truths::CRM_CATALOG.all() { + let key = TruthKey::parse(truth.key).expect("all TRUTHS keys are valid"); + let found = crm_truths::CRM_CATALOG.find(&key); + assert!( + found.is_some(), + "catalog.find({:?}) must return Some for a key that exists in all()", + truth.key + ); + assert_eq!( + found.unwrap().key, + truth.key, + "catalog.find returned a truth with wrong key" + ); + } +} diff --git a/crates/truth-catalog/tests/fixtures/activate-subscription.intent.json b/crates/crm-truths/tests/fixtures/activate-subscription.intent.json similarity index 100% rename from crates/truth-catalog/tests/fixtures/activate-subscription.intent.json rename to crates/crm-truths/tests/fixtures/activate-subscription.intent.json diff --git a/crates/truth-catalog/tests/fixtures/refill-prepaid-ai-credits.intent.json b/crates/crm-truths/tests/fixtures/refill-prepaid-ai-credits.intent.json similarity index 100% rename from crates/truth-catalog/tests/fixtures/refill-prepaid-ai-credits.intent.json rename to crates/crm-truths/tests/fixtures/refill-prepaid-ai-credits.intent.json diff --git a/crates/truth-catalog/truths/jobs/activate_subscription.feature b/crates/crm-truths/truths/jobs/activate_subscription.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/activate_subscription.feature rename to crates/crm-truths/truths/jobs/activate_subscription.feature diff --git a/crates/truth-catalog/truths/jobs/create_customer_workspace.feature b/crates/crm-truths/truths/jobs/create_customer_workspace.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/create_customer_workspace.feature rename to crates/crm-truths/truths/jobs/create_customer_workspace.feature diff --git a/crates/truth-catalog/truths/jobs/detect_abnormal_token_burn.feature b/crates/crm-truths/truths/jobs/detect_abnormal_token_burn.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/detect_abnormal_token_burn.feature rename to crates/crm-truths/truths/jobs/detect_abnormal_token_burn.feature diff --git a/crates/truth-catalog/truths/jobs/evaluate_acquisition_target.feature b/crates/crm-truths/truths/jobs/evaluate_acquisition_target.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/evaluate_acquisition_target.feature rename to crates/crm-truths/truths/jobs/evaluate_acquisition_target.feature diff --git a/crates/truth-catalog/truths/jobs/generate_data_transformer.feature b/crates/crm-truths/truths/jobs/generate_data_transformer.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/generate_data_transformer.feature rename to crates/crm-truths/truths/jobs/generate_data_transformer.feature diff --git a/crates/truth-catalog/truths/jobs/match_renewal_context.feature b/crates/crm-truths/truths/jobs/match_renewal_context.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/match_renewal_context.feature rename to crates/crm-truths/truths/jobs/match_renewal_context.feature diff --git a/crates/truth-catalog/truths/jobs/match_visual_to_tagline.feature b/crates/crm-truths/truths/jobs/match_visual_to_tagline.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/match_visual_to_tagline.feature rename to crates/crm-truths/truths/jobs/match_visual_to_tagline.feature diff --git a/crates/truth-catalog/truths/jobs/monitor_brand_signal.feature b/crates/crm-truths/truths/jobs/monitor_brand_signal.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/monitor_brand_signal.feature rename to crates/crm-truths/truths/jobs/monitor_brand_signal.feature diff --git a/crates/truth-catalog/truths/jobs/plan_outbound_campaign.feature b/crates/crm-truths/truths/jobs/plan_outbound_campaign.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/plan_outbound_campaign.feature rename to crates/crm-truths/truths/jobs/plan_outbound_campaign.feature diff --git a/crates/truth-catalog/truths/jobs/qualify_inbound_lead.feature b/crates/crm-truths/truths/jobs/qualify_inbound_lead.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/qualify_inbound_lead.feature rename to crates/crm-truths/truths/jobs/qualify_inbound_lead.feature diff --git a/crates/truth-catalog/truths/jobs/reconcile_model_usage_against_customer_ledger.feature b/crates/crm-truths/truths/jobs/reconcile_model_usage_against_customer_ledger.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/reconcile_model_usage_against_customer_ledger.feature rename to crates/crm-truths/truths/jobs/reconcile_model_usage_against_customer_ledger.feature diff --git a/crates/truth-catalog/truths/jobs/refill_prepaid_ai_credits.feature b/crates/crm-truths/truths/jobs/refill_prepaid_ai_credits.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/refill_prepaid_ai_credits.feature rename to crates/crm-truths/truths/jobs/refill_prepaid_ai_credits.feature diff --git a/crates/truth-catalog/truths/jobs/renew_contract.feature b/crates/crm-truths/truths/jobs/renew_contract.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/renew_contract.feature rename to crates/crm-truths/truths/jobs/renew_contract.feature diff --git a/crates/truth-catalog/truths/jobs/resolve_support_incident.feature b/crates/crm-truths/truths/jobs/resolve_support_incident.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/resolve_support_incident.feature rename to crates/crm-truths/truths/jobs/resolve_support_incident.feature diff --git a/crates/truth-catalog/truths/jobs/schedule_strategic_meetings.feature b/crates/crm-truths/truths/jobs/schedule_strategic_meetings.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/schedule_strategic_meetings.feature rename to crates/crm-truths/truths/jobs/schedule_strategic_meetings.feature diff --git a/crates/truth-catalog/truths/jobs/score_inbound_fit.feature b/crates/crm-truths/truths/jobs/score_inbound_fit.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/score_inbound_fit.feature rename to crates/crm-truths/truths/jobs/score_inbound_fit.feature diff --git a/crates/truth-catalog/truths/jobs/submit_expense_report.feature b/crates/crm-truths/truths/jobs/submit_expense_report.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/submit_expense_report.feature rename to crates/crm-truths/truths/jobs/submit_expense_report.feature diff --git a/crates/truth-catalog/truths/jobs/suspend_service_on_payment_failure.feature b/crates/crm-truths/truths/jobs/suspend_service_on_payment_failure.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/suspend_service_on_payment_failure.feature rename to crates/crm-truths/truths/jobs/suspend_service_on_payment_failure.feature diff --git a/crates/truth-catalog/truths/jobs/upgrade_subscription_plan.feature b/crates/crm-truths/truths/jobs/upgrade_subscription_plan.feature similarity index 100% rename from crates/truth-catalog/truths/jobs/upgrade_subscription_plan.feature rename to crates/crm-truths/truths/jobs/upgrade_subscription_plan.feature diff --git a/crates/truth-catalog/truths/modules/active_subscription_requires_plan.feature b/crates/crm-truths/truths/modules/active_subscription_requires_plan.feature similarity index 100% rename from crates/truth-catalog/truths/modules/active_subscription_requires_plan.feature rename to crates/crm-truths/truths/modules/active_subscription_requires_plan.feature diff --git a/crates/truth-catalog/truths/modules/ledger_entry_is_immutable.feature b/crates/crm-truths/truths/modules/ledger_entry_is_immutable.feature similarity index 100% rename from crates/truth-catalog/truths/modules/ledger_entry_is_immutable.feature rename to crates/crm-truths/truths/modules/ledger_entry_is_immutable.feature diff --git a/crates/truth-catalog/truths/policies/overdue_balance_blocks_entitlement_increase.feature b/crates/crm-truths/truths/policies/overdue_balance_blocks_entitlement_increase.feature similarity index 100% rename from crates/truth-catalog/truths/policies/overdue_balance_blocks_entitlement_increase.feature rename to crates/crm-truths/truths/policies/overdue_balance_blocks_entitlement_increase.feature diff --git a/crates/truth-catalog/truths/policies/promoted_fact_requires_traceable_evidence.feature b/crates/crm-truths/truths/policies/promoted_fact_requires_traceable_evidence.feature similarity index 100% rename from crates/truth-catalog/truths/policies/promoted_fact_requires_traceable_evidence.feature rename to crates/crm-truths/truths/policies/promoted_fact_requires_traceable_evidence.feature diff --git a/crates/truth-catalog/truths/policies/top_up_requires_confirmed_payment.feature b/crates/crm-truths/truths/policies/top_up_requires_confirmed_payment.feature similarity index 100% rename from crates/truth-catalog/truths/policies/top_up_requires_confirmed_payment.feature rename to crates/crm-truths/truths/policies/top_up_requires_confirmed_payment.feature diff --git a/crates/helm-coordination/Cargo.toml b/crates/helm-coordination/Cargo.toml index a742101..c381f6c 100644 --- a/crates/helm-coordination/Cargo.toml +++ b/crates/helm-coordination/Cargo.toml @@ -37,3 +37,4 @@ runway-storage = { path = "../../../../runtime-runway/crates/runway-storage" } tempfile = "3" tonic.workspace = true tower = "0.5" +truth-catalog = { version = "0.2.1", path = "../truth-catalog" } diff --git a/crates/helm-coordination/tests/coordination_test.rs b/crates/helm-coordination/tests/coordination_test.rs index 106faba..b8a1869 100644 --- a/crates/helm-coordination/tests/coordination_test.rs +++ b/crates/helm-coordination/tests/coordination_test.rs @@ -31,9 +31,26 @@ use helm_truth_execution::{ TruthBody, TruthExecutionArtifacts, TruthExecutionModule, dispatcher::TruthExecutionContext, }; use runway_app_host::{EventEnvelope, EventHub}; +use truth_catalog::{TruthCatalog, TruthDefinition, TruthKind}; const TRUTH_KEY: &str = "score-inbound-fit"; +const FIXTURE_GHERKIN_SCORE: &str = "Feature: Score inbound fit\n\n Intent:\n Outcome: score inbound lead fit for mechanism tests\n\n Scenario: Score\n Given a test lead exists\n Then fit is scored"; + +const FIXTURE_TRUTHS: &[TruthDefinition] = &[TruthDefinition { + key: "score-inbound-fit", + display_name: "Score inbound fit", + kind: TruthKind::Job, + summary: "Fixture truth for mechanism tests.", + feature_path: "fixture", + actor_roles: &[], + approval_points: &[], + desired_outcomes: &[], + guardrails: &[], + modules: &[], + gherkin: FIXTURE_GHERKIN_SCORE, +}]; + /// A truth that blocks at a HITL gate on first execution and is met on the /// second (post-approval) execution, so an approval completes the job. struct CompletingGateTruth { @@ -126,6 +143,7 @@ fn live_state() -> Arc { hub: hub.handle(), app_id: "test.governed-jobs".into(), gate_timeout: Duration::from_secs(30), + catalog: TruthCatalog::new(FIXTURE_TRUTHS), ..JobStreamState::default() }) } diff --git a/crates/helm-governed-jobs/src/job_stream.rs b/crates/helm-governed-jobs/src/job_stream.rs index 0e1da64..58fbce7 100644 --- a/crates/helm-governed-jobs/src/job_stream.rs +++ b/crates/helm-governed-jobs/src/job_stream.rs @@ -52,12 +52,14 @@ use converge_core::ContextState; use serde::Deserialize; use serde_json::json; use tokio::sync::oneshot; +use organism_pack::IntentPacket; use truth_catalog::{ + TruthCatalog, TruthDefinition, TruthKey, admission::{ TruthFormationSelection, admit_truth_intent, default_helms_capabilities, select_formation_for_intent, }, - find_truth, + resolve::IntentOverlay, }; use uuid::Uuid; @@ -97,11 +99,23 @@ impl JobGateWaiter { // ── State ──────────────────────────────────────────────────────────── +/// No-op overlay — used by `Default` and tests that do not exercise +/// content-specific overlay fields. The mounted binary injects +/// `crm_truths::CrmIntentOverlay`; this placeholder ensures `Default` +/// compiles without a `crm-truths` dependency. +struct NoOpOverlay; + +impl IntentOverlay for NoOpOverlay { + fn apply(&self, _def: &TruthDefinition, _intent: &mut IntentPacket) {} +} + /// Route state for the governed-jobs stream. /// /// Construct with `JobStreamState::default()` for a zero-arg shell (routes return -/// 501), or with `JobStreamState::new(store, runtime_stores, truths, hub, app_id)` for -/// real wiring. +/// 501 for execution and empty catalog for truth lookup), or with +/// `JobStreamState::new(store, runtime_stores, truths, hub, app_id, catalog, overlay)` +/// for real wiring. The mounting binary injects `crm_truths::CRM_CATALOG` and +/// `Arc::new(crm_truths::CrmIntentOverlay)` for the CRM content. #[derive(Clone)] pub struct JobStreamState { pub store: AppKernelStore, @@ -115,6 +129,14 @@ pub struct JobStreamState { pub gate_timeout: Duration, #[doc(hidden)] pub gate_waiters: Arc>>, + /// Truth definition catalog. Inject `TruthCatalog::new(crm_truths::TRUTHS)` + /// (equivalently `crm_truths::CRM_CATALOG`) at the mounting site. + /// `Default` provides an empty catalog (all lookups return `None`). + pub catalog: TruthCatalog<'static>, + /// Content-side overlay. Inject `Arc::new(crm_truths::CrmIntentOverlay)` at + /// the mounting site. `Default` provides a no-op overlay that leaves every + /// `IntentPacket` field at its compiled value. + pub overlay: Arc, } const DEFAULT_GATE_TIMEOUT: Duration = Duration::from_secs(600); @@ -126,6 +148,8 @@ impl JobStreamState { truths: Arc, hub: EventHubHandle, app_id: impl Into, + catalog: TruthCatalog<'static>, + overlay: Arc, ) -> Self { Self { store, @@ -133,6 +157,8 @@ impl JobStreamState { truths, hub, app_id: app_id.into(), + catalog, + overlay, gate_timeout: DEFAULT_GATE_TIMEOUT, gate_waiters: Arc::new(Mutex::new(HashMap::new())), } @@ -199,7 +225,8 @@ impl JobStreamState { impl Default for JobStreamState { fn default() -> Self { // Freestanding hub — not wired to any host. Routes built with this - // default state return 501 for every truth key (no truths registered). + // default state return 501 for every truth key (no truths registered) + // and NOT_FOUND for every catalog lookup (empty catalog). let hub = EventHub::with_capacity(256); Self { store: AppKernelStore::Memory(InMemoryKernelStore::default_local()), @@ -209,6 +236,8 @@ impl Default for JobStreamState { app_id: "helm.governed-jobs".into(), gate_timeout: DEFAULT_GATE_TIMEOUT, gate_waiters: Arc::new(Mutex::new(HashMap::new())), + catalog: TruthCatalog::new(&[]), + overlay: Arc::new(NoOpOverlay), } } } @@ -326,7 +355,9 @@ async fn stream_job( if truth_key.is_empty() { return Err((StatusCode::BAD_REQUEST, "job key is required".into())); } - if find_truth(&truth_key).is_none() { + let tk = TruthKey::parse(&truth_key) + .map_err(|_| (StatusCode::NOT_FOUND, format!("job not found: {truth_key}")))?; + if state.catalog.find(&tk).is_none() { return Err((StatusCode::NOT_FOUND, format!("job not found: {truth_key}"))); } if !supports_truth_execution(&state.truths, &truth_key) { @@ -435,7 +466,7 @@ pub async fn run_job_task(task: JobRunTask) { let pub_ = state.publisher(&run_id, &truth_key, &app_id, actor_tag); pub_.emit("job.started", json!({})); - if let Err(error) = admit_job(&pub_, &truth_key) { + if let Err(error) = admit_job(&pub_, &truth_key, &state.catalog, state.overlay.as_ref()) { pub_.emit("job.failed", json!({ "error": error })); return; } @@ -602,10 +633,22 @@ pub async fn run_job_task(task: JobRunTask) { pub_.emit("job.completed", json!({ "result": receipt })); } -fn admit_job(pub_: &Publisher, truth_key: &str) -> Result<(), String> { +fn admit_job( + pub_: &Publisher, + truth_key: &str, + catalog: &TruthCatalog<'static>, + overlay: &dyn IntentOverlay, +) -> Result<(), String> { + let tk = TruthKey::parse(truth_key) + .map_err(|e| format!("invalid truth key {truth_key}: {e}"))?; + let def = catalog + .find(&tk) + .copied() + .ok_or_else(|| format!("truth not found in catalog: {truth_key}"))?; let mut context = ContextState::new(); let intent = admit_truth_intent( - truth_key, + def, + overlay, &pub_.app_id, &format!("truth:{truth_key}"), &mut context, diff --git a/crates/helm-governed-jobs/tests/characterization.rs b/crates/helm-governed-jobs/tests/characterization.rs index e6a6660..a3d1190 100644 --- a/crates/helm-governed-jobs/tests/characterization.rs +++ b/crates/helm-governed-jobs/tests/characterization.rs @@ -18,9 +18,27 @@ use helm_truth_execution::{ TruthBody, TruthExecutionArtifacts, TruthExecutionModule, dispatcher::TruthExecutionContext, }; use runway_app_host::EventEnvelope; +use truth_catalog::{TruthCatalog, TruthDefinition, TruthKind}; const TRUTH_KEY: &str = "score-inbound-fit"; +/// Minimal fixture gherkin sufficient for axiom to compile an `IntentPacket`. +const FIXTURE_GHERKIN_SCORE: &str = "Feature: Score inbound fit\n\n Intent:\n Outcome: score inbound lead fit for mechanism tests\n\n Scenario: Score\n Given a test lead exists\n Then fit is scored"; + +const FIXTURE_TRUTHS: &[TruthDefinition] = &[TruthDefinition { + key: "score-inbound-fit", + display_name: "Score inbound fit", + kind: TruthKind::Job, + summary: "Fixture truth for mechanism tests.", + feature_path: "fixture", + actor_roles: &[], + approval_points: &[], + desired_outcomes: &[], + guardrails: &[], + modules: &[], + gherkin: FIXTURE_GHERKIN_SCORE, +}]; + struct ImmediateTruth; #[async_trait] @@ -66,6 +84,7 @@ fn live_state() -> Arc { hub: hub.handle(), app_id: "test.governed-jobs".into(), gate_timeout: Duration::from_secs(30), + catalog: TruthCatalog::new(FIXTURE_TRUTHS), ..JobStreamState::default() }) } diff --git a/crates/helm-governed-jobs/tests/gate_test.rs b/crates/helm-governed-jobs/tests/gate_test.rs index 268a292..c01b4e6 100644 --- a/crates/helm-governed-jobs/tests/gate_test.rs +++ b/crates/helm-governed-jobs/tests/gate_test.rs @@ -32,6 +32,7 @@ use helm_truth_execution::{ TruthBody, TruthExecutionArtifacts, TruthExecutionModule, dispatcher::TruthExecutionContext, }; use runway_app_host::{EventEnvelope, EventHub}; +use truth_catalog::{TruthCatalog, TruthDefinition, TruthKind}; // ── Stub truth body ──────────────────────────────────────────────────────────── @@ -43,9 +44,27 @@ use runway_app_host::{EventEnvelope, EventHub}; /// so we always return `Blocked` here to keep the stub simple. const GATE_REF: &str = "gate-ref"; -/// The truth key used by the test. Must exist in truth-catalog's TRUTHS slice. +/// The truth key used by the test. Supplied via the fixture catalog injected +/// into `JobStreamState` — no longer requires the old global TRUTHS slice. const TRUTH_KEY: &str = "score-inbound-fit"; +/// Minimal gherkin sufficient for axiom to compile an `IntentPacket`. +const FIXTURE_GHERKIN_SCORE: &str = "Feature: Score inbound fit\n\n Intent:\n Outcome: score inbound lead fit for mechanism tests\n\n Scenario: Score\n Given a test lead exists\n Then fit is scored"; + +const FIXTURE_TRUTHS: &[TruthDefinition] = &[TruthDefinition { + key: "score-inbound-fit", + display_name: "Score inbound fit", + kind: TruthKind::Job, + summary: "Fixture truth for mechanism tests.", + feature_path: "fixture", + actor_roles: &[], + approval_points: &[], + desired_outcomes: &[], + guardrails: &[], + modules: &[], + gherkin: FIXTURE_GHERKIN_SCORE, +}]; + struct GateRequiringTruth; #[async_trait] @@ -103,6 +122,7 @@ fn state_with_timeout(timeout: Duration) -> Arc { hub: hub.handle(), app_id: "test.governed-jobs".into(), gate_timeout: timeout, + catalog: TruthCatalog::new(FIXTURE_TRUTHS), ..JobStreamState::default() }) } diff --git a/crates/truth-catalog/Cargo.toml b/crates/truth-catalog/Cargo.toml index 8bdac8f..62eb955 100644 --- a/crates/truth-catalog/Cargo.toml +++ b/crates/truth-catalog/Cargo.toml @@ -22,10 +22,12 @@ converge-model.workspace = true organism-pack.workspace = true organism-runtime.workspace = true thiserror.workspace = true -capability-core = { version = "0.2.1", path = "../capability-core" } -capability-registry = { version = "0.2.1", path = "../capability-registry" } serde.workspace = true serde_json.workspace = true [dev-dependencies] uuid.workspace = true +proptest = { workspace = true } +trybuild = { version = "1", features = ["diff"] } +organism-pack = { workspace = true } +chrono = { workspace = true } diff --git a/crates/truth-catalog/src/admission.rs b/crates/truth-catalog/src/admission.rs index 10b775b..d1e3136 100644 --- a/crates/truth-catalog/src/admission.rs +++ b/crates/truth-catalog/src/admission.rs @@ -1,6 +1,6 @@ //! Truth IntentPacket → Converge typed admission boundary + formation selection. //! -//! Wraps `axiom_truth::compile_intent` (via [`compile_intent_for_truth`]) + +//! Wraps `axiom_truth::compile_intent` (via [`compile_intent_with_overlay`]) + //! `organism_runtime::Runtime::admit_intent` so each truth executor stages //! its IntentPacket once per run with one call. This is handoff step 4: //! intents enter the Converge kernel through the typed admission gate @@ -22,8 +22,9 @@ use organism_runtime::guru::SelectionTrace; use organism_runtime::templates::standard_formation_catalog; use organism_runtime::{GuruError, IntentAdmissionError, Runtime}; -use crate::find_truth; -use crate::intent_compile::{CompileTruthError, compile_intent_for_truth}; +use crate::intent_compile::{CompileTruthError, compile_intent_with_overlay}; +use crate::resolve::IntentOverlay; +use crate::TruthDefinition; /// Errors produced when staging a Truth's IntentPacket. #[derive(Debug, thiserror::Error)] @@ -38,24 +39,23 @@ pub enum AdmitTruthError { Admission(#[from] IntentAdmissionError), } -/// Compile the Truth's IntentPacket through axiom and stage it through -/// Converge's typed admission boundary. Returns the compiled packet so the -/// caller can use it directly (e.g. as input to a future -/// `Runtime::select_formation`, handoff step 5). +/// Compile the truth's [`IntentPacket`] through axiom, apply `overlay`, then +/// stage through Converge's typed admission boundary. /// -/// `actor_id` identifies the principal staging the intent (e.g. an operator -/// id or `"helms"` for system-staged runs). `source_label` is recorded as -/// the admission source — pick something stable like `"truth:"` or -/// `"helms-pipeline"`. +/// Returns the compiled packet for downstream use (e.g. `Runtime::select_formation`). +/// +/// # Errors +/// +/// Returns [`AdmitTruthError`] on compile failure, invalid actor/source +/// identity, or admission rejection. pub fn admit_truth_intent( - truth_key: &str, + def: TruthDefinition, + overlay: &dyn IntentOverlay, actor_id: &str, source_label: &str, context: &mut ContextState, ) -> Result { - let truth = find_truth(truth_key) - .ok_or_else(|| AdmitTruthError::UnknownTruth(truth_key.to_string()))?; - let intent = compile_intent_for_truth(&truth)?; + let intent = compile_intent_with_overlay(&def, overlay)?; let actor = AdmissionActor::new(actor_id, AdmissionActorKind::System)?; let source = AdmissionSource::new(source_label)?; Runtime::new().admit_intent(&intent, actor, source, context)?; @@ -93,11 +93,6 @@ pub fn default_helms_capabilities() -> Vec { /// catalog given the host's `capabilities`. Returns the primary template /// id, up to two alternates, and the SelectionTrace for audit/UI surfaces. /// -/// This is handoff §5's "smart selection" call — currently observability -/// only; the executor still drives the Engine directly. Step §6 wires the -/// chosen template through `compile_and_run_formation` (or the tournament -/// variant) instead. -/// /// # Errors /// /// Returns [`GuruError::NoMatch`] when no template in the standard catalog diff --git a/crates/truth-catalog/src/catalog.rs b/crates/truth-catalog/src/catalog.rs new file mode 100644 index 0000000..9c4f6eb --- /dev/null +++ b/crates/truth-catalog/src/catalog.rs @@ -0,0 +1,188 @@ +//! `TruthCatalog<'a>` — a borrowing view over a slice of [`TruthDefinition`]s. +//! +//! The catalog wraps any `&[TruthDefinition]` and provides typed query methods. +//! Content crates (e.g. `crm-truths`, which owns the CRM `TRUTHS` const) and +//! test harnesses construct their own catalog over any slice — including +//! synthetic fixtures — by calling [`TruthCatalog::new`]. + +use crate::{TruthDefinition, TruthKey, TruthKind}; + +/// A borrowing view over a slice of [`TruthDefinition`]s. +/// +/// Construct with [`TruthCatalog::new`] and inject wherever a truth lookup is +/// needed. The standard CRM catalog is available as +/// `TruthCatalog::new(crm_truths::TRUTHS)`. +#[derive(Debug, Clone, Copy)] +pub struct TruthCatalog<'a>(&'a [TruthDefinition]); + +impl<'a> TruthCatalog<'a> { + /// Wrap a truth-definition slice. + /// + /// Pass `crm_truths::TRUTHS` for the CRM catalog, or a synthetic slice + /// in tests. + pub const fn new(truths: &'a [TruthDefinition]) -> Self { + Self(truths) + } + + /// Find a truth by its typed key. + /// + /// Returns `None` when no definition in the catalog has a `key` field that + /// matches `key.as_str()`. + #[must_use] + pub fn find(&self, key: &TruthKey) -> Option<&'a TruthDefinition> { + self.0.iter().find(|t| t.key == key.as_str()) + } + + /// Return every truth definition in the catalog. + #[must_use] + pub fn all(&self) -> &'a [TruthDefinition] { + self.0 + } + + /// Return truths whose `kind` matches the given [`TruthKind`]. + #[must_use] + pub fn by_kind(&self, kind: TruthKind) -> Vec<&'a TruthDefinition> { + self.0.iter().filter(|t| t.kind == kind).collect() + } + + /// Return truths that touch the given module (matched by `module_key`). + #[must_use] + pub fn for_module(&self, module_key: &str) -> Vec<&'a TruthDefinition> { + self.0 + .iter() + .filter(|t| t.modules.iter().any(|touch| touch.module_key == module_key)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use crate::{TruthDefinition, TruthKey, TruthKind, TruthModuleTouch}; + + use super::TruthCatalog; + + // Minimal synthetic fixture — intentionally NOT using the global TRUTHS + // const so that tests remain independent of CRM content changes. + const FIXTURE_TRUTHS: &[TruthDefinition] = &[ + TruthDefinition { + key: "approve-access-request", + display_name: "Approve access request", + kind: TruthKind::Job, + summary: "Review and approve or deny an access request.", + feature_path: "truths/jobs/approve_access_request.feature", + actor_roles: &["security-operator"], + approval_points: &["manual approval when risk is elevated"], + desired_outcomes: &["access decision is recorded"], + guardrails: &["decision must cite a policy"], + modules: &[ + TruthModuleTouch { + module_key: "identity", + responsibility: "verify requestor identity", + }, + TruthModuleTouch { + module_key: "policies", + responsibility: "apply access policy", + }, + ], + gherkin: "", + }, + TruthDefinition { + key: "revoke-access", + display_name: "Revoke access", + kind: TruthKind::Job, + summary: "Revoke an existing access grant.", + feature_path: "truths/jobs/revoke_access.feature", + actor_roles: &["security-operator"], + approval_points: &[], + desired_outcomes: &["access grant is terminated"], + guardrails: &["revocation must be logged"], + modules: &[ + TruthModuleTouch { + module_key: "identity", + responsibility: "resolve the access subject", + }, + ], + gherkin: "", + }, + TruthDefinition { + key: "identity-record-is-immutable", + display_name: "Identity record is immutable", + kind: TruthKind::Policy, + summary: "Posted identity records must not be mutated.", + feature_path: "truths/policies/identity_record_is_immutable.feature", + actor_roles: &["security-operator"], + approval_points: &[], + desired_outcomes: &["identity records remain unchanged"], + guardrails: &["mutation of identity records is blocked"], + modules: &[ + TruthModuleTouch { + module_key: "identity", + responsibility: "own immutable identity history", + }, + TruthModuleTouch { + module_key: "audit", + responsibility: "preserve the change trail", + }, + ], + gherkin: "", + }, + ]; + + fn catalog() -> TruthCatalog<'static> { + TruthCatalog::new(FIXTURE_TRUTHS) + } + + #[test] + fn all_returns_all_definitions() { + assert_eq!(catalog().all().len(), 3); + } + + #[test] + fn find_returns_matching_definition() { + let key: TruthKey = "revoke-access".parse().unwrap(); + let truth = catalog().find(&key).expect("revoke-access should exist"); + assert_eq!(truth.key, "revoke-access"); + assert_eq!(truth.kind, TruthKind::Job); + } + + #[test] + fn find_returns_none_for_unknown_key() { + let key: TruthKey = "no-such-truth".parse().unwrap(); + assert!(catalog().find(&key).is_none()); + } + + #[test] + fn by_kind_filters_correctly() { + let jobs = catalog().by_kind(TruthKind::Job); + assert_eq!(jobs.len(), 2); + assert!(jobs.iter().all(|t| t.kind == TruthKind::Job)); + + let policies = catalog().by_kind(TruthKind::Policy); + assert_eq!(policies.len(), 1); + assert_eq!(policies[0].key, "identity-record-is-immutable"); + + let module_local = catalog().by_kind(TruthKind::ModuleLocal); + assert!(module_local.is_empty()); + } + + #[test] + fn for_module_returns_touching_truths() { + let identity_truths = catalog().for_module("identity"); + // All three fixture truths touch the "identity" module. + assert_eq!(identity_truths.len(), 3); + + let audit_truths = catalog().for_module("audit"); + assert_eq!(audit_truths.len(), 1); + assert_eq!(audit_truths[0].key, "identity-record-is-immutable"); + + let unknown_truths = catalog().for_module("nonexistent-module"); + assert!(unknown_truths.is_empty()); + } + + #[test] + fn new_is_const_usable() { + // Verify that const fn new can be used in a const context. + const CAT: TruthCatalog<'_> = TruthCatalog::new(FIXTURE_TRUTHS); + assert_eq!(CAT.all().len(), 3); + } +} diff --git a/crates/truth-catalog/src/converge.rs b/crates/truth-catalog/src/converge.rs index 56e1e4e..aae81ad 100644 --- a/crates/truth-catalog/src/converge.rs +++ b/crates/truth-catalog/src/converge.rs @@ -1,39 +1,12 @@ -use capability_core::ModuleSuite; -use capability_registry::find_module; -use converge_kernel::{Context, ContextKey, CriterionEvaluator, CriterionResult}; use converge_model::{ - Criterion, FactId, RiskPosture, TruthCatalog as ConvergeTruthCatalog, - TruthDefinition as ConvergeTruth, TruthKind as ConvergeTruthKind, TypesBudgets, + RiskPosture, TruthDefinition as ConvergeTruth, TruthKind as ConvergeTruthKind, TypesBudgets, TypesConstraintSeverity, TypesIntentConstraint, TypesIntentId, TypesIntentKind, TypesObjective, TypesRootIntent, }; use serde::Serialize; -use crate::{TruthDefinition, TruthKind, all_truths, find_truth}; - -const FOUNDATION_PACK_ID: &str = "prio-foundation-pack"; -const RELATIONSHIP_PACK_ID: &str = "prio-relationship-pack"; -const COMMERCIAL_PACK_ID: &str = "prio-commercial-pack"; -const REVENUE_PACK_ID: &str = "prio-revenue-pack"; -const WORK_PACK_ID: &str = "prio-work-pack"; -const TRUST_PACK_ID: &str = "trust"; -const KNOWLEDGE_PACK_ID: &str = "knowledge"; - -pub struct StaticTruthCatalog; - -pub struct EvaluateAcquisitionTargetEvaluator; -pub struct QualifyInboundLeadEvaluator; -pub struct ActivateSubscriptionEvaluator; -pub struct RefillPrepaidAiCreditsEvaluator; -pub struct UpgradeSubscriptionPlanEvaluator; -pub struct SuspendServiceOnPaymentFailureEvaluator; -pub struct ReconcileModelUsageAgainstCustomerLedgerEvaluator; -pub struct ScoreInboundFitEvaluator; -pub struct PlanOutboundCampaignEvaluator; -pub struct MatchRenewalContextEvaluator; -pub struct ScheduleStrategicMeetingsEvaluator; -pub struct MonitorBrandSignalEvaluator; -pub struct MatchVisualToTaglineEvaluator; +use crate::resolve::{PackResolver, UnknownModule}; +use crate::{TruthDefinition, TruthKind}; #[derive(Debug, Clone, Serialize)] pub struct TruthConvergeBinding { @@ -71,58 +44,60 @@ impl TruthConvergeBinding { } } -impl From for TruthConvergeBinding { - fn from(truth: TruthDefinition) -> Self { - let pack_ids = pack_ids_for_truth(truth); - Self { - truth_key: truth.key, +impl TruthConvergeBinding { + /// Build a [`TruthConvergeBinding`] from a definition and an injected + /// [`PackResolver`]. + /// + /// # Errors + /// + /// Returns [`UnknownModule`] when any module touched by `def` is not + /// resolvable by `packs`. The `truth_key` field of the error is populated + /// here from `def.key`. + pub fn build(def: TruthDefinition, packs: &dyn PackResolver) -> Result { + let pack_ids = packs.pack_ids_for(def.modules).map_err(|mut e| { + e.truth_key = def.key.to_owned(); + e + })?; + Ok(Self { + truth_key: def.key, runtime: "converge", pack_ids: pack_ids.clone(), - approval_points: truth.approval_points.to_vec(), + approval_points: def.approval_points.to_vec(), intent: TypesRootIntent::builder() - .id(TypesIntentId::new(format!("truth:{}", truth.key))) + .id(TypesIntentId::new(format!("truth:{}", def.key))) .kind(TypesIntentKind::Custom) - .request(truth_request(truth)) - .objective(Some(TypesObjective::Custom(truth.display_name.to_string()))) - .risk_posture(truth_risk_posture(truth)) - .constraints(truth_constraints(truth)) + .request(truth_request(def)) + .objective(Some(TypesObjective::Custom(def.display_name.to_string()))) + .risk_posture(truth_risk_posture(def)) + .constraints(truth_constraints(def)) .active_packs(pack_ids.iter().map(|p| (*p).into()).collect()) - .success_criteria(truth_success_criteria(truth)) - .budgets(truth_budgets(truth)) + .success_criteria(truth_success_criteria(def)) + .budgets(truth_budgets(def)) .build(), - } + }) } } -#[must_use] -pub fn converge_binding_for_truth(truth_key: &str) -> Option { - find_truth(truth_key).map(TruthConvergeBinding::from) -} - -#[must_use] -pub fn converge_truth_definition(truth_key: &str) -> Option { - find_truth(truth_key).map(ConvergeTruth::from) -} - -impl ConvergeTruthCatalog for StaticTruthCatalog { - fn list_truths(&self) -> Vec { - all_truths().into_iter().map(ConvergeTruth::from).collect() - } -} - -impl From for ConvergeTruth { - fn from(truth: TruthDefinition) -> Self { - let binding = TruthConvergeBinding::from(truth); - Self { - key: truth.key.into(), - kind: truth.kind.into(), - summary: truth.summary.to_string(), - success_criteria: binding.intent.success_criteria, - constraints: binding.intent.constraints, - approval_points: truth.approval_points.iter().map(|p| (*p).into()).collect(), - participating_packs: binding.pack_ids.into_iter().map(Into::into).collect(), - } - } +/// Build a [`ConvergeTruth`] from a definition and an injected [`PackResolver`]. +/// +/// # Errors +/// +/// Propagates [`UnknownModule`] from [`TruthConvergeBinding::build`]. +pub fn to_converge_truth( + def: TruthDefinition, + packs: &dyn PackResolver, +) -> Result { + // TruthDefinition is Copy so def remains available after build(). + let binding = TruthConvergeBinding::build(def, packs)?; + Ok(ConvergeTruth { + key: def.key.into(), + kind: def.kind.into(), + summary: def.summary.to_string(), + success_criteria: binding.intent.success_criteria, + constraints: binding.intent.constraints, + approval_points: def.approval_points.iter().map(|p| (*p).into()).collect(), + participating_packs: binding.pack_ids.into_iter().map(Into::into).collect(), + }) } impl From for ConvergeTruthKind { @@ -135,436 +110,6 @@ impl From for ConvergeTruthKind { } } -impl CriterionEvaluator for QualifyInboundLeadEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - match criterion.id.as_str() { - "outcome.lead-is-explicitly-qualified-or-disqualified" => { - if let Some(fact_id) = - find_fact_id(context, ContextKey::Evaluations, "lead:qualification") - { - CriterionResult::Met { - evidence: vec![FactId::new(fact_id)], - } - } else { - CriterionResult::Unmet { - reason: "lead qualification fact is missing".to_string(), - } - } - } - "outcome.next-owner-and-next-step-are-recorded" => { - let owner = find_fact_id(context, ContextKey::Strategies, "lead:owner"); - let next_step = find_fact_id(context, ContextKey::Strategies, "lead:next-step"); - match (owner, next_step) { - (Some(owner), Some(next_step)) => CriterionResult::Met { - evidence: vec![FactId::new(owner), FactId::new(next_step)], - }, - (None, Some(_)) => CriterionResult::Unmet { - reason: "lead owner fact is missing".to_string(), - }, - (Some(_), None) => CriterionResult::Unmet { - reason: "lead next-step fact is missing".to_string(), - }, - (None, None) => CriterionResult::Unmet { - reason: "lead owner and next-step facts are missing".to_string(), - }, - } - } - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for ScoreInboundFitEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - match criterion.id.as_str() { - "outcome.a-governed-fit-score-is-recorded-for-the-inbound-lead" => { - require_fact(context, ContextKey::Evaluations, "lead:fit-score") - } - "outcome.the-score-cites-attributable-behavioral-evidence" => { - require_fact(context, ContextKey::Signals, "lead:fit-evidence") - } - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for ActivateSubscriptionEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - if let Some(review_fact) = find_fact_id( - context, - ContextKey::Evaluations, - "subscription:manual-review-required", - ) { - return CriterionResult::Blocked { - reason: format!("manual review is required before activation ({review_fact})"), - approval_ref: Some(review_fact.into()), - }; - } - - match criterion.id.as_str() { - "outcome.subscription-becomes-active-with-an-explicit-plan" => require_fact( - context, - ContextKey::Strategies, - "subscription:activation-ready", - ), - "outcome.entitlements-and-financial-opening-state-are-aligned" => { - let entitlements = find_fact_id( - context, - ContextKey::Signals, - "subscription:entitlement-preview", - ); - let balance = find_fact_id( - context, - ContextKey::Evaluations, - "subscription:opening-balance", - ); - match (entitlements, balance) { - (Some(entitlements), Some(balance)) => CriterionResult::Met { - evidence: vec![FactId::new(entitlements), FactId::new(balance)], - }, - (None, Some(_)) => CriterionResult::Unmet { - reason: "subscription entitlement preview fact is missing".to_string(), - }, - (Some(_), None) => CriterionResult::Unmet { - reason: "subscription opening-balance fact is missing".to_string(), - }, - (None, None) => CriterionResult::Unmet { - reason: - "subscription entitlement preview and opening-balance facts are missing" - .to_string(), - }, - } - } - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for RefillPrepaidAiCreditsEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - if let Some(review_fact) = find_fact_id( - context, - ContextKey::Evaluations, - "credit-top-up:manual-review-required", - ) { - return CriterionResult::Blocked { - reason: format!("manual review is required before refill ({review_fact})"), - approval_ref: Some(review_fact.into()), - }; - } - - match criterion.id.as_str() { - "outcome.confirmed-top-up-appears-in-the-ledger" => { - let payment = find_fact_id(context, ContextKey::Evaluations, "payment:confirmed"); - let grant = - find_fact_id(context, ContextKey::Strategies, "credit-top-up:grant-ready"); - match (payment, grant) { - (Some(payment), Some(grant)) => CriterionResult::Met { - evidence: vec![FactId::new(payment), FactId::new(grant)], - }, - (None, Some(_)) => CriterionResult::Unmet { - reason: "payment confirmation fact is missing".to_string(), - }, - (Some(_), None) => CriterionResult::Unmet { - reason: "credit grant plan fact is missing".to_string(), - }, - (None, None) => CriterionResult::Unmet { - reason: "payment confirmation and credit grant plan facts are missing" - .to_string(), - }, - } - } - "outcome.entitlement-balance-increases-for-the-correct-account" => require_fact( - context, - ContextKey::Signals, - "credit-top-up:entitlement-adjustment", - ), - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for UpgradeSubscriptionPlanEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - if let Some(review_fact) = find_fact_id( - context, - ContextKey::Evaluations, - "subscription:plan-change-manual-review-required", - ) { - return CriterionResult::Blocked { - reason: format!("manual review is required before plan change ({review_fact})"), - approval_ref: Some(review_fact.into()), - }; - } - - match criterion.id.as_str() { - "outcome.subscription-moves-to-the-target-plan-on-an-explicit-date" => require_fact( - context, - ContextKey::Strategies, - "subscription:plan-change-ready", - ), - "outcome.entitlements-and-commercial-delta-stay-aligned" => { - let entitlements = find_fact_id( - context, - ContextKey::Signals, - "subscription:plan-change-entitlements", - ); - let delta = find_fact_id( - context, - ContextKey::Evaluations, - "subscription:plan-change-delta", - ); - match (entitlements, delta) { - (Some(entitlements), Some(delta)) => CriterionResult::Met { - evidence: vec![FactId::new(entitlements), FactId::new(delta)], - }, - (None, Some(_)) => CriterionResult::Unmet { - reason: "subscription plan-change entitlement preview fact is missing" - .to_string(), - }, - (Some(_), None) => CriterionResult::Unmet { - reason: "subscription commercial delta fact is missing".to_string(), - }, - (None, None) => CriterionResult::Unmet { - reason: "subscription plan-change entitlement preview and commercial delta facts are missing" - .to_string(), - }, - } - } - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for SuspendServiceOnPaymentFailureEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - if let Some(review_fact) = find_fact_id( - context, - ContextKey::Evaluations, - "subscription:suspension-manual-review-required", - ) { - return CriterionResult::Blocked { - reason: format!("manual review is required before suspension ({review_fact})"), - approval_ref: Some(review_fact.into()), - }; - } - - match criterion.id.as_str() { - "outcome.service-state-matches-payment-policy" => { - let suspended = find_fact_id( - context, - ContextKey::Strategies, - "subscription:suspension-ready", - ); - let deferred = find_fact_id( - context, - ContextKey::Strategies, - "subscription:suspension-deferred", - ); - let impact = find_fact_id( - context, - ContextKey::Signals, - "subscription:entitlement-impact", - ); - match (suspended.or(deferred), impact) { - (Some(state), Some(impact)) => CriterionResult::Met { - evidence: vec![FactId::new(state), FactId::new(impact)], - }, - (None, Some(_)) => CriterionResult::Unmet { - reason: "subscription suspension policy decision fact is missing" - .to_string(), - }, - (Some(_), None) => CriterionResult::Unmet { - reason: "subscription entitlement impact fact is missing".to_string(), - }, - (None, None) => CriterionResult::Unmet { - reason: "subscription suspension policy decision and entitlement impact facts are missing" - .to_string(), - }, - } - } - "outcome.customer-receives-a-clear-recovery-path" => require_fact( - context, - ContextKey::Strategies, - "subscription:recovery-path", - ), - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for ReconcileModelUsageAgainstCustomerLedgerEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - if let Some(review_fact) = find_fact_id( - context, - ContextKey::Evaluations, - "reconciliation:manual-review-required", - ) { - return CriterionResult::Blocked { - reason: format!( - "manual review is required before reconciliation can be accepted ({review_fact})" - ), - approval_ref: Some(review_fact.into()), - }; - } - - match criterion.id.as_str() { - "outcome.usage-and-financial-state-reconcile-cleanly" => { - require_fact(context, ContextKey::Evaluations, "reconciliation:clean") - } - "outcome.exceptions-are-recorded-and-routed" => { - if let Some(clean_fact) = - find_fact_id(context, ContextKey::Evaluations, "reconciliation:clean") - { - return CriterionResult::Met { - evidence: vec![FactId::new(clean_fact)], - }; - } - - let exception_fact = - find_fact_id(context, ContextKey::Evaluations, "reconciliation:exception"); - let route_fact = - find_fact_id(context, ContextKey::Strategies, "reconciliation:route"); - match (exception_fact, route_fact) { - (Some(exception_fact), Some(route_fact)) => CriterionResult::Met { - evidence: vec![FactId::new(exception_fact), FactId::new(route_fact)], - }, - (None, Some(_)) => CriterionResult::Unmet { - reason: "reconciliation exception fact is missing".to_string(), - }, - (Some(_), None) => CriterionResult::Unmet { - reason: "reconciliation route fact is missing".to_string(), - }, - (None, None) => CriterionResult::Unmet { - reason: - "reconciliation outcome facts are missing from the converge context" - .to_string(), - }, - } - } - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for PlanOutboundCampaignEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - match criterion.id.as_str() { - "outcome.a-governed-outbound-campaign-plan-exists" => { - require_fact(context, ContextKey::Strategies, "campaign:plan") - } - "outcome.campaign-budget-status-is-explicit-and-queryable" => { - require_fact(context, ContextKey::Evaluations, "campaign:budget-status") - } - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for MatchRenewalContextEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - match criterion.id.as_str() { - "outcome.a-renewal-brief-is-attached-to-the-account-or-renewal-motion" => { - require_fact(context, ContextKey::Strategies, "renewal:brief") - } - "outcome.retrieved-renewal-signals-stay-traceable-to-their-source-artifacts" => { - require_any_fact(context, ContextKey::Signals, "renewal:signal:") - } - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for ScheduleStrategicMeetingsEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - if let Some(review_fact) = find_fact_id( - context, - ContextKey::Evaluations, - "meeting:human-confirmation-required", - ) { - return CriterionResult::Blocked { - reason: format!( - "human confirmation required before booking meetings ({review_fact})" - ), - approval_ref: Some(review_fact.into()), - }; - } - - match criterion.id.as_str() { - "outcome.a-ranked-meeting-slate-is-proposed-with-reasoning" => { - require_fact(context, ContextKey::Strategies, "meeting:slate") - } - "outcome.each-proposed-meeting-cites-strategy-alignment-evidence" => { - require_any_fact(context, ContextKey::Signals, "meeting:alignment:") - } - _ => CriterionResult::Indeterminate, - } - } -} - -impl CriterionEvaluator for MonitorBrandSignalEvaluator { - fn evaluate(&self, _criterion: &Criterion, _context: &dyn Context) -> CriterionResult { - CriterionResult::Blocked { - reason: "monitor-brand-signal runtime is not yet implemented".to_string(), - approval_ref: None, - } - } -} - -impl CriterionEvaluator for MatchVisualToTaglineEvaluator { - fn evaluate(&self, _criterion: &Criterion, _context: &dyn Context) -> CriterionResult { - CriterionResult::Blocked { - reason: "match-visual-to-tagline runtime is not yet implemented".to_string(), - approval_ref: None, - } - } -} - -impl CriterionEvaluator for EvaluateAcquisitionTargetEvaluator { - fn evaluate(&self, criterion: &Criterion, context: &dyn Context) -> CriterionResult { - // Governance gate: block if contradictions need human review - if let Some(contradiction_fact) = - find_fact_id(context, ContextKey::Evaluations, "dd:human-review-required") - { - return CriterionResult::Blocked { - reason: format!( - "material contradictions require human review before recommendation ({contradiction_fact})" - ), - approval_ref: Some(contradiction_fact.into()), - }; - } - - match criterion.id.as_str() { - "outcome.a-recommendation-is-produced-with-confidence-at-least-0-7" => { - require_fact(context, ContextKey::Proposals, "dd:synthesis") - } - "outcome.all-material-contradictions-are-surfaced-and-documented" => { - // Met if either: no contradictions exist, or contradictions are documented - let has_contradictions = context - .get(ContextKey::Evaluations) - .iter() - .any(|f| f.id().starts_with("contradiction-")); - if has_contradictions { - let evidence = context - .get(ContextKey::Evaluations) - .iter() - .filter(|f| f.id().starts_with("contradiction-")) - .map(|f| f.id().clone()) - .collect::>(); - CriterionResult::Met { evidence } - } else { - // No contradictions found — criterion is met (clean research) - CriterionResult::Met { evidence: vec![] } - } - } - "outcome.each-dd-dimension-cites-at-least-one-independent-source" => { - require_any_fact(context, ContextKey::Hypotheses, "hypothesis-") - } - _ => CriterionResult::Indeterminate, - } - } -} - fn truth_request(truth: TruthDefinition) -> String { format!("{}: {}", truth.display_name, truth.summary) } @@ -586,11 +131,11 @@ fn truth_constraints(truth: TruthDefinition) -> Vec { constraints } -fn truth_success_criteria(truth: TruthDefinition) -> Vec { +fn truth_success_criteria(truth: TruthDefinition) -> Vec { truth .desired_outcomes .iter() - .map(|outcome| Criterion::required(format!("outcome.{}", slug(outcome)), *outcome)) + .map(|outcome| converge_model::Criterion::required(format!("outcome.{}", slug(outcome)), *outcome)) .collect() } @@ -598,37 +143,6 @@ fn truth_budgets(_truth: TruthDefinition) -> TypesBudgets { TypesBudgets::default() } -fn pack_ids_for_truth(truth: TruthDefinition) -> Vec<&'static str> { - let mut pack_ids = Vec::new(); - - for touch in truth.modules { - let module = find_module(touch.module_key).unwrap_or_else(|| { - panic!( - "truth '{}' references unknown module '{}'", - truth.key, touch.module_key - ) - }); - let pack_id = suite_pack_id(module.suite); - if !pack_ids.contains(&pack_id) { - pack_ids.push(pack_id); - } - } - - pack_ids -} - -fn suite_pack_id(suite: ModuleSuite) -> &'static str { - match suite { - ModuleSuite::Foundation => FOUNDATION_PACK_ID, - ModuleSuite::RelationshipCore => RELATIONSHIP_PACK_ID, - ModuleSuite::CommercialCore => COMMERCIAL_PACK_ID, - ModuleSuite::UsageRevenueCore => REVENUE_PACK_ID, - ModuleSuite::WorkCore => WORK_PACK_ID, - ModuleSuite::TrustCore => TRUST_PACK_ID, - ModuleSuite::IntelligenceCore => KNOWLEDGE_PACK_ID, - } -} - fn intent_kind_name(kind: &TypesIntentKind) -> &'static str { match kind { TypesIntentKind::GrowthStrategy => "growth-strategy", @@ -641,7 +155,7 @@ fn intent_kind_name(kind: &TypesIntentKind) -> &'static str { } } -fn slug(value: &str) -> String { +pub(crate) fn slug(value: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; @@ -666,38 +180,196 @@ fn slug(value: &str) -> String { } } -fn find_fact_id(context: &dyn Context, key: ContextKey, fact_id: &str) -> Option { - context - .get(key) - .iter() - .find(|fact| fact.id().as_str() == fact_id) - .map(|fact| fact.id().to_string()) -} +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use crate::resolve::{PackResolver, UnknownModule}; + use crate::{TruthDefinition, TruthKind, TruthModuleTouch}; -fn require_fact(context: &dyn Context, key: ContextKey, fact_id: &str) -> CriterionResult { - if let Some(fact_id) = find_fact_id(context, key, fact_id) { - CriterionResult::Met { - evidence: vec![FactId::new(fact_id)], + use super::{TruthConvergeBinding, slug}; + + proptest! { + #[test] + fn slug_is_deterministic(s in ".*") { + let a = slug(&s); + let b = slug(&s); + prop_assert_eq!(a, b); } - } else { - CriterionResult::Unmet { - reason: format!("{fact_id} fact is missing"), + + #[test] + fn slug_is_idempotent(s in ".*") { + let once = slug(&s); + let twice = slug(&once); + prop_assert_eq!(once, twice); + } + + #[test] + fn slug_output_matches_truth_key_grammar(s in "[a-z0-9 ]+") { + let result = slug(&s); + if !result.is_empty() && result != "value" { + let parsed = crate::TruthKey::parse(&result); + prop_assert!(parsed.is_ok(), "slug({s:?}) = {result:?} must be a valid TruthKey: {}", parsed.unwrap_err()); + } } } -} -fn require_any_fact(context: &dyn Context, key: ContextKey, prefix: &str) -> CriterionResult { - let evidence = context - .get(key) - .iter() - .filter(|fact| fact.id().starts_with(prefix)) - .map(|fact| fact.id().clone()) - .collect::>(); - if evidence.is_empty() { - CriterionResult::Unmet { - reason: format!("no facts found with prefix {prefix}"), + // --- Fixture types for mechanism tests (no real capability-* access) --- + + /// A resolver that maps module keys to pack IDs via a static table. + struct StaticPackResolver(&'static [(&'static str, &'static str)]); + + impl PackResolver for StaticPackResolver { + fn pack_ids_for( + &self, + modules: &[TruthModuleTouch], + ) -> Result, UnknownModule> { + let mut pack_ids = Vec::new(); + for touch in modules { + let pack_id = self + .0 + .iter() + .find(|(k, _)| *k == touch.module_key) + .map(|(_, p)| *p) + .ok_or_else(|| UnknownModule { + truth_key: String::new(), + module_key: touch.module_key.to_owned(), + })?; + if !pack_ids.contains(&pack_id) { + pack_ids.push(pack_id); + } + } + Ok(pack_ids) } - } else { - CriterionResult::Met { evidence } + } + + /// A resolver that always fails on the first module key it encounters. + struct AlwaysUnknownResolver; + + impl PackResolver for AlwaysUnknownResolver { + fn pack_ids_for( + &self, + modules: &[TruthModuleTouch], + ) -> Result, UnknownModule> { + Err(UnknownModule { + truth_key: String::new(), + module_key: modules + .first() + .map(|m| m.module_key.to_owned()) + .unwrap_or_else(|| "unknown".to_owned()), + }) + } + } + + const FIXTURE_TRUTH: TruthDefinition = TruthDefinition { + key: "approve-access-request", + display_name: "Approve access request", + kind: TruthKind::Job, + summary: "Review and approve or deny an access request.", + feature_path: "truths/jobs/approve_access_request.feature", + actor_roles: &["security-operator"], + approval_points: &["manual approval when risk is elevated"], + desired_outcomes: &["access decision is recorded"], + guardrails: &["decision must cite a policy"], + modules: &[ + TruthModuleTouch { + module_key: "identity", + responsibility: "verify requestor identity", + }, + TruthModuleTouch { + module_key: "policies", + responsibility: "apply access policy", + }, + ], + gherkin: "", + }; + + const FIXTURE_RESOLVER: StaticPackResolver = StaticPackResolver(&[ + ("identity", "trust"), + ("policies", "prio-foundation-pack"), + ]); + + // --- Negative test: unknown module → Err, not panic --- + + #[test] + fn build_unknown_module_returns_err_not_panic() { + let result = TruthConvergeBinding::build(FIXTURE_TRUTH, &AlwaysUnknownResolver); + assert!( + result.is_err(), + "expected Err for unknown module but got Ok" + ); + } + + #[test] + fn unknown_module_error_carries_truth_key() { + let err = TruthConvergeBinding::build(FIXTURE_TRUTH, &AlwaysUnknownResolver) + .unwrap_err(); + assert_eq!( + err.truth_key, "approve-access-request", + "build() must fill truth_key into the error" + ); + } + + #[test] + fn unknown_module_error_carries_module_key() { + let err = TruthConvergeBinding::build(FIXTURE_TRUTH, &AlwaysUnknownResolver) + .unwrap_err(); + assert_eq!( + err.module_key, "identity", + "error must carry the unresolvable module key" + ); + } + + #[test] + fn unknown_module_error_message_matches_former_panic_text() { + let err = TruthConvergeBinding::build(FIXTURE_TRUTH, &AlwaysUnknownResolver) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("approve-access-request"), + "message must contain truth key; got: {msg}" + ); + assert!( + msg.contains("identity"), + "message must contain module key; got: {msg}" + ); + assert!( + msg.contains("references unknown module"), + "message must match former panic text; got: {msg}" + ); + } + + // --- Positive test: build over fixture resolver --- + + #[test] + fn build_with_fixture_resolver_succeeds() { + let binding = + TruthConvergeBinding::build(FIXTURE_TRUTH, &FIXTURE_RESOLVER).expect("build failed"); + assert_eq!(binding.truth_key, "approve-access-request"); + assert_eq!(binding.runtime, "converge"); + assert_eq!(binding.pack_ids, vec!["trust", "prio-foundation-pack"]); + assert_eq!(binding.approval_points, vec!["manual approval when risk is elevated"]); + } + + #[test] + fn build_pack_ids_are_deduped() { + // Both modules resolve to the same pack; result must be len 1. + let both_trust: StaticPackResolver = StaticPackResolver(&[ + ("identity", "trust"), + ("policies", "trust"), + ]); + let binding = + TruthConvergeBinding::build(FIXTURE_TRUTH, &both_trust).expect("build failed"); + assert_eq!(binding.pack_ids, vec!["trust"], "pack_ids must be deduped"); + } + + #[test] + fn build_populates_intent_id() { + let binding = + TruthConvergeBinding::build(FIXTURE_TRUTH, &FIXTURE_RESOLVER).expect("build failed"); + assert_eq!( + binding.intent.id.as_str(), + "truth:approve-access-request" + ); } } diff --git a/crates/truth-catalog/src/intent_compile.rs b/crates/truth-catalog/src/intent_compile.rs index ac1cb74..70c62b7 100644 --- a/crates/truth-catalog/src/intent_compile.rs +++ b/crates/truth-catalog/src/intent_compile.rs @@ -1,17 +1,16 @@ //! axiom-driven IntentPacket construction (organism 1.8.0 migration step 2). //! //! Builds an `IntentPacket` for a given `TruthDefinition` by parsing the -//! truth's `.feature` source through axiom, then applying a small helms-side +//! truth's `.feature` source through axiom, then applying a content-side //! overlay for fields the source schema doesn't yet capture (context JSON, //! relative expiry, and bare-string constraints/authority). //! -//! As truths are progressively migrated, the overlay shrinks. When all fields -//! land in source, `truth_overlay` becomes a no-op and `organism_recipe` / -//! `TruthDefinition` can be deleted (handoff step 3). +//! Content crates supply their own [`IntentOverlay`] (e.g. `CrmIntentOverlay` +//! in `crm-truths`); the mechanism crate carries zero per-truth knowledge. -use chrono::{Duration, Utc}; use organism_pack::IntentPacket; +use crate::resolve::IntentOverlay; use crate::TruthDefinition; /// Errors produced by the axiom-driven compile path. @@ -21,115 +20,18 @@ pub enum CompileTruthError { Axiom(#[from] axiom_truth::CompileFromSourceError), } -/// Compile a `TruthDefinition` into an `IntentPacket` via axiom + helms overlay. +/// Compile a [`TruthDefinition`] into an [`IntentPacket`] via axiom, then +/// apply `overlay` to fill in content-specific fields. /// -/// The overlay (per-truth `with_context`, `expires`, supplementary constraints -/// or authority) lives in [`truth_overlay`] until the corresponding governance -/// gets pushed into the source schema. -pub fn compile_intent_for_truth( - truth: &TruthDefinition, +/// # Errors +/// +/// Returns [`CompileTruthError`] when axiom cannot parse or compile the +/// truth's `.feature` source. +pub fn compile_intent_with_overlay( + def: &TruthDefinition, + overlay: &dyn IntentOverlay, ) -> Result { - let mut intent = axiom_truth::compile_intent_from_source(truth.gherkin)?; - truth_overlay(truth, &mut intent); + let mut intent = axiom_truth::compile_intent_from_source(def.gherkin)?; + overlay.apply(def, &mut intent); Ok(intent) } - -/// Per-truth helms-side overlay. Mirrors what the legacy `organism_recipe` -/// inlined; will shrink as governance migrates into the source schema. -fn truth_overlay(truth: &TruthDefinition, intent: &mut IntentPacket) { - // Default 1-hour expiry to match the legacy recipe; per-truth overrides - // can land here once axiom expresses an absolute Authority.expires. - intent.expires = Utc::now() + Duration::hours(1); - - match truth.key { - "qualify-inbound-lead" => { - intent.context = serde_json::json!({ - "pending": ["lead:inbound"], - "strategies": "next owner and route required", - }); - intent.constraints = vec!["lead_has_source".to_string()]; - } - "submit-expense-report" => { - intent.context = serde_json::json!({ - "expense": { - "receipt": "receipt:pending", - "category": "expense:travel", - "approval": "approval:route", - "budget": "budget_envelope:team-travel", - }, - "documents": ["receipt:pending"], - "evaluations": "approval review required", - }); - intent.constraints = vec![ - "approval_has_rationale".to_string(), - "no_spend_beyond_envelope".to_string(), - ]; - } - "evaluate-acquisition-target" => { - intent.context = serde_json::json!({ - "target": "company:pending", - "research": ["market", "competition", "technology", "financials", "team"], - "evaluations": "investment committee review required", - }); - intent.constraints = vec![ - "contradictions_flagged".to_string(), - "synthesis_requires_coverage".to_string(), - "hypothesis_has_source".to_string(), - ]; - intent.authority = vec!["investment-committee".to_string()]; - } - "plan-outbound-campaign" => { - intent.context = serde_json::json!({ - "campaign": "campaign:q3-pipeline", - "audience": "audience:target-accounts", - "budget": "budget:quarterly-outbound", - "evaluations": "attribution review", - }); - intent.constraints = vec!["budget_guardrails_enforced".to_string()]; - } - _ => { - // Other truths still flow through the legacy `organism_recipe` - // path; their overlays land here as they migrate. - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::find_truth; - - /// Regression test: each truth's outcome string is round-trippable through - /// axiom's parse + compile pipeline. The legacy `organism_recipe` - /// equivalence gates lived here during the migration and were retired - /// once the recipe path was deleted (handoff step 3). - fn assert_compiles(key: &str) { - let truth = find_truth(key).unwrap_or_else(|| panic!("truth {key} exists")); - let intent = - compile_intent_for_truth(&truth).unwrap_or_else(|e| panic!("compile {key}: {e}")); - assert!( - !intent.outcome.trim().is_empty(), - "{key} compiled with empty outcome" - ); - } - - #[test] - fn qualify_inbound_lead_compiles() { - assert_compiles("qualify-inbound-lead"); - } - - #[test] - fn submit_expense_report_compiles() { - assert_compiles("submit-expense-report"); - } - - #[test] - fn evaluate_acquisition_target_compiles() { - assert_compiles("evaluate-acquisition-target"); - } - - #[test] - fn plan_outbound_campaign_compiles() { - assert_compiles("plan-outbound-campaign"); - } -} diff --git a/crates/truth-catalog/src/key.rs b/crates/truth-catalog/src/key.rs new file mode 100644 index 0000000..299fce6 --- /dev/null +++ b/crates/truth-catalog/src/key.rs @@ -0,0 +1,245 @@ +//! `TruthKey` — typed, parse-don't-validate newtype for truth identifiers. +//! +//! # Rationale +//! +//! Truth keys are runtime string values that cross HTTP boundaries and serve as +//! lookup tokens in the `TruthCatalog`. Passing raw `&str` throughout the API +//! pushes validation responsibility to every call-site, leading to either +//! defensive panics or silent "no match" bugs. +//! +//! `TruthKey` encodes the invariant once at the parse boundary (HTTP handler, +//! CLI arg, deserialization) and allows the interior of the system to handle a +//! value that is **guaranteed** to be a valid kebab-case identifier. This is +//! the parse-don't-validate pattern applied to a string type. +//! +//! # Grammar +//! +//! A valid `TruthKey` is a kebab-case identifier: +//! +//! ```text +//! truth-key ::= segment ('-' segment)* +//! segment ::= [a-z0-9]+ +//! ``` +//! +//! In other words: +//! - One or more lowercase ASCII alphanumeric segments. +//! - Segments are joined by exactly one hyphen. +//! - No leading or trailing hyphens. +//! - No consecutive hyphens. +//! - Non-ASCII characters are rejected. +//! - Empty strings are rejected. +//! +//! This matches the output of the `slug()` helper that produced the `&'static str` +//! truth keys used by `TruthDefinition::key` throughout this crate. + +use std::fmt; +use std::str::FromStr; + +use serde::Serialize; +use thiserror::Error; + +/// A validated, kebab-case truth identifier. +/// +/// Construct via [`TruthKey::parse`] or via the [`FromStr`] impl. Both reject +/// any string that does not conform to the grammar; see the module-level docs +/// for the full grammar. +/// +/// Use [`TruthKey::as_str`] to borrow the inner value. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct TruthKey(String); + +/// Returned when a string cannot be parsed as a [`TruthKey`]. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("invalid truth key {input:?}: {reason}")] +pub struct InvalidTruthKey { + /// The original string that was rejected. + pub input: String, + /// A human-readable description of why the string was rejected. + pub reason: &'static str, +} + +impl TruthKey { + /// Parse `s` as a kebab-case truth key. + /// + /// # Errors + /// + /// Returns [`InvalidTruthKey`] when `s` is empty, contains non-ASCII + /// characters, uses uppercase letters, has a leading or trailing hyphen, or + /// has consecutive hyphens. + pub fn parse(s: &str) -> Result { + let err = |reason| InvalidTruthKey { + input: s.to_owned(), + reason, + }; + + if s.is_empty() { + return Err(err("must not be empty")); + } + + if !s.is_ascii() { + return Err(err("must contain only ASCII characters")); + } + + if s.starts_with('-') { + return Err(err("must not start with a hyphen")); + } + + if s.ends_with('-') { + return Err(err("must not end with a hyphen")); + } + + for ch in s.chars() { + if !matches!(ch, 'a'..='z' | '0'..='9' | '-') { + return Err(err( + "must contain only lowercase ASCII letters, digits, and hyphens", + )); + } + } + + if s.contains("--") { + return Err(err("must not contain consecutive hyphens")); + } + + Ok(TruthKey(s.to_owned())) + } + + /// Borrow the inner string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for TruthKey { + type Err = InvalidTruthKey; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl fmt::Display for TruthKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for TruthKey { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::{InvalidTruthKey, TruthKey}; + + fn valid(s: &str) -> TruthKey { + TruthKey::parse(s).expect("expected valid key") + } + + fn invalid(s: &str) -> InvalidTruthKey { + TruthKey::parse(s).expect_err("expected invalid key") + } + + // --- valid cases --- + + #[test] + fn single_segment_is_valid() { + assert_eq!(valid("lead").as_str(), "lead"); + } + + #[test] + fn multi_segment_is_valid() { + assert_eq!( + valid("qualify-inbound-lead").as_str(), + "qualify-inbound-lead" + ); + } + + #[test] + fn segment_with_digits_is_valid() { + assert_eq!(valid("truth-42-abc").as_str(), "truth-42-abc"); + } + + #[test] + fn display_roundtrips() { + let key = valid("score-inbound-fit"); + assert_eq!(key.to_string(), "score-inbound-fit"); + } + + #[test] + fn fromstr_roundtrips() { + let key: TruthKey = "plan-outbound-campaign".parse().unwrap(); + assert_eq!(key.as_str(), "plan-outbound-campaign"); + } + + // --- invalid cases --- + + #[test] + fn empty_string_is_invalid() { + let e = invalid(""); + assert_eq!(e.input, ""); + assert!(e.reason.contains("empty"), "reason: {}", e.reason); + } + + #[test] + fn uppercase_is_invalid() { + let e = invalid("Qualify"); + assert!( + e.reason.contains("lowercase"), + "reason should mention lowercase: {}", + e.reason + ); + } + + #[test] + fn underscore_is_invalid() { + let e = invalid("submit_expense"); + assert!( + e.reason.contains("lowercase"), + "reason: {}", + e.reason + ); + } + + #[test] + fn leading_hyphen_is_invalid() { + let e = invalid("-lead"); + assert!( + e.reason.contains("start"), + "reason: {}", + e.reason + ); + } + + #[test] + fn trailing_hyphen_is_invalid() { + let e = invalid("lead-"); + assert!( + e.reason.contains("end"), + "reason: {}", + e.reason + ); + } + + #[test] + fn non_ascii_is_invalid() { + let e = invalid("lead-über"); + assert!( + e.reason.contains("ASCII"), + "reason: {}", + e.reason + ); + } + + #[test] + fn consecutive_hyphens_invalid() { + let e = invalid("lead--inbound"); + assert!( + e.reason.contains("consecutive"), + "reason: {}", + e.reason + ); + } +} diff --git a/crates/truth-catalog/src/lib.rs b/crates/truth-catalog/src/lib.rs index fc1486f..48d01cc 100644 --- a/crates/truth-catalog/src/lib.rs +++ b/crates/truth-catalog/src/lib.rs @@ -1,25 +1,80 @@ +//! # truth-catalog — Mechanism Seam (RFL-172) +//! +//! `truth-catalog` is the *mechanism* crate for Helms' executable truth layer. +//! It owns the structural types and runtime machinery that govern how truths +//! are defined, catalogued, compiled to organism `IntentPacket`s, and +//! admitted into the Converge reasoning kernel. +//! +//! ## Mechanism / content inversion (Seam B) +//! +//! Historically, truth definitions (the CRM `TRUTHS` const, per-truth +//! evaluators, overlay tables, and the capability-registry-based pack resolver) +//! lived alongside the mechanism in a single crate. RFL-172 inverted this: +//! +//! | Layer | Crate | Contains | +//! |-------|-------|---------| +//! | **Mechanism** | `truth-catalog` *(this crate)* | `TruthDefinition`, `TruthCatalog`, `TruthKey`, `TruthConvergeBinding`, `PackResolver`, `IntentOverlay`, admission, orchestration | +//! | **Content** | `crm-truths` | `TRUTHS` const, 24 `.feature` files, 13 CRM evaluators, `CrmPackResolver`, `CrmIntentOverlay` | +//! +//! The inversion means `truth-catalog` carries **zero** `capability_registry` / +//! `capability_core` imports — the content side injects those via traits at +//! call sites. +//! +//! ## Core types +//! +//! - [`TruthDefinition`] — the static descriptor for a single executable truth +//! (key, kind, summary, modules, gherkin source). +//! - [`TruthCatalog`] — a borrowing view over a `&[TruthDefinition]` slice with +//! typed query methods (`find`, `by_kind`, `for_module`). Construct with +//! `TruthCatalog::new(crm_truths::TRUTHS)` for the CRM catalog, or over a +//! synthetic slice in tests. +//! - [`TruthKey`] — a validated, kebab-case newtype for truth identifiers. +//! Enforces parse-don't-validate at the runtime string crossing (HTTP keys, +//! CLI args). Construct via [`TruthKey::parse`] or the [`FromStr`] impl. +//! - [`TruthConvergeBinding`] — a truth mapped to Converge's typed model +//! (intent, pack IDs, approval points). Built via +//! [`TruthConvergeBinding::build`] with an injected [`PackResolver`]. +//! +//! ## Injection points +//! +//! Content-side behaviour enters the mechanism via two traits: +//! +//! - [`resolve::PackResolver`] — maps `TruthModuleTouch` entries to Converge +//! pack IDs without importing capability crates. +//! - [`resolve::IntentOverlay`] — applies per-truth `context`, `constraints`, +//! `authority`, and `expires` overrides to compiled `IntentPacket`s. +//! +//! Mounting binaries inject `crm_truths::CrmPackResolver` and +//! `crm_truths::CrmIntentOverlay` at construction time. +//! +//! ## Lineage +//! +//! - RFL-171 (Seam A): `helm-module-contracts` split extracted the +//! `HelmModule` / `HelmModuleState` contract boundary. +//! - RFL-172 (Seam B, this crate): mechanism/content split; `crm-truths` +//! created as the content implementor. Task sequence: +//! T1 scaffold → T2 TruthKey+TruthCatalog → T3 PackResolver+IntentOverlay → +//! T4 content move → T5 governed-jobs injection → T6 workbench repoint → +//! T7 quality suite → T8 docs. +//! +//! [`FromStr`]: std::str::FromStr + pub mod admission; +pub mod catalog; mod converge; pub mod intent_compile; +pub mod key; pub mod orchestration; mod organism; +pub mod resolve; +pub use catalog::TruthCatalog; +pub use converge::{TruthConvergeBinding, to_converge_truth}; +pub use key::{InvalidTruthKey, TruthKey}; +pub use organism::TruthOrganismBinding; +pub use resolve::{IntentOverlay, PackResolver, UnknownModule}; use serde::Serialize; -pub use converge::{ - ActivateSubscriptionEvaluator, EvaluateAcquisitionTargetEvaluator, - MatchRenewalContextEvaluator, MatchVisualToTaglineEvaluator, MonitorBrandSignalEvaluator, - PlanOutboundCampaignEvaluator, QualifyInboundLeadEvaluator, - ReconcileModelUsageAgainstCustomerLedgerEvaluator, RefillPrepaidAiCreditsEvaluator, - ScheduleStrategicMeetingsEvaluator, ScoreInboundFitEvaluator, StaticTruthCatalog, - SuspendServiceOnPaymentFailureEvaluator, UpgradeSubscriptionPlanEvaluator, - converge_truth_definition, -}; -pub use converge::{TruthConvergeBinding, converge_binding_for_truth}; -pub use organism::{ - TruthOrganismBinding, display_pack_names_for_truth, organism_binding_for_truth, -}; - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum TruthKind { @@ -48,1121 +103,3 @@ pub struct TruthDefinition { pub modules: &'static [TruthModuleTouch], pub gherkin: &'static str, } - -pub const TRUTHS: &[TruthDefinition] = &[ - TruthDefinition { - key: "qualify-inbound-lead", - display_name: "Qualify inbound lead", - kind: TruthKind::Job, - summary: "Capture inbound demand, verify fit, and assign an explicit next commercial step.", - feature_path: "truths/jobs/qualify_inbound_lead.feature", - actor_roles: &["commercial-operator", "sales-agent"], - approval_points: &["manual handoff when fit or authority is ambiguous"], - desired_outcomes: &[ - "lead is explicitly qualified or disqualified", - "next owner and next step are recorded", - ], - guardrails: &[ - "qualification facts must cite attributable evidence", - "disqualification reason must be explicit and queryable", - ], - modules: &[ - TruthModuleTouch { - module_key: "parties", - responsibility: "persist organization, contact, and stakeholder context", - }, - TruthModuleTouch { - module_key: "opportunities", - responsibility: "create lead and opportunity state", - }, - TruthModuleTouch { - module_key: "conversations", - responsibility: "capture the inbound thread and follow-up context", - }, - TruthModuleTouch { - module_key: "facts", - responsibility: "promote verified qualification signals", - }, - TruthModuleTouch { - module_key: "intents", - responsibility: "frame the JTBD and success criteria", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/qualify_inbound_lead.feature" - )), - }, - TruthDefinition { - key: "score-inbound-fit", - display_name: "Score inbound fit", - kind: TruthKind::Job, - summary: "Use website behavior and inbound context to produce a governed fit score for a lead.", - feature_path: "truths/jobs/score_inbound_fit.feature", - actor_roles: &["growth-operator", "commercial-analyst", "runtime-agent"], - approval_points: &["manual review when the behavioral signal quality is weak"], - desired_outcomes: &[ - "a governed fit score is recorded for the inbound lead", - "the score cites attributable behavioral evidence", - ], - guardrails: &[ - "fit scoring must retain traceable behavioral provenance", - "weak or sparse signal quality must not be treated as high confidence", - ], - modules: &[ - TruthModuleTouch { - module_key: "parties", - responsibility: "anchor the score to an organization or contact context", - }, - TruthModuleTouch { - module_key: "metering", - responsibility: "supply attributable website and usage event history", - }, - TruthModuleTouch { - module_key: "opportunities", - responsibility: "make the commercial fit signal available to downstream lead handling", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/score_inbound_fit.feature" - )), - }, - TruthDefinition { - key: "plan-outbound-campaign", - display_name: "Plan outbound campaign", - kind: TruthKind::Job, - summary: "Assign prospects to reps and schedule campaign work under capacity and budget guardrails.", - feature_path: "truths/jobs/plan_outbound_campaign.feature", - actor_roles: &["growth-operator", "sales-manager", "runtime-agent"], - approval_points: &["manual approval when campaign spend exceeds the allocated budget"], - desired_outcomes: &[ - "a governed outbound campaign plan exists", - "campaign budget status is explicit and queryable", - ], - guardrails: &[ - "campaign plans must retain assignment rationale", - "budget overruns require an explicit approval path", - ], - modules: &[ - TruthModuleTouch { - module_key: "opportunities", - responsibility: "provide the prospect pool and expected commercial value", - }, - TruthModuleTouch { - module_key: "tasks", - responsibility: "translate campaign assignments into executable work", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "track the campaign plan and exception path", - }, - TruthModuleTouch { - module_key: "ledger", - responsibility: "govern budget consumption and auditability", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/plan_outbound_campaign.feature" - )), - }, - TruthDefinition { - key: "match-renewal-context", - display_name: "Match renewal context", - kind: TruthKind::Job, - summary: "Retrieve and converge the most relevant account history ahead of a contract renewal.", - feature_path: "truths/jobs/match_renewal_context.feature", - actor_roles: &["account-owner", "renewal-manager", "runtime-agent"], - approval_points: &["manual review when renewal terms fall outside the standard path"], - desired_outcomes: &[ - "a renewal brief is attached to the account or renewal motion", - "retrieved renewal signals stay traceable to their source artifacts", - ], - guardrails: &[ - "renewal retrieval must preserve source attribution", - "non-standard renewal terms require an explicit human gate", - ], - modules: &[ - TruthModuleTouch { - module_key: "parties", - responsibility: "anchor retrieval to the customer account and stakeholders", - }, - TruthModuleTouch { - module_key: "conversations", - responsibility: "supply call, email, and timeline context", - }, - TruthModuleTouch { - module_key: "documents", - responsibility: "store the resulting renewal brief and source artifacts", - }, - TruthModuleTouch { - module_key: "opportunities", - responsibility: "tie retrieved context to the renewal commercial motion", - }, - TruthModuleTouch { - module_key: "memory", - responsibility: "provide semantic retrieval and learned relevance", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/match_renewal_context.feature" - )), - }, - TruthDefinition { - key: "submit-expense-report", - display_name: "Submit expense report", - kind: TruthKind::Job, - summary: "Submit a reimbursable expense report with receipt evidence, policy review, and export-ready approval state.", - feature_path: "truths/jobs/submit_expense_report.feature", - actor_roles: &["employee", "finance-approver", "runtime-agent"], - approval_points: &[ - "manual review when OCR confidence or policy fit is ambiguous", - "manual approval when spend falls outside the allowed envelope", - ], - desired_outcomes: &[ - "expense report is submitted with attributable receipt evidence", - "approval route and export status are explicit and queryable", - ], - guardrails: &[ - "every claimed amount must remain attached to receipt evidence", - "out-of-policy or low-confidence extraction must open an explicit human gate", - ], - modules: &[ - TruthModuleTouch { - module_key: "expenses", - responsibility: "own the expense report, expense items, and export readiness state", - }, - TruthModuleTouch { - module_key: "documents", - responsibility: "persist receipt evidence and OCR output artifacts", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "track review, exception, and export status", - }, - TruthModuleTouch { - module_key: "approvals", - responsibility: "govern non-standard or elevated-risk spend decisions", - }, - TruthModuleTouch { - module_key: "policies", - responsibility: "apply reimbursement rules and policy thresholds", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/submit_expense_report.feature" - )), - }, - TruthDefinition { - key: "create-customer-workspace", - display_name: "Create customer workspace", - kind: TruthKind::Job, - summary: "Provision a customer workspace with the right commercial and access context.", - feature_path: "truths/jobs/create_customer_workspace.feature", - actor_roles: &["customer-ops", "revops", "runtime-agent"], - approval_points: &["exception approval before provisioning non-standard workspaces"], - desired_outcomes: &[ - "workspace exists with the correct owner", - "commercial plan and quotas are attached", - ], - guardrails: &[ - "provisioning cannot finish without a linked account", - "workspace activation must reference a commercial commitment", - ], - modules: &[ - TruthModuleTouch { - module_key: "parties", - responsibility: "anchor the workspace to the customer account", - }, - TruthModuleTouch { - module_key: "subscriptions", - responsibility: "bind the workspace to the purchased commitment", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "apply quotas and feature access", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "track the provisioning case and exceptions", - }, - TruthModuleTouch { - module_key: "approvals", - responsibility: "control exceptions or manual releases", - }, - TruthModuleTouch { - module_key: "intents", - responsibility: "keep the operator-facing job context explicit", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/create_customer_workspace.feature" - )), - }, - TruthDefinition { - key: "activate-subscription", - display_name: "Activate subscription", - kind: TruthKind::Job, - summary: "Turn an agreed commercial plan into an active subscription and entitlement state.", - feature_path: "truths/jobs/activate_subscription.feature", - actor_roles: &["revops", "billing-operator"], - approval_points: &["manual review for non-standard plan terms"], - desired_outcomes: &[ - "subscription becomes active with an explicit plan", - "entitlements and financial opening state are aligned", - ], - guardrails: &[ - "an active subscription must resolve to a valid catalog plan", - "activation events must remain auditable", - ], - modules: &[ - TruthModuleTouch { - module_key: "catalog", - responsibility: "resolve the plan and pricing definition", - }, - TruthModuleTouch { - module_key: "subscriptions", - responsibility: "persist subscription lifecycle state", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "derive usable access from the plan", - }, - TruthModuleTouch { - module_key: "ledger", - responsibility: "open the auditable commercial balance context", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "coordinate activation checks and handoffs", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/activate_subscription.feature" - )), - }, - TruthDefinition { - key: "refill-prepaid-ai-credits", - display_name: "Refill prepaid AI credits", - kind: TruthKind::Job, - summary: "Apply a top-up purchase to prepaid AI credit balances with financial traceability.", - feature_path: "truths/jobs/refill_prepaid_ai_credits.feature", - actor_roles: &["customer", "billing-operator", "runtime-agent"], - approval_points: &["manual review for unusual top-up size or risk signal"], - desired_outcomes: &[ - "confirmed top-up appears in the ledger", - "entitlement balance increases for the correct account", - ], - guardrails: &[ - "payment must be confirmed before any credit grant", - "top-up must remain linked to the customer account and commercial context", - ], - modules: &[ - TruthModuleTouch { - module_key: "parties", - responsibility: "link the purchase to the customer account", - }, - TruthModuleTouch { - module_key: "subscriptions", - responsibility: "resolve the active commercial commitment", - }, - TruthModuleTouch { - module_key: "payments", - responsibility: "confirm settlement state", - }, - TruthModuleTouch { - module_key: "ledger", - responsibility: "record the auditable credit grant", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "increase the usable prepaid balance", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/refill_prepaid_ai_credits.feature" - )), - }, - TruthDefinition { - key: "upgrade-subscription-plan", - display_name: "Upgrade subscription plan", - kind: TruthKind::Job, - summary: "Migrate a customer to a better plan while keeping pricing, access, and approval history coherent.", - feature_path: "truths/jobs/upgrade_subscription_plan.feature", - actor_roles: &["account-owner", "customer", "revops"], - approval_points: &["approval for price override or custom migration terms"], - desired_outcomes: &[ - "subscription moves to the target plan on an explicit date", - "entitlements and commercial delta stay aligned", - ], - guardrails: &[ - "target plan must exist in catalog", - "non-standard commercial deltas require explicit approval", - ], - modules: &[ - TruthModuleTouch { - module_key: "catalog", - responsibility: "resolve target plan and pricing metadata", - }, - TruthModuleTouch { - module_key: "subscriptions", - responsibility: "apply the lifecycle transition", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "swap access and quota state", - }, - TruthModuleTouch { - module_key: "approvals", - responsibility: "govern exceptional terms", - }, - TruthModuleTouch { - module_key: "ledger", - responsibility: "record financial deltas and adjustments", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/upgrade_subscription_plan.feature" - )), - }, - TruthDefinition { - key: "suspend-service-on-payment-failure", - display_name: "Suspend service on payment failure", - kind: TruthKind::Job, - summary: "Apply suspension policy when payment state fails while preserving controlled recovery paths.", - feature_path: "truths/jobs/suspend_service_on_payment_failure.feature", - actor_roles: &["billing-operator", "customer-success", "runtime-agent"], - approval_points: &["override approval before suspending strategic accounts"], - desired_outcomes: &[ - "service state matches payment policy", - "customer receives a clear recovery path", - ], - guardrails: &[ - "grace rules must be evaluated before suspension", - "reactivation path must remain explicit and auditable", - ], - modules: &[ - TruthModuleTouch { - module_key: "payments", - responsibility: "surface failed or overdue payment state", - }, - TruthModuleTouch { - module_key: "subscriptions", - responsibility: "apply subscription lifecycle suspension", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "reduce or pause access appropriately", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "run the suspension case and grace timers", - }, - TruthModuleTouch { - module_key: "parties", - responsibility: "keep customer ownership and communication routing intact", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/suspend_service_on_payment_failure.feature" - )), - }, - TruthDefinition { - key: "resolve-support-incident", - display_name: "Resolve support incident", - kind: TruthKind::Job, - summary: "Drive a customer issue from intake through diagnosis to a verified resolution or escalation.", - feature_path: "truths/jobs/resolve_support_incident.feature", - actor_roles: &["support-agent", "subject-matter-expert", "customer"], - approval_points: &["escalation approval for risky or customer-impacting workaround"], - desired_outcomes: &[ - "incident is resolved or deliberately escalated", - "root cause and customer-facing resolution are documented", - ], - guardrails: &[ - "resolution claims require evidence", - "customer-visible status must be updated before closure", - ], - modules: &[ - TruthModuleTouch { - module_key: "conversations", - responsibility: "hold the incident thread and external communications", - }, - TruthModuleTouch { - module_key: "tasks", - responsibility: "coordinate follow-ups and handoffs", - }, - TruthModuleTouch { - module_key: "documents", - responsibility: "store runbooks, notes, and attachments", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "track severity, SLA, and resolution state", - }, - TruthModuleTouch { - module_key: "facts", - responsibility: "promote verified diagnosis and remediation facts", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/resolve_support_incident.feature" - )), - }, - TruthDefinition { - key: "reconcile-model-usage-against-customer-ledger", - display_name: "Reconcile model usage against customer ledger", - kind: TruthKind::Job, - summary: "Align usage metering, financial balance, and entitlement burn-down without mutating history.", - feature_path: "truths/jobs/reconcile_model_usage_against_customer_ledger.feature", - actor_roles: &["finance-ops", "runtime-agent"], - approval_points: &["human review for unreconciled delta above threshold"], - desired_outcomes: &[ - "usage and financial state reconcile cleanly", - "exceptions are recorded and routed", - ], - guardrails: &[ - "reconciliation must preserve immutable ledger history", - "adjustments must remain traceable to evidence", - ], - modules: &[ - TruthModuleTouch { - module_key: "metering", - responsibility: "provide normalized usage events and consumption state", - }, - TruthModuleTouch { - module_key: "ledger", - responsibility: "provide auditable financial balance movements", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "compare usage against usable balance and quota state", - }, - TruthModuleTouch { - module_key: "subscriptions", - responsibility: "resolve commercial terms and billing period context", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "route reconciliation exceptions into operator review flows", - }, - TruthModuleTouch { - module_key: "audit", - responsibility: "preserve reconciliation provenance and evidence", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/reconcile_model_usage_against_customer_ledger.feature" - )), - }, - TruthDefinition { - key: "schedule-strategic-meetings", - display_name: "Schedule strategic meetings", - kind: TruthKind::Job, - summary: "Collapse multi-tool scheduling into a single intent: rank prospects by strategy alignment, resolve availability, and propose a concrete meeting slate with reasoning.", - feature_path: "truths/jobs/schedule_strategic_meetings.feature", - actor_roles: &["commercial-operator", "account-owner", "sales-agent"], - approval_points: &["human confirmation before any meeting is booked"], - desired_outcomes: &[ - "a ranked meeting slate is proposed with reasoning", - "each proposed meeting cites strategy alignment evidence", - ], - guardrails: &[ - "no meeting shall be auto-booked without human confirmation", - "candidate ranking must cite pipeline score and strategy context", - ], - modules: &[ - TruthModuleTouch { - module_key: "parties", - responsibility: "resolve prospect organizations, contacts, and relationship context", - }, - TruthModuleTouch { - module_key: "opportunities", - responsibility: "supply scored pipeline and commercial readiness signals", - }, - TruthModuleTouch { - module_key: "conversations", - responsibility: "provide communication history and scheduling preferences", - }, - TruthModuleTouch { - module_key: "tasks", - responsibility: "create bookable meeting tasks from the proposed slate", - }, - TruthModuleTouch { - module_key: "facts", - responsibility: "record strategy alignment evidence for each candidate", - }, - TruthModuleTouch { - module_key: "intents", - responsibility: "preserve the original free-text scheduling intent", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/schedule_strategic_meetings.feature" - )), - }, - TruthDefinition { - key: "detect-abnormal-token-burn", - display_name: "Detect abnormal token burn", - kind: TruthKind::Job, - summary: "Detect unusual usage patterns early and route a controlled mitigation path.", - feature_path: "truths/jobs/detect_abnormal_token_burn.feature", - actor_roles: &["runtime-agent", "customer-success"], - approval_points: &["operator approval before hard-limit intervention"], - desired_outcomes: &[ - "anomaly is explained with telemetry", - "a mitigation case is opened with recommended actions", - ], - guardrails: &[ - "automated intervention must respect policy thresholds", - "anomaly assertions must cite observed telemetry", - ], - modules: &[ - TruthModuleTouch { - module_key: "metering", - responsibility: "surface the usage anomaly signals", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "show quota and balance exposure", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "open and track the intervention case", - }, - TruthModuleTouch { - module_key: "memory", - responsibility: "provide historical context and comparable patterns", - }, - TruthModuleTouch { - module_key: "agent-ops", - responsibility: "track the detecting agents and validation chain", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/detect_abnormal_token_burn.feature" - )), - }, - TruthDefinition { - key: "renew-contract", - display_name: "Renew contract", - kind: TruthKind::Job, - summary: "Move a renewal from account context to approved commercial terms and current documents.", - feature_path: "truths/jobs/renew_contract.feature", - actor_roles: &["account-owner", "legal-operator", "customer"], - approval_points: &["approval for non-standard renewal terms"], - desired_outcomes: &[ - "renewal ends in accepted terms or an explicit no-renew decision", - "current commercial documents remain linked and versioned", - ], - guardrails: &[ - "renewal cannot close without explicit commercial terms", - "the current proposal or contract version must remain traceable", - ], - modules: &[ - TruthModuleTouch { - module_key: "parties", - responsibility: "provide account and stakeholder ownership context", - }, - TruthModuleTouch { - module_key: "catalog", - responsibility: "resolve current offerable plans and prices", - }, - TruthModuleTouch { - module_key: "opportunities", - responsibility: "carry renewal pipeline and forecast state", - }, - TruthModuleTouch { - module_key: "subscriptions", - responsibility: "link the renewal to the active commercial commitment", - }, - TruthModuleTouch { - module_key: "approvals", - responsibility: "govern non-standard commercial decisions", - }, - TruthModuleTouch { - module_key: "documents", - responsibility: "store proposal, quote, and contract artifacts", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/renew_contract.feature" - )), - }, - TruthDefinition { - key: "monitor-brand-signal", - display_name: "Monitor brand signal", - kind: TruthKind::Job, - summary: "Ingest external brand mentions, cluster them into narratives, and keep a governed brand-state projection current without auto-publishing anything.", - feature_path: "truths/jobs/monitor_brand_signal.feature", - actor_roles: &["brand-manager", "communications-lead", "runtime-agent"], - approval_points: &[ - "promote SignalIncident at severity high or above", - "revise risk thresholds on the BrandWatch policy", - ], - desired_outcomes: &[ - "brand-state projection is current within the watch cadence", - "every promoted incident cites traceable source evidence", - ], - guardrails: &[ - "mentions must retain source URL and retrieval timestamp", - "clustering must be reproducible from stored embeddings", - "sentiment must carry calibrated confidence, not a bare label", - "high-severity incidents cannot auto-promote", - "no outbound response may be generated by this truth", - ], - modules: &[ - TruthModuleTouch { - module_key: "parties", - responsibility: "anchor the BrandWatch to an organization, product, or executive", - }, - TruthModuleTouch { - module_key: "conversations", - responsibility: "store raw signal items and cluster membership", - }, - TruthModuleTouch { - module_key: "documents", - responsibility: "store narrative summaries and incident briefs", - }, - TruthModuleTouch { - module_key: "memory", - responsibility: "provide embeddings and semantic retrieval", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "run the watch cadence and incident lifecycle", - }, - TruthModuleTouch { - module_key: "approvals", - responsibility: "gate high-severity incident promotion", - }, - TruthModuleTouch { - module_key: "intents", - responsibility: "preserve the operator-facing BrandWatch context", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/monitor_brand_signal.feature" - )), - }, - TruthDefinition { - key: "match-visual-to-tagline", - display_name: "Match visual to tagline", - kind: TruthKind::Job, - summary: "Match brand-safe visuals with taglines at scale under typed multi-role approval, so campaign pairings ship only with brand, marketing, and storyteller sign-off.", - feature_path: "truths/jobs/match_visual_to_tagline.feature", - actor_roles: &[ - "campaign-owner", - "brand-manager", - "marketer", - "storyteller", - "legal-reviewer", - "runtime-agent", - ], - approval_points: &[ - "brand-manager approval of brand fit", - "marketer approval of audience fit", - "storyteller approval of narrative and copy", - "legal approval when a risk flag is raised", - ], - desired_outcomes: &[ - "a governed CampaignPairing fact exists for the brief", - "every chosen pairing cites brand, audience, and narrative evidence", - ], - guardrails: &[ - "taglines must comply with brand voice policy facts", - "synthetic visuals must be flagged and never auto-selected", - "risk-flagged pairings require legal approval", - "priors used in scoring must be traceable to an analytics source", - ], - modules: &[ - TruthModuleTouch { - module_key: "intents", - responsibility: "carry the CampaignBrief and desired outcome", - }, - TruthModuleTouch { - module_key: "documents", - responsibility: "store briefs, pairings, approvals, and rationale", - }, - TruthModuleTouch { - module_key: "memory", - responsibility: "supply brand guardrails, priors, and multimodal embeddings", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "run the multi-gate approval lifecycle", - }, - TruthModuleTouch { - module_key: "approvals", - responsibility: "enforce typed approval gates per role", - }, - TruthModuleTouch { - module_key: "parties", - responsibility: "anchor campaign owner and approving actors", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/match_visual_to_tagline.feature" - )), - }, - TruthDefinition { - key: "evaluate-acquisition-target", - display_name: "Evaluate acquisition target", - kind: TruthKind::Job, - summary: "Converge multi-source evidence into a structured acquisition recommendation with traceable evidence, contradiction surfacing, and honest stopping.", - feature_path: "truths/jobs/evaluate_acquisition_target.feature", - actor_roles: &["deal-lead", "investment-committee", "research-analyst"], - approval_points: &[ - "investment committee approval before recommendation leaves draft", - "human review when material contradictions are detected", - ], - desired_outcomes: &[ - "a recommendation is produced with confidence at least 0.7", - "all material contradictions are surfaced and documented", - "each DD dimension cites at least one independent source", - ], - guardrails: &[ - "no recommendation without adversarial review passing", - "contradictions must be surfaced, never resolved silently", - "human approval required before recommendation leaves draft", - ], - modules: &[ - TruthModuleTouch { - module_key: "facts", - responsibility: "hold research hypotheses and promoted findings", - }, - TruthModuleTouch { - module_key: "documents", - responsibility: "store source evidence, analysis pages, and the final brief", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "coordinate the DD lifecycle and approval gates", - }, - TruthModuleTouch { - module_key: "approvals", - responsibility: "enforce investment committee sign-off", - }, - TruthModuleTouch { - module_key: "audit", - responsibility: "preserve the full evidence and decision trail", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/jobs/evaluate_acquisition_target.feature" - )), - }, - TruthDefinition { - key: "top-up-requires-confirmed-payment", - display_name: "Top-up requires confirmed payment", - kind: TruthKind::Policy, - summary: "No prepaid balance increase may occur until settlement is confirmed.", - feature_path: "truths/policies/top_up_requires_confirmed_payment.feature", - actor_roles: &["billing-operator", "runtime-agent"], - approval_points: &["override approval for manual corrective grant"], - desired_outcomes: &[ - "credit grants only occur after confirmed settlement", - "manual overrides remain explicit and auditable", - ], - guardrails: &[ - "unconfirmed payment blocks credit application", - "override path must create provenance and rationale", - ], - modules: &[ - TruthModuleTouch { - module_key: "payments", - responsibility: "declare settlement state", - }, - TruthModuleTouch { - module_key: "ledger", - responsibility: "block or record the credit movement", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "avoid premature balance increase", - }, - TruthModuleTouch { - module_key: "policies", - responsibility: "own the cross-module guardrail", - }, - TruthModuleTouch { - module_key: "audit", - responsibility: "capture override evidence and decision trail", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/policies/top_up_requires_confirmed_payment.feature" - )), - }, - TruthDefinition { - key: "overdue-balance-blocks-entitlement-increase", - display_name: "Overdue balance blocks entitlement increase", - kind: TruthKind::Policy, - summary: "Customers with overdue obligations should not receive expanded access without exception handling.", - feature_path: "truths/policies/overdue_balance_blocks_entitlement_increase.feature", - actor_roles: &["finance-ops", "customer-success"], - approval_points: &["exception approval for temporary relief"], - desired_outcomes: &[ - "overdue customers do not receive expanded entitlements by default", - "temporary relief remains explicit and time-bound", - ], - guardrails: &[ - "overdue evaluation must use current payment state", - "exceptions must expire or be revisited explicitly", - ], - modules: &[ - TruthModuleTouch { - module_key: "payments", - responsibility: "surface overdue state", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "block entitlement expansion until resolved", - }, - TruthModuleTouch { - module_key: "policies", - responsibility: "define the blocking rule and exception policy", - }, - TruthModuleTouch { - module_key: "workflow", - responsibility: "run the exception path and follow-up timers", - }, - TruthModuleTouch { - module_key: "parties", - responsibility: "bind the exception to the customer account", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/policies/overdue_balance_blocks_entitlement_increase.feature" - )), - }, - TruthDefinition { - key: "promoted-fact-requires-traceable-evidence", - display_name: "Promoted fact requires traceable evidence", - kind: TruthKind::Policy, - summary: "Durable business truth must remain backed by evidence and provenance.", - feature_path: "truths/policies/promoted_fact_requires_traceable_evidence.feature", - actor_roles: &["analyst", "runtime-agent", "approver"], - approval_points: &["approval for low-confidence promotion"], - desired_outcomes: &[ - "every promoted fact links to evidence", - "low-confidence facts stay proposed until reviewed", - ], - guardrails: &[ - "unverifiable statements shall not become durable truth", - "provenance for promoted facts shall remain immutable", - ], - modules: &[ - TruthModuleTouch { - module_key: "facts", - responsibility: "hold proposed and promoted facts", - }, - TruthModuleTouch { - module_key: "documents", - responsibility: "store or link the supporting evidence", - }, - TruthModuleTouch { - module_key: "audit", - responsibility: "capture the promotion decision trail", - }, - TruthModuleTouch { - module_key: "policies", - responsibility: "enforce the promotion guardrail", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/policies/promoted_fact_requires_traceable_evidence.feature" - )), - }, - TruthDefinition { - key: "ledger-entry-is-immutable", - display_name: "Ledger entry is immutable", - kind: TruthKind::ModuleLocal, - summary: "Posted balance movements remain append-only, with corrections expressed as new entries.", - feature_path: "truths/modules/ledger_entry_is_immutable.feature", - actor_roles: &["finance-ops"], - approval_points: &[], - desired_outcomes: &[ - "original ledger entries remain unchanged", - "corrections are expressed as adjusting entries", - ], - guardrails: &[ - "posted ledger entries are append-only", - "correction chains must stay audit-linked", - ], - modules: &[ - TruthModuleTouch { - module_key: "ledger", - responsibility: "own immutable balance history", - }, - TruthModuleTouch { - module_key: "audit", - responsibility: "preserve the correction provenance chain", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/modules/ledger_entry_is_immutable.feature" - )), - }, - TruthDefinition { - key: "active-subscription-requires-plan", - display_name: "Active subscription requires plan", - kind: TruthKind::ModuleLocal, - summary: "A subscription cannot be active unless it resolves to a valid plan and entitlement source.", - feature_path: "truths/modules/active_subscription_requires_plan.feature", - actor_roles: &["revops", "runtime-agent"], - approval_points: &[], - desired_outcomes: &[ - "every active subscription maps to a valid plan", - "the entitlement source for active access is explicit", - ], - guardrails: &[ - "activation is blocked without a valid plan", - "entitlement template source must be explicit", - ], - modules: &[ - TruthModuleTouch { - module_key: "catalog", - responsibility: "provide the authoritative plan definition", - }, - TruthModuleTouch { - module_key: "subscriptions", - responsibility: "own subscription lifecycle validity", - }, - TruthModuleTouch { - module_key: "entitlements", - responsibility: "resolve access from the selected plan", - }, - ], - gherkin: include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/truths/modules/active_subscription_requires_plan.feature" - )), - }, -]; - -#[must_use] -pub fn all_truths() -> Vec { - TRUTHS.to_vec() -} - -#[must_use] -pub fn truths_by_kind(kind: TruthKind) -> Vec { - TRUTHS - .iter() - .copied() - .filter(|truth| truth.kind == kind) - .collect() -} - -#[must_use] -pub fn truths_for_module(module_key: &str) -> Vec { - TRUTHS - .iter() - .copied() - .filter(|truth| { - truth - .modules - .iter() - .any(|touch| touch.module_key == module_key) - }) - .collect() -} - -#[must_use] -pub fn find_truth(key: &str) -> Option { - TRUTHS.iter().copied().find(|truth| truth.key == key) -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use capability_registry::MODULES; - - use super::{TRUTHS, TruthKind, converge_binding_for_truth, truths_by_kind}; - - #[test] - fn starter_catalog_has_eighteen_job_truths() { - assert_eq!(truths_by_kind(TruthKind::Job).len(), 18); - } - - #[test] - fn starter_catalog_spans_all_truth_classes() { - assert!(!truths_by_kind(TruthKind::Job).is_empty()); - assert!(!truths_by_kind(TruthKind::Policy).is_empty()); - assert!(!truths_by_kind(TruthKind::ModuleLocal).is_empty()); - } - - #[test] - fn every_referenced_module_exists_in_registry() { - let known_modules = MODULES - .iter() - .map(|module| module.key) - .collect::>(); - for truth in TRUTHS { - for touch in truth.modules { - assert!( - known_modules.contains(touch.module_key), - "unknown module '{}' in truth '{}'", - touch.module_key, - truth.key - ); - } - } - } - - #[test] - fn qualify_inbound_lead_maps_to_converge_binding() { - let binding = converge_binding_for_truth("qualify-inbound-lead") - .expect("binding should exist for starter truth"); - assert_eq!(binding.runtime, "converge"); - assert_eq!( - binding.pack_ids, - vec![ - "prio-relationship-pack", - "prio-commercial-pack", - "prio-work-pack", - "trust", - "knowledge", - ] - ); - assert_eq!(binding.intent.id.as_str(), "truth:qualify-inbound-lead"); - assert_eq!( - binding.intent.request, - "Qualify inbound lead: Capture inbound demand, verify fit, and assign an explicit next commercial step." - ); - assert_eq!( - binding.intent.active_packs, - vec![ - "prio-relationship-pack".to_string(), - "prio-commercial-pack".to_string(), - "prio-work-pack".to_string(), - "trust".to_string(), - "knowledge".to_string(), - ] - ); - assert_eq!(binding.intent.success_criteria.len(), 2); - assert_eq!(binding.intent.constraints.len(), 3); - } -} diff --git a/crates/truth-catalog/src/orchestration.rs b/crates/truth-catalog/src/orchestration.rs index 31a50f9..ade45ae 100644 --- a/crates/truth-catalog/src/orchestration.rs +++ b/crates/truth-catalog/src/orchestration.rs @@ -204,101 +204,3 @@ pub fn prepare_candidates( }) } -#[cfg(test)] -mod tests { - use super::*; - use crate::admission::default_helms_capabilities; - use crate::find_truth; - use crate::intent_compile::compile_intent_for_truth; - - fn intent_for(key: &str) -> IntentPacket { - let truth = find_truth(key).expect("known truth"); - compile_intent_for_truth(&truth).expect("axiom compiles") - } - - #[test] - fn single_shot_excludes_all_alternates() { - let intent = intent_for("qualify-inbound-lead"); - let opts = AutoRunOptions::default(); // race_alternates: false - let slate = prepare_candidates(&intent, &default_helms_capabilities(), &opts) - .expect("selection succeeds"); - assert!(slate.alternate_template_ids.is_empty()); - assert!( - slate - .excluded - .iter() - .all(|e| e.reason == ExclusionReason::SingleShotRequested) - ); - } - - #[test] - fn race_keeps_primary_and_filters_alternates() { - let intent = intent_for("qualify-inbound-lead"); - let opts = AutoRunOptions { - race_alternates: true, - max_candidates: 3, - relative_cutoff: 0.0, // accept anything that scored - }; - let slate = prepare_candidates(&intent, &default_helms_capabilities(), &opts) - .expect("selection succeeds"); - assert!(!slate.primary_template_id.is_empty()); - // primary + alternates ≤ max_candidates - assert!(slate.alternate_template_ids.len() < opts.max_candidates); - } - - #[test] - fn max_candidates_clamps_to_one() { - let intent = intent_for("qualify-inbound-lead"); - let opts = AutoRunOptions { - race_alternates: true, - max_candidates: 0, // would underflow without the clamp - relative_cutoff: 0.0, - }; - let slate = prepare_candidates(&intent, &default_helms_capabilities(), &opts) - .expect("selection succeeds"); - assert!(slate.alternate_template_ids.is_empty()); - assert!( - slate - .excluded - .iter() - .all(|e| e.reason == ExclusionReason::BeyondMaxCandidates) - ); - } - - #[test] - fn cutoff_above_one_does_not_eject_primary() { - let intent = intent_for("qualify-inbound-lead"); - let opts = AutoRunOptions { - race_alternates: true, - max_candidates: 3, - relative_cutoff: 5.0, // pathological, should clamp to 1.0 - }; - let slate = prepare_candidates(&intent, &default_helms_capabilities(), &opts) - .expect("primary is never filtered out"); - assert!(!slate.primary_template_id.is_empty()); - } - - #[test] - fn irreversible_intents_refuse_to_race() { - let mut intent = intent_for("qualify-inbound-lead"); - intent.reversibility = Reversibility::Irreversible; - let opts = AutoRunOptions { - race_alternates: true, - max_candidates: 3, - relative_cutoff: 0.85, - }; - let err = prepare_candidates(&intent, &default_helms_capabilities(), &opts) - .expect_err("irreversible + race must reject"); - assert!(matches!(err, AutoRunError::IrreversibleCannotRace)); - } - - #[test] - fn irreversible_intents_can_run_single_shot() { - let mut intent = intent_for("qualify-inbound-lead"); - intent.reversibility = Reversibility::Irreversible; - let opts = AutoRunOptions::default(); // race_alternates: false - let slate = prepare_candidates(&intent, &default_helms_capabilities(), &opts) - .expect("single-shot is fine for irreversibles"); - assert!(!slate.primary_template_id.is_empty()); - } -} diff --git a/crates/truth-catalog/src/organism.rs b/crates/truth-catalog/src/organism.rs index 4676710..5c50e97 100644 --- a/crates/truth-catalog/src/organism.rs +++ b/crates/truth-catalog/src/organism.rs @@ -1,13 +1,7 @@ -use organism_pack::{DeclarativeBinding, IntentBinding, IntentResolver}; -use organism_runtime::{ - BudgetProbe, CredentialProbe, PackProbe, ReadinessProbe, ReadinessReport, Registry, - StructuralResolver, check_readiness, -}; +use organism_pack::IntentBinding; +use organism_runtime::ReadinessReport; use serde::Serialize; -use crate::intent_compile::compile_intent_for_truth; -use crate::{TruthDefinition, find_truth}; - #[derive(Debug, Clone, Serialize)] pub struct TruthOrganismBinding { pub truth_key: &'static str, @@ -26,133 +20,3 @@ impl TruthOrganismBinding { .collect() } } - -#[must_use] -pub fn organism_binding_for_truth( - truth_key: &str, - registry: &Registry, -) -> Option { - find_truth(truth_key).and_then(|truth| build_binding(truth, registry)) -} - -#[must_use] -pub fn display_pack_names_for_truth(truth_key: &str, registry: &Registry) -> Option> { - organism_binding_for_truth(truth_key, registry).map(|binding| binding.pack_names()) -} - -fn build_binding(truth: TruthDefinition, registry: &Registry) -> Option { - let (blueprint, baseline, readiness) = binding_recipe(truth)?; - let intent = compile_intent_for_truth(&truth) - .expect("truth has axiom-compilable governance and a known overlay"); - let resolver = StructuralResolver::new(registry); - let binding = resolver.resolve(&intent, &baseline); - let pack_probe = PackProbe::new(registry); - let credential_probe = CredentialProbe::new().with_standard_checks(); - let probes: Vec<&dyn ReadinessProbe> = vec![&pack_probe, &credential_probe, &readiness]; - let readiness = check_readiness(&binding, &probes); - - Some(TruthOrganismBinding { - truth_key: truth.key, - blueprint, - binding, - readiness, - }) -} - -/// Per-truth helms-static binding metadata: blueprint label, the declarative -/// pack/capability/invariant baseline that `StructuralResolver` resolves -/// against, and the budget probe used by readiness checks. -/// -/// The IntentPacket part of the legacy `organism_recipe` lives in -/// `intent_compile::compile_intent_for_truth` now (axiom-compiled + -/// helms overlay). Whatever remains here is the "smart selection" surface -/// the handoff explicitly tells helms to keep until `select_formation` -/// replaces it (handoff step 5). -fn binding_recipe( - truth: TruthDefinition, -) -> Option<(Option<&'static str>, IntentBinding, BudgetProbe)> { - match truth.key { - "submit-expense-report" => { - let binding = DeclarativeBinding::new() - .pack( - "procurement", - "expense intake, reimbursement routing, and export readiness", - ) - .pack( - "autonomous_org", - "approval policy, spend governance, and exception handling", - ) - .capability("ocr", "extract receipt fields from uploaded evidence") - .invariant("approval_has_rationale") - .invariant("no_spend_beyond_envelope") - .build(); - Some(( - Some("procure_to_pay"), - binding, - BudgetProbe::new() - .with_token_budget(5_000) - .with_spend_budget(5.0), - )) - } - "qualify-inbound-lead" => { - let binding = DeclarativeBinding::new() - .pack("customers", "lead qualification workflow") - .pack( - "linkedin_research", - "external company and stakeholder enrichment", - ) - .invariant("lead_has_source") - .build(); - Some(( - Some("lead_to_cash"), - binding, - BudgetProbe::new() - .with_token_budget(8_000) - .with_spend_budget(8.0), - )) - } - "evaluate-acquisition-target" => { - let binding = DeclarativeBinding::new() - .pack( - "due_diligence", - "convergent research, fact extraction, gap detection, contradiction finding, synthesis", - ) - .pack("legal", "legal review of findings and contractual implications") - .pack( - "knowledge", - "persist confirmed findings to the knowledge base", - ) - .capability("web", "broad and deep web research for company intelligence") - .capability("llm", "fact extraction, gap detection, and synthesis") - .invariant("hypothesis_has_source") - .invariant("contradictions_flagged") - .invariant("synthesis_requires_coverage") - .build(); - Some(( - Some("diligence_to_decision"), - binding, - BudgetProbe::new() - .with_token_budget(20_000) - .with_spend_budget(20.0), - )) - } - "plan-outbound-campaign" => { - let binding = DeclarativeBinding::new() - .pack( - "growth_marketing", - "campaign planning, allocation, and channel execution", - ) - .pack("customers", "downstream lead handling and handoff") - .invariant("budget_guardrails_enforced") - .build(); - Some(( - Some("campaign_to_revenue"), - binding, - BudgetProbe::new() - .with_token_budget(6_000) - .with_spend_budget(6.0), - )) - } - _ => None, - } -} diff --git a/crates/truth-catalog/src/resolve.rs b/crates/truth-catalog/src/resolve.rs new file mode 100644 index 0000000..38e4288 --- /dev/null +++ b/crates/truth-catalog/src/resolve.rs @@ -0,0 +1,85 @@ +//! `PackResolver` and `IntentOverlay` — inversion boundary for content-side +//! capability bindings (Seam B T3 keystone, RFL-172). +//! +//! # Rationale +//! +//! `truth-catalog` is the *mechanism* crate: it owns `TruthDefinition`, +//! `TruthCatalog`, `TruthConvergeBinding`, and the admission machinery. +//! Historically the mechanism reached directly into +//! `capability_registry::find_module` (to map module suites to pack IDs) and +//! per-truth overlay tables (to apply `context`/`constraints`/`authority` to +//! compiled `IntentPacket`s), coupling the mechanism to CRM content. +//! +//! `PackResolver` and `IntentOverlay` invert this dependency: the mechanism +//! receives behaviour from the content side via trait objects at call sites, +//! rather than importing content crates at all. The content-side +//! implementations (`CrmPackResolver`, `CrmIntentOverlay`) live in +//! `crm-truths` (Seam B T4, RFL-172); the mechanism carries zero +//! `capability_registry` / `capability_core` imports. + +use organism_pack::IntentPacket; + +use crate::{TruthDefinition, TruthModuleTouch}; + +/// Error produced when a [`PackResolver`] encounters a module key that is not +/// present in the capability registry. +/// +/// The `Display` output matches the former `panic!` at `converge.rs:606`: +/// +/// > `truth '{truth_key}' references unknown module '{module_key}'` +/// +/// The `truth_key` field is empty when the error is returned from +/// [`PackResolver::pack_ids_for`] (the resolver does not know which truth is +/// being built); it is filled in by [`crate::converge::TruthConvergeBinding::build`]. +#[derive(Debug, thiserror::Error)] +#[error("truth '{truth_key}' references unknown module '{module_key}'")] +pub struct UnknownModule { + /// The truth that references the unknown module. Filled in by the + /// calling `build()` context; may be empty when the error comes directly + /// from a resolver. + pub truth_key: String, + /// The module key that could not be resolved. + pub module_key: String, +} + +/// Resolves a set of [`TruthModuleTouch`] entries to their Converge pack IDs. +/// +/// Implement this trait on the content side (e.g. `CrmPackResolver` in +/// `crm-truths`) to supply the module → pack mapping without importing +/// capability crates into the mechanism. +/// +/// # Contract +/// +/// * The returned `Vec` is deduped and preserves insertion order. +/// * Every `module_key` in `modules` must be resolvable; return +/// [`UnknownModule`] on the first unresolvable key. +/// * The `truth_key` field of the returned error should be left empty; the +/// caller (`build()`) fills it in from the [`TruthDefinition`]. +pub trait PackResolver { + /// Return the deduped, ordered pack IDs for the given module touches. + /// + /// # Errors + /// + /// Returns [`UnknownModule`] when any `module_key` in `modules` cannot be + /// resolved. + fn pack_ids_for( + &self, + modules: &[TruthModuleTouch], + ) -> Result, UnknownModule>; +} + +/// Applies content-side overlay fields to an [`IntentPacket`] that has been +/// compiled from a truth's `.feature` source. +/// +/// Implement on the content side (e.g. `CrmIntentOverlay` in `crm-truths`) to +/// supply per-truth `context`, `constraints`, `authority`, and `expires` +/// overrides without encoding CRM specifics in the mechanism. +/// +/// # Safety +/// +/// The `Send + Sync` bound is required because overlay instances are shared +/// across async boundaries in the Helms runtime. +pub trait IntentOverlay: Send + Sync { + /// Mutate `intent` in-place with content-specific fields for `def`. + fn apply(&self, def: &TruthDefinition, intent: &mut IntentPacket); +} diff --git a/crates/truth-catalog/tests/compile_fail.rs b/crates/truth-catalog/tests/compile_fail.rs new file mode 100644 index 0000000..2667d92 --- /dev/null +++ b/crates/truth-catalog/tests/compile_fail.rs @@ -0,0 +1,8 @@ +//! Compile-fail regression guards for truth-catalog seam (RFL-172 T7). +//! +//! Verifies that certain type-system seam properties hold at compile time. +#[test] +fn compile_fail_guards() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/compile_fail/*.rs"); +} diff --git a/crates/truth-catalog/tests/compile_fail/bare_str_not_truth_key.rs b/crates/truth-catalog/tests/compile_fail/bare_str_not_truth_key.rs new file mode 100644 index 0000000..85761e2 --- /dev/null +++ b/crates/truth-catalog/tests/compile_fail/bare_str_not_truth_key.rs @@ -0,0 +1,4 @@ +//! Guard: bare &str cannot be coerced to &TruthKey. +fn main() { + let _: &truth_catalog::TruthKey = "qualify-inbound-lead"; +} diff --git a/crates/truth-catalog/tests/compile_fail/bare_str_not_truth_key.stderr b/crates/truth-catalog/tests/compile_fail/bare_str_not_truth_key.stderr new file mode 100644 index 0000000..62a65cc --- /dev/null +++ b/crates/truth-catalog/tests/compile_fail/bare_str_not_truth_key.stderr @@ -0,0 +1,10 @@ +error[E0308]: mismatched types + --> tests/compile_fail/bare_str_not_truth_key.rs:3:39 + | +3 | let _: &truth_catalog::TruthKey = "qualify-inbound-lead"; + | ------------------------ ^^^^^^^^^^^^^^^^^^^^^^ expected `&TruthKey`, found `&str` + | | + | expected due to this + | + = note: expected reference `&TruthKey` + found reference `&'static str` diff --git a/crates/truth-catalog/tests/compile_fail/capability_registry_not_a_dep.rs b/crates/truth-catalog/tests/compile_fail/capability_registry_not_a_dep.rs new file mode 100644 index 0000000..85d3f84 --- /dev/null +++ b/crates/truth-catalog/tests/compile_fail/capability_registry_not_a_dep.rs @@ -0,0 +1,4 @@ +//! Guard: capability_registry is NOT a dependency of truth-catalog. +fn main() { + let _modules = capability_registry::MODULES; +} diff --git a/crates/truth-catalog/tests/compile_fail/capability_registry_not_a_dep.stderr b/crates/truth-catalog/tests/compile_fail/capability_registry_not_a_dep.stderr new file mode 100644 index 0000000..27116f4 --- /dev/null +++ b/crates/truth-catalog/tests/compile_fail/capability_registry_not_a_dep.stderr @@ -0,0 +1,7 @@ +error[E0433]: cannot find module or crate `capability_registry` in this scope + --> tests/compile_fail/capability_registry_not_a_dep.rs:3:20 + | +3 | let _modules = capability_registry::MODULES; + | ^^^^^^^^^^^^^^^^^^^ use of unresolved module or unlinked crate `capability_registry` + | + = help: if you wanted to use a crate named `capability_registry`, use `cargo add capability_registry` to add it to your `Cargo.toml` diff --git a/crates/truth-catalog/tests/negative_tests.rs b/crates/truth-catalog/tests/negative_tests.rs new file mode 100644 index 0000000..83e9860 --- /dev/null +++ b/crates/truth-catalog/tests/negative_tests.rs @@ -0,0 +1,272 @@ +//! Negative-path tests for truth-catalog mechanism primitives (RFL-172 T7). +//! +//! Covers: +//! - TruthKey grammar rejections (all documented invalid patterns) +//! - Error fields (input, reason) on InvalidTruthKey +//! - TruthConvergeBinding::build with an always-missing resolver +//! - TruthCatalog::find returning None for unknown keys + +use truth_catalog::key::InvalidTruthKey; +use truth_catalog::resolve::{PackResolver, UnknownModule}; +use truth_catalog::{ + TruthCatalog, TruthConvergeBinding, TruthDefinition, TruthKey, TruthKind, TruthModuleTouch, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn assert_invalid(s: &str) -> InvalidTruthKey { + TruthKey::parse(s).expect_err(&format!("expected {s:?} to be invalid")) +} + +fn assert_valid(s: &str) -> TruthKey { + TruthKey::parse(s).expect(&format!("expected {s:?} to be valid")) +} + +// --------------------------------------------------------------------------- +// TruthKey grammar rejections +// --------------------------------------------------------------------------- + +#[test] +fn empty_string_is_rejected() { + let e = assert_invalid(""); + assert_eq!(e.input, ""); + assert!(e.reason.contains("empty"), "reason: {}", e.reason); +} + +#[test] +fn uppercase_letter_is_rejected() { + let e = assert_invalid("Qualify"); + assert_eq!(e.input, "Qualify"); + assert!(e.reason.contains("lowercase"), "reason: {}", e.reason); +} + +#[test] +fn mixed_case_is_rejected() { + let e = assert_invalid("qualify-Inbound"); + assert_eq!(e.input, "qualify-Inbound"); + assert!(e.reason.contains("lowercase"), "reason: {}", e.reason); +} + +#[test] +fn underscore_is_rejected() { + let e = assert_invalid("submit_expense"); + assert_eq!(e.input, "submit_expense"); + assert!(e.reason.contains("lowercase"), "reason: {}", e.reason); +} + +#[test] +fn space_is_rejected() { + let e = assert_invalid("qualify lead"); + assert_eq!(e.input, "qualify lead"); + assert!(e.reason.contains("lowercase"), "reason: {}", e.reason); +} + +#[test] +fn leading_hyphen_is_rejected() { + let e = assert_invalid("-lead"); + assert_eq!(e.input, "-lead"); + assert!(e.reason.contains("start"), "reason: {}", e.reason); +} + +#[test] +fn trailing_hyphen_is_rejected() { + let e = assert_invalid("lead-"); + assert_eq!(e.input, "lead-"); + assert!(e.reason.contains("end"), "reason: {}", e.reason); +} + +#[test] +fn consecutive_hyphens_are_rejected() { + let e = assert_invalid("lead--inbound"); + assert_eq!(e.input, "lead--inbound"); + assert!(e.reason.contains("consecutive"), "reason: {}", e.reason); +} + +#[test] +fn non_ascii_is_rejected() { + let e = assert_invalid("lead-über"); + assert_eq!(e.input, "lead-über"); + assert!(e.reason.contains("ASCII"), "reason: {}", e.reason); +} + +#[test] +fn period_is_rejected() { + let e = assert_invalid("qualify.lead"); + assert_eq!(e.input, "qualify.lead"); + assert!(e.reason.contains("lowercase"), "reason: {}", e.reason); +} + +#[test] +fn slash_is_rejected() { + let e = assert_invalid("qualify/lead"); + assert_eq!(e.input, "qualify/lead"); + assert!(e.reason.contains("lowercase"), "reason: {}", e.reason); +} + +#[test] +fn all_hyphens_is_rejected() { + // Would violate leading-hyphen rule first + let e = assert_invalid("---"); + assert!(e.reason.contains("start"), "reason: {}", e.reason); +} + +// --------------------------------------------------------------------------- +// Error fields are populated correctly +// --------------------------------------------------------------------------- + +#[test] +fn error_input_field_matches_rejected_string() { + let s = "Invalid-Key"; + let e = assert_invalid(s); + assert_eq!(e.input, s, "InvalidTruthKey.input must equal the rejected string"); +} + +#[test] +fn error_reason_field_is_non_empty() { + let e = assert_invalid(""); + assert!(!e.reason.is_empty(), "InvalidTruthKey.reason must not be empty"); +} + +#[test] +fn error_display_includes_input_and_reason() { + let e = assert_invalid("Bad_Key"); + let msg = e.to_string(); + assert!(msg.contains("Bad_Key"), "Display must include the rejected input; got: {msg}"); + // reason is embedded in the message via thiserror template + assert!(!msg.is_empty(), "Display must produce a non-empty message"); +} + +// --------------------------------------------------------------------------- +// Valid boundary cases (should NOT be rejected) +// --------------------------------------------------------------------------- + +#[test] +fn single_char_segment_is_valid() { + assert_valid("a"); +} + +#[test] +fn digit_only_segment_is_valid() { + assert_valid("42"); +} + +#[test] +fn segment_with_digit_at_end_is_valid() { + assert_valid("truth-v2"); +} + +// --------------------------------------------------------------------------- +// AlwaysMissingResolver — simulates a content side with no known modules +// --------------------------------------------------------------------------- + +struct AlwaysMissingResolver; + +impl PackResolver for AlwaysMissingResolver { + fn pack_ids_for( + &self, + modules: &[TruthModuleTouch], + ) -> Result, UnknownModule> { + Err(UnknownModule { + truth_key: String::new(), + module_key: modules + .first() + .map(|m| m.module_key.to_owned()) + .unwrap_or_else(|| "unknown".to_owned()), + }) + } +} + +const FIXTURE_TRUTH: TruthDefinition = TruthDefinition { + key: "approve-access-request", + display_name: "Approve access request", + kind: TruthKind::Job, + summary: "Review and approve or deny an access request.", + feature_path: "truths/jobs/approve_access_request.feature", + actor_roles: &["security-operator"], + approval_points: &["manual approval when risk is elevated"], + desired_outcomes: &["access decision is recorded"], + guardrails: &["decision must cite a policy"], + modules: &[TruthModuleTouch { + module_key: "identity", + responsibility: "verify requestor identity", + }], + gherkin: "", +}; + +#[test] +fn build_with_missing_resolver_returns_err() { + let result = TruthConvergeBinding::build(FIXTURE_TRUTH, &AlwaysMissingResolver); + assert!( + result.is_err(), + "build with AlwaysMissingResolver must return Err, not panic" + ); +} + +#[test] +fn build_error_carries_truth_key() { + let err = TruthConvergeBinding::build(FIXTURE_TRUTH, &AlwaysMissingResolver).unwrap_err(); + assert_eq!( + err.truth_key, "approve-access-request", + "build() must populate truth_key into the error" + ); +} + +#[test] +fn build_error_carries_module_key() { + let err = TruthConvergeBinding::build(FIXTURE_TRUTH, &AlwaysMissingResolver).unwrap_err(); + assert_eq!( + err.module_key, "identity", + "build() must populate module_key into the error" + ); +} + +#[test] +fn build_error_display_mentions_truth_and_module() { + let err = TruthConvergeBinding::build(FIXTURE_TRUTH, &AlwaysMissingResolver).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("approve-access-request"), + "error message must contain the truth key; got: {msg}" + ); + assert!( + msg.contains("identity"), + "error message must contain the module key; got: {msg}" + ); +} + +// --------------------------------------------------------------------------- +// TruthCatalog::find returns None for unknown key +// --------------------------------------------------------------------------- + +const CATALOG_FIXTURE: TruthCatalog<'static> = TruthCatalog::new(&[FIXTURE_TRUTH]); + +#[test] +fn catalog_find_returns_none_for_nonexistent_key() { + let key: TruthKey = "no-such-truth".parse().expect("valid key"); + assert!( + CATALOG_FIXTURE.find(&key).is_none(), + "find must return None for a key not in the catalog" + ); +} + +#[test] +fn catalog_find_returns_none_for_prefix_match() { + // "approve" is a prefix of "approve-access-request" — find must not do prefix matching + let key: TruthKey = "approve".parse().expect("valid key"); + assert!( + CATALOG_FIXTURE.find(&key).is_none(), + "find must not do prefix matching" + ); +} + +#[test] +fn catalog_find_returns_none_for_suffix_match() { + // "access-request" is a suffix — find must require exact key + let key: TruthKey = "access-request".parse().expect("valid key"); + assert!( + CATALOG_FIXTURE.find(&key).is_none(), + "find must not do suffix matching" + ); +} diff --git a/crates/truth-catalog/tests/orchestration_coverage.rs b/crates/truth-catalog/tests/orchestration_coverage.rs new file mode 100644 index 0000000..a348453 --- /dev/null +++ b/crates/truth-catalog/tests/orchestration_coverage.rs @@ -0,0 +1,179 @@ +//! Fixture-catalog coverage for `prepare_candidates` orchestration invariants. +//! +//! These tests verify the tournament-policy behavior of `prepare_candidates` +//! without depending on real CRM content or a specific formation outcome. + +use chrono::{Duration, Utc}; +use organism_pack::{IntentPacket, Reversibility}; +use truth_catalog::admission::default_helms_capabilities; +use truth_catalog::orchestration::{AutoRunError, AutoRunOptions, ExclusionReason, prepare_candidates}; + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +fn fixture_intent_reversible() -> IntentPacket { + let expires = Utc::now() + Duration::hours(1); + IntentPacket::new("qualify inbound lead", expires) +} + +fn fixture_intent_irreversible() -> IntentPacket { + let expires = Utc::now() + Duration::hours(1); + IntentPacket::new("permanently delete all records", expires) + .with_reversibility(Reversibility::Irreversible) +} + +// --------------------------------------------------------------------------- +// AutoRunOptions defaults +// --------------------------------------------------------------------------- + +#[test] +fn auto_run_options_default_is_single_shot() { + let opts = AutoRunOptions::default(); + assert!(!opts.race_alternates, "default must be single-shot (race_alternates=false)"); + assert!(opts.max_candidates >= 1, "max_candidates must be at least 1"); + assert!( + (0.0..=1.0).contains(&opts.relative_cutoff), + "relative_cutoff must be in [0.0, 1.0]" + ); +} + +// --------------------------------------------------------------------------- +// IrreversibleCannotRace guard +// --------------------------------------------------------------------------- + +#[test] +fn irreversible_intent_with_race_returns_cannot_race_error() { + let intent = fixture_intent_irreversible(); + let caps = default_helms_capabilities(); + let opts = AutoRunOptions { + race_alternates: true, + ..Default::default() + }; + let result = prepare_candidates(&intent, &caps, &opts); + assert!( + matches!(result, Err(AutoRunError::IrreversibleCannotRace)), + "expected IrreversibleCannotRace, got: {result:?}" + ); +} + +#[test] +fn irreversible_intent_single_shot_does_not_fire_cannot_race() { + // Single-shot on an irreversible intent is allowed — the guard only + // applies when racing is requested. + let intent = fixture_intent_irreversible(); + let caps = default_helms_capabilities(); + let opts = AutoRunOptions { + race_alternates: false, + ..Default::default() + }; + let result = prepare_candidates(&intent, &caps, &opts); + // Must NOT be IrreversibleCannotRace — any other outcome is acceptable. + assert!( + !matches!(result, Err(AutoRunError::IrreversibleCannotRace)), + "single-shot irreversible must not fire IrreversibleCannotRace" + ); +} + +// --------------------------------------------------------------------------- +// Single-shot invariant: all alternates go to excluded with SingleShotRequested +// --------------------------------------------------------------------------- + +#[test] +fn single_shot_excludes_all_alternates_with_correct_reason() { + let intent = fixture_intent_reversible(); + let caps = default_helms_capabilities(); + let opts = AutoRunOptions { + race_alternates: false, + ..Default::default() + }; + match prepare_candidates(&intent, &caps, &opts) { + Ok(slate) => { + // Single-shot: alternate_template_ids must be empty. + assert!( + slate.alternate_template_ids.is_empty(), + "single-shot: alternate_template_ids must be empty, got {:?}", + slate.alternate_template_ids + ); + // Every exclusion must cite SingleShotRequested. + for exc in &slate.excluded { + assert_eq!( + exc.reason, + ExclusionReason::SingleShotRequested, + "all excluded in single-shot must have SingleShotRequested reason" + ); + } + // Primary must be non-empty. + assert!( + !slate.primary_template_id.is_empty(), + "primary_template_id must not be empty" + ); + } + Err(AutoRunError::Selection(_)) => { + // No formation matched the fixture intent — acceptable; the + // IrreversibleCannotRace guard test already proves the + // happy-path guard behavior. + } + Err(e) => panic!("unexpected error from prepare_candidates: {e}"), + } +} + +// --------------------------------------------------------------------------- +// Racing invariant: max_candidates=1 keeps only the primary +// --------------------------------------------------------------------------- + +#[test] +fn racing_with_max_candidates_1_keeps_only_primary() { + let intent = fixture_intent_reversible(); + let caps = default_helms_capabilities(); + let opts = AutoRunOptions { + race_alternates: true, + max_candidates: 1, + relative_cutoff: 0.0, // accept any alternate that gets past the cap + }; + match prepare_candidates(&intent, &caps, &opts) { + Ok(slate) => { + // With max_candidates=1, only the primary fits. + assert!( + slate.alternate_template_ids.is_empty(), + "max_candidates=1: no alternates allowed, got {:?}", + slate.alternate_template_ids + ); + // Any alternates the guru returned must be BeyondMaxCandidates. + for exc in &slate.excluded { + assert!( + matches!(exc.reason, ExclusionReason::BeyondMaxCandidates | ExclusionReason::BelowRelativeCutoff), + "unexpected exclusion reason with max_candidates=1: {:?}", + exc.reason + ); + } + } + Err(AutoRunError::IrreversibleCannotRace) => { + panic!("reversible intent must not fire IrreversibleCannotRace"); + } + Err(AutoRunError::Selection(_)) => { + // No match in the formation catalog — acceptable. + } + } +} + +// --------------------------------------------------------------------------- +// ExclusionReason structural completeness +// --------------------------------------------------------------------------- + +#[test] +fn exclusion_reason_all_variants_are_copy() { + // Verify Copy (and PartialEq) work as expected — these are used in + // assertions throughout orchestration consumers. + let a = ExclusionReason::SingleShotRequested; + let b = a; + assert_eq!(a, b); + + let c = ExclusionReason::BelowRelativeCutoff; + let d = c; + assert_eq!(c, d); + + let e = ExclusionReason::BeyondMaxCandidates; + let f = e; + assert_eq!(e, f); +} diff --git a/crates/truth-catalog/tests/property_tests.rs b/crates/truth-catalog/tests/property_tests.rs new file mode 100644 index 0000000..1ee89d6 --- /dev/null +++ b/crates/truth-catalog/tests/property_tests.rs @@ -0,0 +1,209 @@ +//! Property-based tests for truth-catalog mechanism primitives (RFL-172 T7). +//! +//! These tests verify structural invariants that must hold for any +//! TruthKey and any TruthCatalog slice, regardless of content. + +use proptest::prelude::*; +use truth_catalog::{TruthCatalog, TruthDefinition, TruthKey, TruthKind, TruthModuleTouch}; + +// --------------------------------------------------------------------------- +// Fixture catalog — synthetic, independent of CRM content +// --------------------------------------------------------------------------- + +const FIXTURE_TRUTHS: &[TruthDefinition] = &[ + TruthDefinition { + key: "approve-access-request", + display_name: "Approve access request", + kind: TruthKind::Job, + summary: "Review and approve or deny an access request.", + feature_path: "truths/jobs/approve_access_request.feature", + actor_roles: &["security-operator"], + approval_points: &["manual approval when risk is elevated"], + desired_outcomes: &["access decision is recorded"], + guardrails: &["decision must cite a policy"], + modules: &[ + TruthModuleTouch { + module_key: "identity", + responsibility: "verify requestor identity", + }, + ], + gherkin: "", + }, + TruthDefinition { + key: "revoke-access", + display_name: "Revoke access", + kind: TruthKind::Job, + summary: "Revoke an existing access grant.", + feature_path: "truths/jobs/revoke_access.feature", + actor_roles: &["security-operator"], + approval_points: &[], + desired_outcomes: &["access grant is terminated"], + guardrails: &["revocation must be logged"], + modules: &[ + TruthModuleTouch { + module_key: "identity", + responsibility: "resolve the access subject", + }, + ], + gherkin: "", + }, + TruthDefinition { + key: "identity-record-is-immutable", + display_name: "Identity record is immutable", + kind: TruthKind::Policy, + summary: "Posted identity records must not be mutated.", + feature_path: "truths/policies/identity_record_is_immutable.feature", + actor_roles: &["security-operator"], + approval_points: &[], + desired_outcomes: &["identity records remain unchanged"], + guardrails: &["mutation of identity records is blocked"], + modules: &[ + TruthModuleTouch { + module_key: "identity", + responsibility: "own immutable identity history", + }, + TruthModuleTouch { + module_key: "audit", + responsibility: "preserve the change trail", + }, + ], + gherkin: "", + }, +]; + +fn fixture_catalog() -> TruthCatalog<'static> { + TruthCatalog::new(FIXTURE_TRUTHS) +} + +// --------------------------------------------------------------------------- +// TruthKey parse/format roundtrip +// --------------------------------------------------------------------------- + +proptest! { + #[test] + fn valid_truth_key_roundtrips_through_display( + // Generate a string that satisfies the grammar: segments of [a-z0-9]+ joined by single hyphens + s in "[a-z][a-z0-9]*(-[a-z0-9]+)*" + ) { + let key = TruthKey::parse(&s).expect("generated key should be valid"); + let displayed = key.to_string(); + let reparsed = TruthKey::parse(&displayed).expect("displayed key should reparse"); + prop_assert_eq!(key, reparsed, "roundtrip via Display should produce equal TruthKey"); + prop_assert_eq!(displayed, s, "Display should reproduce the original string"); + } + + #[test] + fn truth_key_as_str_equals_original( + s in "[a-z][a-z0-9]*(-[a-z0-9]+)*" + ) { + let key = TruthKey::parse(&s).expect("valid key"); + prop_assert_eq!(key.as_str(), s.as_str()); + } + + #[test] + fn truth_key_fromstr_and_parse_agree( + s in "[a-z][a-z0-9]*(-[a-z0-9]+)*" + ) { + let via_parse = TruthKey::parse(&s); + let via_fromstr: Result = s.parse(); + match (via_parse, via_fromstr) { + (Ok(a), Ok(b)) => prop_assert_eq!(a, b), + (Err(e1), Err(e2)) => prop_assert_eq!(e1.input, e2.input), + _ => prop_assert!(false, "parse and FromStr disagreed on {:?}", s), + } + } + + #[test] + fn invalid_inputs_are_rejected( + // Space, uppercase, underscore, period, slash — all invalid + s in "[A-Z _./]+[a-z]*" + ) { + // If the string contains only invalid chars or starts invalid, parse must fail. + // We check: if it starts with or contains invalidating chars, parse should reject. + let result = TruthKey::parse(&s); + // The result might be Ok if the string happens to be pure lowercase after + // filtering; we only assert when we KNOW it contains uppercase letters. + if s.chars().any(|c| c.is_ascii_uppercase()) { + prop_assert!(result.is_err(), "string with uppercase must be rejected: {s:?}"); + } + } + + // --------------------------------------------------------------------------- + // Catalog consistency properties + // --------------------------------------------------------------------------- + + #[test] + fn catalog_find_for_present_key_is_some(idx in 0usize..FIXTURE_TRUTHS.len()) { + let catalog = fixture_catalog(); + let raw_key = FIXTURE_TRUTHS[idx].key; + let key = TruthKey::parse(raw_key).expect("fixture keys are valid"); + prop_assert!(catalog.find(&key).is_some(), "find must return Some for key in catalog"); + } + + #[test] + fn catalog_find_returns_correct_definition(idx in 0usize..FIXTURE_TRUTHS.len()) { + let catalog = fixture_catalog(); + let expected = &FIXTURE_TRUTHS[idx]; + let key = TruthKey::parse(expected.key).expect("fixture keys are valid"); + let found = catalog.find(&key).expect("key must be in catalog"); + prop_assert_eq!(found.key, expected.key); + prop_assert_eq!(found.kind, expected.kind); + } + + #[test] + fn catalog_all_length_is_stable( + // Property: all() length doesn't change between calls + _unused in 0..1u8 + ) { + let catalog = fixture_catalog(); + prop_assert_eq!(catalog.all().len(), FIXTURE_TRUTHS.len()); + } + + #[test] + fn catalog_by_kind_subset_of_all(kind_idx in 0usize..3) { + let catalog = fixture_catalog(); + let kind = [TruthKind::Job, TruthKind::Policy, TruthKind::ModuleLocal][kind_idx]; + let by_kind = catalog.by_kind(kind); + let all = catalog.all(); + for t in &by_kind { + prop_assert!( + all.iter().any(|d| d.key == t.key), + "by_kind result {:?} not in all()", + t.key + ); + prop_assert_eq!(t.kind, kind, "by_kind must only return matching kind"); + } + } + + #[test] + fn catalog_for_module_subset_of_all(module_key in "[a-z]+") { + let catalog = fixture_catalog(); + let for_mod = catalog.for_module(&module_key); + let all = catalog.all(); + for t in &for_mod { + prop_assert!( + all.iter().any(|d| d.key == t.key), + "for_module result {:?} not in all()", + t.key + ); + prop_assert!( + t.modules.iter().any(|m| m.module_key == module_key.as_str()), + "for_module result {:?} does not touch module {:?}", + t.key, + module_key + ); + } + } + + #[test] + fn catalog_by_kind_and_all_counts_are_consistent( + _unused in 0..1u8 + ) { + let catalog = fixture_catalog(); + let jobs = catalog.by_kind(TruthKind::Job).len(); + let policies = catalog.by_kind(TruthKind::Policy).len(); + let module_local = catalog.by_kind(TruthKind::ModuleLocal).len(); + // Every truth must appear in exactly one kind bucket. + prop_assert_eq!(jobs + policies + module_local, catalog.all().len()); + } +} diff --git a/crates/truth-catalog/tests/soak.rs b/crates/truth-catalog/tests/soak.rs new file mode 100644 index 0000000..6cbdc70 --- /dev/null +++ b/crates/truth-catalog/tests/soak.rs @@ -0,0 +1,152 @@ +//! Soak tests for truth-catalog mechanism primitives (RFL-172 T7). +//! +//! These tests are marked `#[ignore]` so they only run when explicitly +//! requested with `-- --include-ignored`. They exercise the mechanism +//! at volume to catch latent panics or regressions. + +use truth_catalog::resolve::{PackResolver, UnknownModule}; +use truth_catalog::{TruthConvergeBinding, TruthDefinition, TruthKind, TruthModuleTouch}; + +// --------------------------------------------------------------------------- +// Fixture resolver — maps module keys used in FIXTURE_TRUTH +// --------------------------------------------------------------------------- + +struct FixtureResolver; + +impl PackResolver for FixtureResolver { + fn pack_ids_for( + &self, + modules: &[TruthModuleTouch], + ) -> Result, UnknownModule> { + let mut pack_ids = Vec::new(); + for touch in modules { + let pack_id = match touch.module_key { + "identity" => "trust", + "policies" => "prio-foundation-pack", + "audit" => "prio-foundation-pack", + "conversations" => "prio-work-pack", + "facts" => "prio-work-pack", + "opportunities" => "prio-commercial-pack", + "parties" => "prio-relationship-pack", + "intents" => "knowledge", + _ => { + return Err(UnknownModule { + truth_key: String::new(), + module_key: touch.module_key.to_owned(), + }); + } + }; + if !pack_ids.contains(&pack_id) { + pack_ids.push(pack_id); + } + } + Ok(pack_ids) + } +} + +// --------------------------------------------------------------------------- +// Fixture truth definition +// --------------------------------------------------------------------------- + +const FIXTURE_TRUTH: TruthDefinition = TruthDefinition { + key: "approve-access-request", + display_name: "Approve access request", + kind: TruthKind::Job, + summary: "Review and approve or deny an access request.", + feature_path: "truths/jobs/approve_access_request.feature", + actor_roles: &["security-operator"], + approval_points: &["manual approval when risk is elevated"], + desired_outcomes: &["access decision is recorded"], + guardrails: &["decision must cite a policy"], + modules: &[ + TruthModuleTouch { + module_key: "identity", + responsibility: "verify requestor identity", + }, + TruthModuleTouch { + module_key: "policies", + responsibility: "apply access policy", + }, + ], + gherkin: "", +}; + +// --------------------------------------------------------------------------- +// Soak: 10 000 cycles +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "soak: run with -- --include-ignored"] +fn soak_10k_cycles_build_converge_binding() { + let resolver = FixtureResolver; + let start = std::time::Instant::now(); + for _ in 0..10_000 { + let binding = + TruthConvergeBinding::build(FIXTURE_TRUTH, &resolver).expect("build must not fail"); + assert_eq!(binding.truth_key, "approve-access-request"); + assert!(!binding.pack_ids.is_empty()); + } + let elapsed = start.elapsed(); + println!("soak_10k: {:?}", elapsed); + // Loose guard: 10k builds must complete in under 10 seconds on any dev machine. + assert!( + elapsed.as_secs() < 10, + "10k builds took {:?} — unexpected regression in build() throughput", + elapsed + ); +} + +// --------------------------------------------------------------------------- +// Soak: 100 000 cycles +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "soak: run with -- --include-ignored"] +fn soak_100k_cycles_build_converge_binding() { + let resolver = FixtureResolver; + let start = std::time::Instant::now(); + for _ in 0..100_000 { + let binding = + TruthConvergeBinding::build(FIXTURE_TRUTH, &resolver).expect("build must not fail"); + assert_eq!(binding.truth_key, "approve-access-request"); + assert!(!binding.pack_ids.is_empty()); + } + let elapsed = start.elapsed(); + println!("soak_100k: {:?}", elapsed); + // Loose guard: 100k builds must complete in under 60 seconds. + assert!( + elapsed.as_secs() < 60, + "100k builds took {:?} — unexpected regression in build() throughput", + elapsed + ); +} + +// --------------------------------------------------------------------------- +// Soak: TruthKey parse/format roundtrip +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "soak: run with -- --include-ignored"] +fn soak_10k_truth_key_parse_roundtrip() { + use truth_catalog::TruthKey; + let keys = &[ + "qualify-inbound-lead", + "score-inbound-fit", + "plan-outbound-campaign", + "approve-access-request", + "revoke-access", + "identity-record-is-immutable", + "ledger-entry-is-immutable", + "active-subscription-requires-plan", + ]; + let start = std::time::Instant::now(); + for _ in 0..10_000 { + for &key in keys { + let parsed = TruthKey::parse(key).expect("all fixture keys are valid"); + assert_eq!(parsed.as_str(), key); + assert_eq!(parsed.to_string(), key); + } + } + let elapsed = start.elapsed(); + println!("soak_10k_key_roundtrip: {:?}", elapsed); +} diff --git a/crates/workbench-backend/Cargo.toml b/crates/workbench-backend/Cargo.toml index e772020..e524dba 100644 --- a/crates/workbench-backend/Cargo.toml +++ b/crates/workbench-backend/Cargo.toml @@ -21,3 +21,4 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true uuid.workspace = true +crm-truths = { version = "0.2.1", path = "../crm-truths" } diff --git a/crates/workbench-backend/src/lib.rs b/crates/workbench-backend/src/lib.rs index 530676e..5423987 100644 --- a/crates/workbench-backend/src/lib.rs +++ b/crates/workbench-backend/src/lib.rs @@ -20,11 +20,13 @@ use helm_module_contracts::operator_preview::OperatorControlPreview; use organism_domain::packs; use organism_runtime::Registry; use thiserror::Error; +use crm_truths::{ + all_truths, compile_intent_for_truth, converge_binding_for_truth, + display_pack_names_for_truth, find_truth, organism_binding_for_truth, +}; use truth_catalog::{ TruthDefinition, admission::{TruthFormationSelection, default_helms_capabilities, select_formation_for_intent}, - all_truths, converge_binding_for_truth, display_pack_names_for_truth, find_truth, - intent_compile::compile_intent_for_truth, }; use uuid::Uuid; @@ -370,7 +372,7 @@ where .ok() .map(|selection| formation_selection_view(&selection)) }); - let organism_resolution = truth_catalog::organism_binding_for_truth( + let organism_resolution = organism_binding_for_truth( truth.key, &self.organism_registry, ) @@ -449,7 +451,7 @@ where } }); let converge_resolution = - truth_catalog::converge_binding_for_truth(truth.key).map(|binding| { + converge_binding_for_truth(truth.key).map(|binding| { let intent_kind = binding.intent_kind_name().to_string(); let required_success_criteria = binding.required_success_criteria(); let hard_constraints = binding.hard_constraints(); diff --git a/kb/Architecture/Truths Layer.md b/kb/Architecture/Truths Layer.md index 6f3df1a..6ea3c53 100644 --- a/kb/Architecture/Truths Layer.md +++ b/kb/Architecture/Truths Layer.md @@ -52,17 +52,38 @@ Invariants that stay close to one capability boundary: - ledger entry is immutable - active subscription requires plan +## Mechanism / Content Split (RFL-172, Seam B) + +The truth layer is split into a mechanism crate and a content crate: + +- `crates/truth-catalog` — **mechanism**: `TruthDefinition`, `TruthCatalog`, + `TruthKey` (kebab-case newtype, parse-don't-validate), `TruthConvergeBinding`, + the `PackResolver` + `IntentOverlay` injection traits, intent compilation, + admission, and orchestration (`prepare_candidates`). Carries zero + `capability_registry` / `capability_core` imports — a trybuild compile-fail + guard (`tests/compile_fail/capability_registry_not_a_dep.rs`) pins that edge + as deleted. +- `crates/crm-truths` — **content**: the `TRUTHS` const, the `.feature` files, + the CRM evaluators, `CrmPackResolver`, `CrmIntentOverlay`, and the assembled + `CRM_CATALOG`. + +Injection flow: mounting binaries construct `TruthCatalog::new(crm_truths::TRUTHS)` +(the same value as `CRM_CATALOG`) and inject catalog + overlay into +`helm-governed-jobs::JobStreamState` (T5). The desktop path is +`apps/desktop/src-tauri` (embedded-backend) → `workbench-backend` → +`crm_truths::find_truth(key)` → `CRM_CATALOG.find(&key)`; this chain is pinned +by `crates/crm-truths/tests/catalog_mount.rs`. `apps/crm-helm/` is orphaned — +it has no cargo edge to `helm-governed-jobs`; its truth files are legacy. + ## Current Catalog -The starter catalog lives in: +The catalog lives in: -- `crates/prio-truths` +- `crates/crm-truths` (content) over `crates/truth-catalog` (mechanism) - `truths/jobs` - `truths/policies` - `truths/modules` -It is exposed through the `prio.truths.v1.TruthCatalogService` gRPC package. - Each truth now also exposes a Converge binding: - `request`: the job packet handed to Converge