Trusty: provenance library workspace (initial import)

This commit is contained in:
2026-09-09 18:31:00 -04:00
commit 693f3bef1e
73 changed files with 26295 additions and 0 deletions

View File

@@ -0,0 +1,334 @@
// 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<String>,
pub computed_hash: Option<String>,
pub prev_hash: Option<String>,
pub last_sequence: Option<i64>,
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:<rule>`). 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<String>,
pub hard: bool,
}
impl SourceDomain {
pub fn new(trusted_domains: Vec<String>, 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<Box<dyn Condition>> {
vec![Box::new(HashIntegrity), Box::new(SequenceContinuity)]
}
pub fn evaluate(conditions: &[Box<dyn Condition>], 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<Box<dyn Condition>> =
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)
));
}
}