use std::fmt;
use serde::{Deserialize, Serialize};
use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct Subject {
kind: String,
key: String,
}
impl Subject {
pub fn new(kind: impl Into<String>, key: impl Into<String>) -> Result<Self, ValidationError> {
let kind = kind.into();
let key = key.into();
if kind.trim().is_empty() {
return Err(ValidationError::EmptyField("subject.kind"));
}
if key.trim().is_empty() {
return Err(ValidationError::EmptyField("subject.key"));
}
Ok(Self { kind, key })
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct Predicate(String);
impl Predicate {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into();
if value.trim().is_empty() {
return Err(ValidationError::EmptyField("predicate"));
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct CanonicalJson(String);
impl CanonicalJson {
pub fn new(raw: &str) -> Result<Self, ValidationError> {
let value: serde_json::Value = serde_json::from_str(raw)
.map_err(|error| ValidationError::InvalidJson(error.to_string()))?;
let canonical = serde_json::to_string(&value)
.map_err(|error| ValidationError::InvalidJson(error.to_string()))?;
Ok(Self(canonical))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for CanonicalJson {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::new(&raw).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum FactValue {
Text(String),
Json(CanonicalJson),
Redacted,
}
impl FactValue {
pub fn json(raw: &str) -> Result<Self, ValidationError> {
Ok(Self::Json(CanonicalJson::new(raw)?))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
pub struct Confidence(u16);
impl Confidence {
pub const MAX: u16 = 1_000;
pub const ZERO: Self = Self(0);
pub const ASSERTED: Self = Self(900);
pub fn from_millis(value: u16) -> Result<Self, ValidationError> {
if value > Self::MAX {
return Err(ValidationError::ConfidenceOutOfRange(value));
}
Ok(Self(value))
}
#[must_use]
pub const fn as_millis(self) -> u16 {
self.0
}
}
impl Default for Confidence {
fn default() -> Self {
Self::ASSERTED
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthorityLevel {
Unknown,
Low,
Medium,
High,
Canonical,
}
impl AuthorityLevel {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Canonical => "canonical",
}
}
}
impl fmt::Display for AuthorityLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Authority {
pub level: AuthorityLevel,
pub issuer: Option<String>,
pub scope: Option<String>,
}
impl Authority {
#[must_use]
pub const fn unknown() -> Self {
Self {
level: AuthorityLevel::Unknown,
issuer: None,
scope: None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum Ttl {
Never,
ExpiresAt(TimestampMillis),
DurationMillis(u64),
}
impl Ttl {
#[must_use]
pub fn expires_at(&self, anchor: TimestampMillis) -> Option<TimestampMillis> {
match self {
Self::Never => None,
Self::ExpiresAt(at) => Some(*at),
Self::DurationMillis(duration) => i64::try_from(*duration)
.ok()
.and_then(|duration| anchor.as_unix_millis().checked_add(duration))
.map(TimestampMillis::from_unix_millis),
}
}
#[must_use]
pub fn is_expired_at(&self, anchor: TimestampMillis, now: TimestampMillis) -> bool {
self.expires_at(anchor)
.is_some_and(|expires_at| expires_at <= now)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Provenance {
pub source: SourceId,
pub actor: ActorId,
pub tool: Option<String>,
pub run_id: Option<String>,
pub input_digest: Option<String>,
pub recorded_at: TimestampMillis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestation: Option<WriteAttestation>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct WriteAttestation {
pub algorithm: AttestationAlgorithm,
pub public_key: String,
pub signature: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum AttestationAlgorithm {
#[serde(rename = "ed25519")]
Ed25519,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum EvidenceKind {
DirectObservation,
ToolOutput,
FileSpan,
UserStatement,
DerivedSummary,
ExternalDocument,
DerivedFrom,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Evidence {
pub id: EvidenceId,
pub kind: EvidenceKind,
pub locator: String,
pub digest: Option<String>,
pub summary: Option<String>,
}
impl FactEvent {
#[must_use]
pub fn dependency_edges(&self) -> Vec<FactId> {
self.evidence
.iter()
.filter(|item| item.kind == EvidenceKind::DerivedFrom)
.filter_map(|item| FactId::new(item.locator.clone()).ok())
.collect()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ContradictionBasis {
SamePredicateDifferentValue,
MutuallyExclusivePredicate,
AuthorityChallenge,
FreshnessChallenge,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum SupersessionReason {
NewerObservation,
HigherAuthority,
UserCorrection,
SchemaMigration,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ExpirationReason {
TtlElapsed,
PolicyRetention,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum RetractionReason {
SourceInvalidated,
PoisoningDetected,
UserDeleted,
PolicyViolation,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ChallengeKind {
Supersession,
Contradiction,
Retraction,
Expiration,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ChallengeRejection {
InsufficientAuthority,
LaunderedAuthority,
CanonicalContradiction,
WeakerCorroboration,
WeakerEntrenchment,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum FactEventKind {
Asserted,
Reinforced {
by: FactId,
},
Contradicted {
by: FactId,
basis: ContradictionBasis,
},
Superseded {
by: FactId,
reason: SupersessionReason,
},
Expired {
reason: ExpirationReason,
},
Retracted {
reason: RetractionReason,
},
Retrieved {
purpose: String,
},
UsedInDecision {
decision_id: String,
},
ChallengeRejected {
challenge: ChallengeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
by: Option<FactId>,
rejection: ChallengeRejection,
},
}
impl FactEventKind {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::Asserted => "fact.asserted",
Self::Reinforced { .. } => "fact.reinforced",
Self::Contradicted { .. } => "fact.contradicted",
Self::Superseded { .. } => "fact.superseded",
Self::Expired { .. } => "fact.expired",
Self::Retracted { .. } => "fact.retracted",
Self::Retrieved { .. } => "fact.retrieved",
Self::UsedInDecision { .. } => "fact.used_in_decision",
Self::ChallengeRejected { .. } => "fact.challenge_rejected",
}
}
#[must_use]
pub const fn is_lifecycle_terminal(&self) -> bool {
matches!(
self,
Self::Superseded { .. } | Self::Expired { .. } | Self::Retracted { .. }
)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct FactEvent {
pub event_id: FactEventId,
pub fact_id: FactId,
pub kind: FactEventKind,
pub subject: Subject,
pub predicate: Predicate,
pub value: Option<FactValue>,
pub confidence: Confidence,
pub authority: Authority,
pub ttl: Ttl,
pub provenance: Provenance,
pub evidence: Vec<Evidence>,
pub observed_at: Option<TimestampMillis>,
pub valid_from: Option<TimestampMillis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_to: Option<TimestampMillis>,
}
impl FactEvent {
pub fn validate(&self) -> Result<(), ValidationError> {
if let (Some(from), Some(to)) = (self.valid_from, self.valid_to)
&& to <= from
{
return Err(ValidationError::InvalidValidityInterval);
}
match &self.kind {
FactEventKind::Asserted if self.value.is_none() => {
Err(ValidationError::MissingFactValue)
}
FactEventKind::Asserted if self.evidence.is_empty() => {
Err(ValidationError::MissingEvidence)
}
FactEventKind::Retrieved { purpose } if purpose.trim().is_empty() => {
Err(ValidationError::EmptyField("retrieval.purpose"))
}
FactEventKind::UsedInDecision { decision_id } if decision_id.trim().is_empty() => {
Err(ValidationError::EmptyField("decision_id"))
}
_ => Ok(()),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ValidationError {
EmptyField(&'static str),
ConfidenceOutOfRange(u16),
MissingFactValue,
MissingEvidence,
InvalidJson(String),
InvalidValidityInterval,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyField(field) => write!(f, "{field} cannot be empty"),
Self::ConfidenceOutOfRange(value) => {
write!(f, "confidence {value} is outside 0..=1000")
}
Self::MissingFactValue => f.write_str("asserted facts must include a value"),
Self::MissingEvidence => f.write_str("asserted facts must include evidence"),
Self::InvalidJson(error) => write!(f, "invalid JSON fact value: {error}"),
Self::InvalidValidityInterval => {
f.write_str("valid_to must be after valid_from (non-empty validity interval)")
}
}
}
}
impl std::error::Error for ValidationError {}
#[cfg(test)]
mod tests {
use super::{CanonicalJson, FactValue, ValidationError};
#[test]
fn canonical_json_sorts_keys_and_strips_whitespace() {
let c = CanonicalJson::new("{ \"b\": 2, \"a\": 1 }").expect("valid json");
assert_eq!(c.as_str(), r#"{"a":1,"b":2}"#);
}
#[test]
fn canonical_json_is_idempotent() {
let once = CanonicalJson::new(r#"{"b":2,"a":1}"#).expect("valid json");
let twice = CanonicalJson::new(once.as_str()).expect("valid json");
assert_eq!(once, twice);
}
#[test]
fn float_canonicalization_is_idempotent_through_reload() {
for raw in [
r#"{"x":13e300}"#,
r#"{"x":17e300}"#,
r#"{"x":37e-300}"#,
r#"{"a":0.1,"b":1.0,"c":-0.5,"d":1e10}"#,
] {
let once = CanonicalJson::new(raw).expect("valid json");
let twice = CanonicalJson::new(once.as_str()).expect("valid json");
assert_eq!(once, twice, "not idempotent: {raw}");
let value = FactValue::Json(once.clone());
let bytes = serde_json::to_string(&value).expect("serialize");
let reloaded: FactValue = serde_json::from_str(&bytes).expect("deserialize");
assert_eq!(value, reloaded, "reload changed the value: {raw}");
}
}
#[test]
fn semantically_equal_json_is_equal_regardless_of_form() {
let a = FactValue::json("{ \"b\": 2, \"a\": 1 }").expect("valid json");
let b = FactValue::json(r#"{"a":1,"b":2}"#).expect("valid json");
assert_eq!(a, b);
}
#[test]
fn invalid_json_is_rejected() {
let error = FactValue::json("{not json").unwrap_err();
assert!(matches!(error, ValidationError::InvalidJson(_)));
}
#[test]
fn oversized_integers_are_lossy_but_idempotent() {
let raw = r#"{"n":18446744073709551616}"#;
let once = CanonicalJson::new(raw).expect("valid json");
assert_ne!(
once.as_str(),
raw,
"an out-of-u64-range integer is reformatted as f64"
);
let twice = CanonicalJson::new(once.as_str()).expect("valid json");
assert_eq!(once, twice, "but canonicalization is idempotent thereafter");
}
#[test]
fn deserialize_recanonicalizes_a_non_canonical_stored_value() {
let loaded: FactValue =
serde_json::from_str(r#"{"Json":"{ \"b\": 2, \"a\": 1 }"}"#).expect("load");
assert_eq!(loaded, FactValue::json(r#"{"a":1,"b":2}"#).unwrap());
}
#[test]
fn deserialize_rejects_invalid_embedded_json() {
let result: Result<FactValue, _> = serde_json::from_str(r#"{"Json":"{not json"}"#);
assert!(result.is_err());
}
}