use serde::{Deserialize, Serialize};
use super::anchor::CodeAnchorV1;
use super::knowledge::fact_id_ok;
use super::require_schema;
use crate::{UtcTimestamp, ValidationError};
pub const FACT_STATE_REPORT_SCHEMA: &str = "leanctx.hive.fact-state-report/v1";
pub const FACT_STATE_ACK_SCHEMA: &str = "leanctx.hive.fact-state-ack/v1";
pub const MAX_FACT_STATES: usize = super::knowledge::MAX_FACTS;
pub const MAX_STATE_REASONS: usize = 4;
pub const MAX_CONTRADICTED_BY: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactStateKind {
Current,
ToVerify,
Contradicted,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactStateReason {
AnchorChanged,
AnchorMoved,
AnchorMissing,
Contradiction,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FactStateV1 {
pub fact: String,
pub state: FactStateKind,
pub reasons: Vec<FactStateReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moved_to: Option<CodeAnchorV1>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub contradicted_by: Vec<String>,
pub since: UtcTimestamp,
}
impl FactStateV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
if !fact_id_ok(&self.fact) {
return Err(ValidationError::new("fact id is malformed"));
}
if self.reasons.is_empty()
|| self.reasons.len() > MAX_STATE_REASONS
|| self.reasons.windows(2).any(|pair| pair[0] >= pair[1])
|| self.reasons.contains(&FactStateReason::Unknown)
{
return Err(ValidationError::new(
"reasons are 1-4 known reasons, sorted without repeats",
));
}
let contradiction = self.reasons.contains(&FactStateReason::Contradiction);
match self.state {
FactStateKind::Contradicted if !contradiction => {
return Err(ValidationError::new(
"a contradicted fact has the reason contradiction",
));
}
FactStateKind::ToVerify if contradiction => {
return Err(ValidationError::new(
"a contradiction makes the fact contradicted",
));
}
FactStateKind::Current | FactStateKind::Unknown => {
return Err(ValidationError::new(
"a report carries to_verify or contradicted",
));
}
_ => {}
}
match (
self.reasons.contains(&FactStateReason::AnchorMoved),
&self.moved_to,
) {
(true, Some(anchor)) => anchor.validate()?,
(false, None) => {}
_ => {
return Err(ValidationError::new(
"moved_to comes exactly with anchor_moved",
));
}
}
if contradiction == self.contradicted_by.is_empty()
|| self.contradicted_by.len() > MAX_CONTRADICTED_BY
|| self
.contradicted_by
.iter()
.any(|id| !fact_id_ok(id) || id == &self.fact)
|| self
.contradicted_by
.windows(2)
.any(|pair| pair[0] >= pair[1])
{
return Err(ValidationError::new(
"contradicted_by lists 1-16 other facts, sorted, exactly with contradiction",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FactStateReportV1 {
pub schema: String,
pub revision: u64,
pub states: Vec<FactStateV1>,
}
impl FactStateReportV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, FACT_STATE_REPORT_SCHEMA)?;
if self.revision == 0 {
return Err(ValidationError::new("revision 0 has no facts"));
}
if self.states.len() > MAX_FACT_STATES {
return Err(ValidationError::new(format!(
"at most {MAX_FACT_STATES} states"
)));
}
for (i, state) in self.states.iter().enumerate() {
state.validate()?;
if i > 0 && self.states[i - 1].fact >= state.fact {
return Err(ValidationError::new(
"states must be sorted by fact without repeats",
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FactStateAckV1 {
pub schema: String,
pub revision: u64,
pub to_verify: u64,
pub contradicted: u64,
}
#[cfg(test)]
mod tests {
use super::*;
fn state(reasons: Vec<FactStateReason>, kind: FactStateKind) -> FactStateV1 {
FactStateV1 {
fact: "retry.budget".into(),
state: kind,
reasons,
moved_to: None,
contradicted_by: Vec::new(),
since: "2026-10-22T08:00:00Z".parse().expect("time"),
}
}
#[test]
fn states_follow_their_reasons() {
assert!(
state(
vec![FactStateReason::AnchorChanged],
FactStateKind::ToVerify
)
.validate()
.is_ok()
);
let mut contradicted = state(
vec![FactStateReason::Contradiction],
FactStateKind::Contradicted,
);
assert!(
contradicted.validate().is_err(),
"names no contradicting fact"
);
contradicted.contradicted_by = vec!["retry.limit".into()];
assert!(contradicted.validate().is_ok());
assert!(
state(
vec![FactStateReason::Contradiction],
FactStateKind::ToVerify
)
.validate()
.is_err()
);
assert!(
state(vec![FactStateReason::AnchorMoved], FactStateKind::ToVerify)
.validate()
.is_err(),
"anchor_moved needs moved_to"
);
assert!(
state(vec![FactStateReason::AnchorChanged], FactStateKind::Current)
.validate()
.is_err()
);
}
}