// Pluggable break conditions. // // A condition evaluates the state at append time and votes Pass or // Fail(reason). First failure wins — subsequent conditions do not // run. Default set: HashIntegrity, SequenceContinuity. SourceDomain // exists but is opt-in (requires a domain list). // // Byte-identical vote outcomes to the Python reference at // trusty_core_watermark_proxy/core/trusty/conditions/defaults.py #[derive(Debug, Clone)] pub struct ConditionContext { pub entity_id: String, pub chain_id: String, pub sequence: i64, pub stored_tip_hash: Option, pub computed_hash: Option, pub prev_hash: Option, pub last_sequence: Option, pub source_url: String, pub extraction_method: String, /// The MEASURED DATA being appended (the payload). S126 gap B: this is what /// lets a condition monitor the DATA PLANE, not just chain metadata -- e.g. a /// threshold over a measured metric (`latency_ms > 500`) evaluated at append /// time, recording a break that names the variable and the observed value. /// `Value::Null` when the backend supplies no data (older callers, tests). pub data: serde_json::Value, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum BreakReason { TamperDetected, SequenceGap, SourceChange, ManualReset, SchemaMigration, ConditionEvaluatorError, /// A declared RUN RULE / invariant was violated at append time -- carries the /// rule's name so the recorded break says WHICH invariant broke, instead of /// being mislabelled `SequenceGap` (the S126 gap A). ADDITIVE and safe: BREAK /// rows are skipped in hash verification (`chain::verify_links`), so the break /// reason was never part of the hashed payload -- existing chains are /// unaffected. The rule name travels via `code()`, never via the frozen /// `as_code()`. PropertyViolated(String), } impl BreakReason { /// The FROZEN category code (`&'static str`). Unchanged for existing variants /// so any historical use is byte-stable; `PropertyViolated` reports its /// category here and its rule name via [`code`](Self::code). pub fn as_code(&self) -> &'static str { match self { BreakReason::TamperDetected => "TAMPER_DETECTED", BreakReason::SequenceGap => "SEQUENCE_GAP", BreakReason::SourceChange => "SOURCE_CHANGE", BreakReason::ManualReset => "MANUAL_RESET", BreakReason::SchemaMigration => "SCHEMA_MIGRATION", BreakReason::ConditionEvaluatorError => "CONDITION_EVALUATOR_ERROR", BreakReason::PropertyViolated(_) => "PROPERTY_VIOLATED", } } /// The recorded reason string: the category, plus the rule name for a /// `PropertyViolated` (`PROPERTY_VIOLATED:`). This is what a break row /// stores, so the answer to "which invariant broke?" travels on the chain. pub fn code(&self) -> String { match self { BreakReason::PropertyViolated(rule) => format!("PROPERTY_VIOLATED:{rule}"), other => other.as_code().to_string(), } } /// The rule name, when this break is a `PropertyViolated`. `None` otherwise. pub fn rule(&self) -> Option<&str> { match self { BreakReason::PropertyViolated(rule) => Some(rule), _ => None, } } } #[derive(Debug, Clone)] pub enum ConditionOutcome { Pass, Fail(BreakReason), } pub trait Condition: Send + Sync { fn name(&self) -> &'static str; fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome; } pub struct HashIntegrity; impl Condition for HashIntegrity { fn name(&self) -> &'static str { "hash_integrity" } fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome { let stored = &ctx.stored_tip_hash; let computed = &ctx.computed_hash; // Genesis — first ingestion for this entity. if stored.is_none() && ctx.sequence == 0 { return ConditionOutcome::Pass; } // Stored matches computed — chain intact. if let (Some(s), Some(c)) = (stored.as_ref(), computed.as_ref()) { if s == c { return ConditionOutcome::Pass; } return ConditionOutcome::Fail(BreakReason::TamperDetected); } // stored present, computed missing — cannot compare, treat as fail. if stored.is_some() && computed.is_none() { return ConditionOutcome::Fail(BreakReason::TamperDetected); } // stored=None, sequence>0 — unusual but recoverable. ConditionOutcome::Pass } } pub struct SequenceContinuity; impl Condition for SequenceContinuity { fn name(&self) -> &'static str { "sequence_continuity" } fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome { match ctx.last_sequence { None => ConditionOutcome::Pass, Some(last) => { if ctx.sequence == last + 1 { ConditionOutcome::Pass } else { ConditionOutcome::Fail(BreakReason::SequenceGap) } } } } } pub struct SourceDomain { pub trusted_domains: Vec, pub hard: bool, } impl SourceDomain { pub fn new(trusted_domains: Vec, hard: bool) -> Self { Self { trusted_domains, hard, } } } impl Condition for SourceDomain { fn name(&self) -> &'static str { "source_domain" } fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome { if ctx.source_url.is_empty() || self.trusted_domains.is_empty() { return ConditionOutcome::Pass; } let ok = self .trusted_domains .iter() .any(|d| ctx.source_url.contains(d)); if ok { ConditionOutcome::Pass } else if self.hard { ConditionOutcome::Fail(BreakReason::SourceChange) } else { ConditionOutcome::Pass } } } pub fn default_conditions() -> Vec> { vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)] } pub fn evaluate(conditions: &[Box], ctx: &ConditionContext) -> ConditionOutcome { for c in conditions { match c.evaluate(ctx) { ConditionOutcome::Pass => continue, ConditionOutcome::Fail(reason) => return ConditionOutcome::Fail(reason), } } ConditionOutcome::Pass } #[cfg(test)] mod tests { use super::*; #[test] fn property_violated_carries_the_rule_name_via_code_not_the_frozen_as_code() { let r = BreakReason::PropertyViolated("anchor-before-commit".into()); assert_eq!(r.as_code(), "PROPERTY_VIOLATED", "frozen category"); assert_eq!( r.code(), "PROPERTY_VIOLATED:anchor-before-commit", "recorded reason names the rule" ); assert_eq!(r.rule(), Some("anchor-before-commit")); // Existing variants are byte-stable: code() == as_code() for them, so no // historical break row changes meaning. assert_eq!(BreakReason::SequenceGap.code(), "SEQUENCE_GAP"); assert_eq!(BreakReason::TamperDetected.rule(), None); } fn ctx_genesis() -> ConditionContext { ConditionContext { data: serde_json::Value::Null, entity_id: "acme".into(), chain_id: "chain_acme_001".into(), sequence: 0, stored_tip_hash: None, computed_hash: None, prev_hash: None, last_sequence: None, source_url: "s".into(), extraction_method: "trusty".into(), } } #[test] fn hash_integrity_passes_on_genesis() { let c = HashIntegrity; matches!(c.evaluate(&ctx_genesis()), ConditionOutcome::Pass); } #[test] fn hash_integrity_fails_on_mismatch() { let mut ctx = ctx_genesis(); ctx.sequence = 1; ctx.stored_tip_hash = Some("abc".into()); ctx.computed_hash = Some("def".into()); ctx.last_sequence = Some(0); let out = HashIntegrity.evaluate(&ctx); assert!(matches!( out, ConditionOutcome::Fail(BreakReason::TamperDetected) )); } #[test] fn hash_integrity_passes_when_match() { let mut ctx = ctx_genesis(); ctx.sequence = 1; ctx.stored_tip_hash = Some("same".into()); ctx.computed_hash = Some("same".into()); ctx.last_sequence = Some(0); assert!(matches!( HashIntegrity.evaluate(&ctx), ConditionOutcome::Pass )); } #[test] fn sequence_continuity_passes_first_row() { assert!(matches!( SequenceContinuity.evaluate(&ctx_genesis()), ConditionOutcome::Pass )); } #[test] fn sequence_continuity_fails_on_gap() { let mut ctx = ctx_genesis(); ctx.last_sequence = Some(5); ctx.sequence = 7; let out = SequenceContinuity.evaluate(&ctx); assert!(matches!( out, ConditionOutcome::Fail(BreakReason::SequenceGap) )); } #[test] fn sequence_continuity_passes_on_next() { let mut ctx = ctx_genesis(); ctx.last_sequence = Some(5); ctx.sequence = 6; assert!(matches!( SequenceContinuity.evaluate(&ctx), ConditionOutcome::Pass )); } #[test] fn evaluate_returns_first_failure() { let conditions: Vec> = vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)]; let mut ctx = ctx_genesis(); ctx.sequence = 7; ctx.last_sequence = Some(5); ctx.stored_tip_hash = Some("abc".into()); ctx.computed_hash = Some("def".into()); // Both conditions fail — HashIntegrity runs first, wins. let out = evaluate(&conditions, &ctx); assert!(matches!( out, ConditionOutcome::Fail(BreakReason::TamperDetected) )); } #[test] fn source_domain_soft_does_not_break() { let cond = SourceDomain::new(vec!["sec.gov".into()], false); let mut ctx = ctx_genesis(); ctx.source_url = "http://evil.example/data".into(); assert!(matches!(cond.evaluate(&ctx), ConditionOutcome::Pass)); } #[test] fn source_domain_hard_breaks_on_mismatch() { let cond = SourceDomain::new(vec!["sec.gov".into()], true); let mut ctx = ctx_genesis(); ctx.source_url = "http://evil.example/data".into(); assert!(matches!( cond.evaluate(&ctx), ConditionOutcome::Fail(BreakReason::SourceChange) )); } }