use std::fmt;
use serde_json::{json, Value};
use crate::esk::provenance::{HmacSigner, ProvenanceChain, SignedEntry};
use crate::flow_execution_event::now_ms;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreMutationKind {
Persist,
Mutate,
Purge,
}
impl StoreMutationKind {
pub fn as_str(self) -> &'static str {
match self {
StoreMutationKind::Persist => "persist",
StoreMutationKind::Mutate => "mutate",
StoreMutationKind::Purge => "purge",
}
}
}
impl fmt::Display for StoreMutationKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnBreachPolicy {
Log,
Raise,
Rollback,
}
impl OnBreachPolicy {
pub fn as_str(self) -> &'static str {
match self {
OnBreachPolicy::Log => "log",
OnBreachPolicy::Raise => "raise",
OnBreachPolicy::Rollback => "rollback",
}
}
}
pub fn resolve_on_breach(on_breach: &str) -> OnBreachPolicy {
match on_breach.trim().to_ascii_lowercase().as_str() {
"raise" => OnBreachPolicy::Raise,
"rollback" => OnBreachPolicy::Rollback,
_ => OnBreachPolicy::Log,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainVerdict {
Intact,
Tampered,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BreachOutcome {
Clean,
Logged { detail: String },
Raised { detail: String },
RolledBack { detail: String },
}
impl BreachOutcome {
pub fn is_halting(&self) -> bool {
matches!(self, BreachOutcome::Raised { .. } | BreachOutcome::RolledBack { .. })
}
}
pub fn apply_on_breach(
store_name: &str,
verdict: ChainVerdict,
policy: OnBreachPolicy,
) -> BreachOutcome {
if verdict == ChainVerdict::Intact {
return BreachOutcome::Clean;
}
let detail = format!(
"axonstore `{store_name}` mutation chain failed verification — \
tamper detected"
);
match policy {
OnBreachPolicy::Log => BreachOutcome::Logged { detail },
OnBreachPolicy::Raise => BreachOutcome::Raised { detail },
OnBreachPolicy::Rollback => BreachOutcome::RolledBack { detail },
}
}
pub struct StoreAuditChain {
chain: ProvenanceChain<HmacSigner>,
payloads: Vec<Value>,
}
impl StoreAuditChain {
pub fn new() -> Self {
StoreAuditChain {
chain: ProvenanceChain::new(HmacSigner::random()),
payloads: Vec::new(),
}
}
pub fn with_key(key: Vec<u8>) -> Self {
StoreAuditChain {
chain: ProvenanceChain::new(HmacSigner::new(key)),
payloads: Vec::new(),
}
}
pub fn record(
&mut self,
kind: StoreMutationKind,
store: &str,
summary: &str,
) -> SignedEntry {
let payload = json!({
"seq": self.payloads.len(),
"op": kind.as_str(),
"store": store,
"summary": summary,
"timestamp_ms": now_ms(),
});
let entry = self.chain.append(&payload);
self.payloads.push(payload);
entry
}
pub fn head(&self) -> String {
self.chain.head()
}
pub fn len(&self) -> usize {
self.payloads.len()
}
pub fn is_empty(&self) -> bool {
self.payloads.is_empty()
}
pub fn verify(&self) -> ChainVerdict {
if self.chain.verify(&self.payloads) {
ChainVerdict::Intact
} else {
ChainVerdict::Tampered
}
}
pub fn payloads(&self) -> &[Value] {
&self.payloads
}
pub fn audit(&self, store_name: &str, policy: OnBreachPolicy) -> BreachOutcome {
apply_on_breach(store_name, self.verify(), policy)
}
}
impl Default for StoreAuditChain {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for StoreAuditChain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StoreAuditChain")
.field("deltas", &self.payloads.len())
.field("head", &self.head())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mutation_kinds_render_canonically() {
assert_eq!(StoreMutationKind::Persist.as_str(), "persist");
assert_eq!(StoreMutationKind::Mutate.as_str(), "mutate");
assert_eq!(StoreMutationKind::Purge.as_str(), "purge");
}
#[test]
fn on_breach_resolves_the_closed_catalog() {
assert_eq!(resolve_on_breach("log"), OnBreachPolicy::Log);
assert_eq!(resolve_on_breach("raise"), OnBreachPolicy::Raise);
assert_eq!(resolve_on_breach("rollback"), OnBreachPolicy::Rollback);
}
#[test]
fn on_breach_is_trimmed_and_case_insensitive() {
assert_eq!(resolve_on_breach(" RAISE "), OnBreachPolicy::Raise);
assert_eq!(resolve_on_breach("Rollback"), OnBreachPolicy::Rollback);
}
#[test]
fn on_breach_empty_defaults_to_log() {
assert_eq!(resolve_on_breach(""), OnBreachPolicy::Log);
assert_eq!(resolve_on_breach(" "), OnBreachPolicy::Log);
}
#[test]
fn empty_chain_head_is_genesis() {
let chain = StoreAuditChain::with_key(vec![1, 2, 3, 4]);
assert!(chain.is_empty());
assert_eq!(chain.len(), 0);
assert_eq!(chain.head(), crate::esk::provenance::GENESIS_HASH);
}
#[test]
fn recording_a_mutation_advances_the_chain() {
let mut chain = StoreAuditChain::with_key(vec![9; 32]);
let genesis = chain.head();
chain.record(StoreMutationKind::Persist, "tenants", "1 row");
assert_eq!(chain.len(), 1);
assert_ne!(chain.head(), genesis, "the head must advance on append");
}
#[test]
fn each_delta_links_to_the_previous() {
let mut chain = StoreAuditChain::with_key(vec![7; 32]);
let e0 = chain.record(StoreMutationKind::Persist, "s", "a");
let e1 = chain.record(StoreMutationKind::Mutate, "s", "b");
let e2 = chain.record(StoreMutationKind::Purge, "s", "c");
assert_eq!(e1.previous_hash, e0.chain_hash);
assert_eq!(e2.previous_hash, e1.chain_hash);
assert_eq!(e0.index, 0);
assert_eq!(e2.index, 2);
}
#[test]
fn an_untampered_chain_verifies_intact() {
let mut chain = StoreAuditChain::with_key(vec![3; 32]);
chain.record(StoreMutationKind::Persist, "ledger", "100");
chain.record(StoreMutationKind::Mutate, "ledger", "200");
chain.record(StoreMutationKind::Purge, "ledger", "0");
assert_eq!(chain.verify(), ChainVerdict::Intact);
}
#[test]
fn an_empty_chain_verifies_intact() {
let chain = StoreAuditChain::with_key(vec![1; 32]);
assert_eq!(chain.verify(), ChainVerdict::Intact);
}
#[test]
fn a_tampered_delta_breaks_verification() {
let mut chain = StoreAuditChain::with_key(vec![5; 32]);
chain.record(StoreMutationKind::Persist, "ledger", "100");
chain.record(StoreMutationKind::Mutate, "ledger", "200");
chain.record(StoreMutationKind::Purge, "ledger", "0");
chain.payloads[1]["summary"] = json!("999999");
assert_eq!(chain.verify(), ChainVerdict::Tampered);
}
#[test]
fn tampering_with_the_operation_is_detected() {
let mut chain = StoreAuditChain::with_key(vec![6; 32]);
chain.record(StoreMutationKind::Purge, "ledger", "drop");
chain.payloads[0]["op"] = json!("persist");
assert_eq!(chain.verify(), ChainVerdict::Tampered);
}
#[test]
fn intact_chain_yields_a_clean_outcome_for_every_policy() {
for policy in [
OnBreachPolicy::Log,
OnBreachPolicy::Raise,
OnBreachPolicy::Rollback,
] {
assert_eq!(
apply_on_breach("s", ChainVerdict::Intact, policy),
BreachOutcome::Clean
);
}
}
#[test]
fn tampered_chain_fires_the_declared_policy() {
assert!(matches!(
apply_on_breach("s", ChainVerdict::Tampered, OnBreachPolicy::Log),
BreachOutcome::Logged { .. }
));
assert!(matches!(
apply_on_breach("s", ChainVerdict::Tampered, OnBreachPolicy::Raise),
BreachOutcome::Raised { .. }
));
assert!(matches!(
apply_on_breach("s", ChainVerdict::Tampered, OnBreachPolicy::Rollback),
BreachOutcome::RolledBack { .. }
));
}
#[test]
fn only_raise_and_rollback_are_halting() {
assert!(!BreachOutcome::Clean.is_halting());
assert!(!BreachOutcome::Logged { detail: "x".into() }.is_halting());
assert!(BreachOutcome::Raised { detail: "x".into() }.is_halting());
assert!(BreachOutcome::RolledBack { detail: "x".into() }.is_halting());
}
#[test]
fn audit_combines_verify_and_policy() {
let mut chain = StoreAuditChain::with_key(vec![8; 32]);
chain.record(StoreMutationKind::Persist, "s", "ok");
assert_eq!(
chain.audit("s", OnBreachPolicy::Raise),
BreachOutcome::Clean
);
chain.payloads[0]["summary"] = json!("forged");
assert!(chain.audit("s", OnBreachPolicy::Raise).is_halting());
}
#[test]
fn debug_does_not_leak_delta_payloads() {
let mut chain = StoreAuditChain::with_key(vec![2; 32]);
chain.record(StoreMutationKind::Persist, "secret_store", "sensitive");
let debug = format!("{chain:?}");
assert!(!debug.contains("sensitive"));
assert!(debug.contains("deltas"));
}
}