1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
6
7#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
8pub struct Subject {
9 kind: String,
10 key: String,
11}
12
13impl Subject {
14 pub fn new(kind: impl Into<String>, key: impl Into<String>) -> Result<Self, ValidationError> {
15 let kind = kind.into();
16 let key = key.into();
17 if kind.trim().is_empty() {
18 return Err(ValidationError::EmptyField("subject.kind"));
19 }
20 if key.trim().is_empty() {
21 return Err(ValidationError::EmptyField("subject.key"));
22 }
23 Ok(Self { kind, key })
24 }
25
26 #[must_use]
27 pub fn kind(&self) -> &str {
28 &self.kind
29 }
30
31 #[must_use]
32 pub fn key(&self) -> &str {
33 &self.key
34 }
35}
36
37#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
38pub struct Predicate(String);
39
40impl Predicate {
41 pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
42 let value = value.into();
43 if value.trim().is_empty() {
44 return Err(ValidationError::EmptyField("predicate"));
45 }
46 Ok(Self(value))
47 }
48
49 #[must_use]
50 pub fn as_str(&self) -> &str {
51 &self.0
52 }
53}
54
55#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
76pub struct CanonicalJson(String);
77
78impl CanonicalJson {
79 pub fn new(raw: &str) -> Result<Self, ValidationError> {
85 let value: serde_json::Value = serde_json::from_str(raw)
86 .map_err(|error| ValidationError::InvalidJson(error.to_string()))?;
87 let canonical = serde_json::to_string(&value)
88 .map_err(|error| ValidationError::InvalidJson(error.to_string()))?;
89 Ok(Self(canonical))
90 }
91
92 #[must_use]
93 pub fn as_str(&self) -> &str {
94 &self.0
95 }
96}
97
98impl<'de> Deserialize<'de> for CanonicalJson {
99 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
100 let raw = String::deserialize(deserializer)?;
103 Self::new(&raw).map_err(serde::de::Error::custom)
104 }
105}
106
107#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
108pub enum FactValue {
109 Text(String),
110 Json(CanonicalJson),
111 Redacted,
112}
113
114impl FactValue {
115 pub fn json(raw: &str) -> Result<Self, ValidationError> {
117 Ok(Self::Json(CanonicalJson::new(raw)?))
118 }
119}
120
121#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
122pub struct Confidence(u16);
123
124impl Confidence {
125 pub const MAX: u16 = 1_000;
126
127 pub const ZERO: Self = Self(0);
130
131 pub const ASSERTED: Self = Self(900);
135
136 pub fn from_millis(value: u16) -> Result<Self, ValidationError> {
137 if value > Self::MAX {
138 return Err(ValidationError::ConfidenceOutOfRange(value));
139 }
140 Ok(Self(value))
141 }
142
143 #[must_use]
144 pub const fn as_millis(self) -> u16 {
145 self.0
146 }
147}
148
149impl Default for Confidence {
150 fn default() -> Self {
152 Self::ASSERTED
153 }
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
157#[serde(rename_all = "lowercase")]
158pub enum AuthorityLevel {
159 Unknown,
160 Low,
161 Medium,
162 High,
163 Canonical,
164}
165
166impl AuthorityLevel {
167 #[must_use]
172 pub const fn name(self) -> &'static str {
173 match self {
174 Self::Unknown => "unknown",
175 Self::Low => "low",
176 Self::Medium => "medium",
177 Self::High => "high",
178 Self::Canonical => "canonical",
179 }
180 }
181}
182
183impl fmt::Display for AuthorityLevel {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 f.write_str(self.name())
188 }
189}
190
191#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
192pub struct Authority {
193 pub level: AuthorityLevel,
194 pub issuer: Option<String>,
195 pub scope: Option<String>,
196}
197
198impl Authority {
199 #[must_use]
200 pub const fn unknown() -> Self {
201 Self {
202 level: AuthorityLevel::Unknown,
203 issuer: None,
204 scope: None,
205 }
206 }
207}
208
209#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
210pub enum Ttl {
211 Never,
212 ExpiresAt(TimestampMillis),
213 DurationMillis(u64),
214}
215
216impl Ttl {
217 #[must_use]
224 pub fn expires_at(&self, anchor: TimestampMillis) -> Option<TimestampMillis> {
225 match self {
226 Self::Never => None,
227 Self::ExpiresAt(at) => Some(*at),
228 Self::DurationMillis(duration) => i64::try_from(*duration)
229 .ok()
230 .and_then(|duration| anchor.as_unix_millis().checked_add(duration))
231 .map(TimestampMillis::from_unix_millis),
232 }
233 }
234
235 #[must_use]
236 pub fn is_expired_at(&self, anchor: TimestampMillis, now: TimestampMillis) -> bool {
237 self.expires_at(anchor)
238 .is_some_and(|expires_at| expires_at <= now)
239 }
240}
241
242#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
243pub struct Provenance {
244 pub source: SourceId,
245 pub actor: ActorId,
246 pub tool: Option<String>,
247 pub run_id: Option<String>,
248 pub input_digest: Option<String>,
249 pub recorded_at: TimestampMillis,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub attestation: Option<WriteAttestation>,
256}
257
258#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
264pub struct WriteAttestation {
265 pub algorithm: AttestationAlgorithm,
266 pub public_key: String,
268 pub signature: String,
270}
271
272#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
275pub enum AttestationAlgorithm {
276 #[serde(rename = "ed25519")]
277 Ed25519,
278}
279
280#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
281pub enum EvidenceKind {
282 DirectObservation,
283 ToolOutput,
284 FileSpan,
285 UserStatement,
286 DerivedSummary,
287 ExternalDocument,
288 DerivedFrom,
292}
293
294#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
295pub struct Evidence {
296 pub id: EvidenceId,
297 pub kind: EvidenceKind,
298 pub locator: String,
299 pub digest: Option<String>,
300 pub summary: Option<String>,
301}
302
303impl FactEvent {
304 #[must_use]
308 pub fn dependency_edges(&self) -> Vec<FactId> {
309 self.evidence
310 .iter()
311 .filter(|item| item.kind == EvidenceKind::DerivedFrom)
312 .filter_map(|item| FactId::new(item.locator.clone()).ok())
313 .collect()
314 }
315}
316
317#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
318pub enum ContradictionBasis {
319 SamePredicateDifferentValue,
320 MutuallyExclusivePredicate,
321 AuthorityChallenge,
322 FreshnessChallenge,
323}
324
325#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
326pub enum SupersessionReason {
327 NewerObservation,
328 HigherAuthority,
329 UserCorrection,
330 SchemaMigration,
331}
332
333#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
334pub enum ExpirationReason {
335 TtlElapsed,
336 PolicyRetention,
337}
338
339#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
340pub enum RetractionReason {
341 SourceInvalidated,
342 PoisoningDetected,
343 UserDeleted,
344 PolicyViolation,
345}
346
347#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
350pub enum ChallengeKind {
351 Supersession,
352 Contradiction,
353 Retraction,
354 Expiration,
355}
356
357#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
361pub enum ChallengeRejection {
362 InsufficientAuthority,
364 LaunderedAuthority,
367 CanonicalContradiction,
369 WeakerCorroboration,
374 WeakerEntrenchment,
378}
379
380#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
381pub enum FactEventKind {
382 Asserted,
383 Reinforced {
384 by: FactId,
385 },
386 Contradicted {
387 by: FactId,
388 basis: ContradictionBasis,
389 },
390 Superseded {
391 by: FactId,
392 reason: SupersessionReason,
393 },
394 Expired {
395 reason: ExpirationReason,
396 },
397 Retracted {
398 reason: RetractionReason,
399 },
400 Retrieved {
401 purpose: String,
402 },
403 UsedInDecision {
404 decision_id: String,
405 },
406 ChallengeRejected {
410 challenge: ChallengeKind,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
414 by: Option<FactId>,
415 rejection: ChallengeRejection,
416 },
417}
418
419impl FactEventKind {
420 #[must_use]
421 pub const fn name(&self) -> &'static str {
422 match self {
423 Self::Asserted => "fact.asserted",
424 Self::Reinforced { .. } => "fact.reinforced",
425 Self::Contradicted { .. } => "fact.contradicted",
426 Self::Superseded { .. } => "fact.superseded",
427 Self::Expired { .. } => "fact.expired",
428 Self::Retracted { .. } => "fact.retracted",
429 Self::Retrieved { .. } => "fact.retrieved",
430 Self::UsedInDecision { .. } => "fact.used_in_decision",
431 Self::ChallengeRejected { .. } => "fact.challenge_rejected",
432 }
433 }
434
435 #[must_use]
436 pub const fn is_lifecycle_terminal(&self) -> bool {
437 matches!(
438 self,
439 Self::Superseded { .. } | Self::Expired { .. } | Self::Retracted { .. }
440 )
441 }
442}
443
444#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
445pub struct FactEvent {
446 pub event_id: FactEventId,
447 pub fact_id: FactId,
448 pub kind: FactEventKind,
449 pub subject: Subject,
450 pub predicate: Predicate,
451 pub value: Option<FactValue>,
452 pub confidence: Confidence,
453 pub authority: Authority,
454 pub ttl: Ttl,
455 pub provenance: Provenance,
456 pub evidence: Vec<Evidence>,
457 pub observed_at: Option<TimestampMillis>,
458 pub valid_from: Option<TimestampMillis>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub valid_to: Option<TimestampMillis>,
467}
468
469impl FactEvent {
470 pub fn validate(&self) -> Result<(), ValidationError> {
471 if let (Some(from), Some(to)) = (self.valid_from, self.valid_to)
472 && to <= from
473 {
474 return Err(ValidationError::InvalidValidityInterval);
475 }
476 match &self.kind {
477 FactEventKind::Asserted if self.value.is_none() => {
478 Err(ValidationError::MissingFactValue)
479 }
480 FactEventKind::Asserted if self.evidence.is_empty() => {
481 Err(ValidationError::MissingEvidence)
482 }
483 FactEventKind::Retrieved { purpose } if purpose.trim().is_empty() => {
484 Err(ValidationError::EmptyField("retrieval.purpose"))
485 }
486 FactEventKind::UsedInDecision { decision_id } if decision_id.trim().is_empty() => {
487 Err(ValidationError::EmptyField("decision_id"))
488 }
489 _ => Ok(()),
490 }
491 }
492}
493
494#[derive(Clone, Debug, Eq, PartialEq)]
495pub enum ValidationError {
496 EmptyField(&'static str),
497 ConfidenceOutOfRange(u16),
498 MissingFactValue,
499 MissingEvidence,
500 InvalidJson(String),
501 InvalidValidityInterval,
503}
504
505impl fmt::Display for ValidationError {
506 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507 match self {
508 Self::EmptyField(field) => write!(f, "{field} cannot be empty"),
509 Self::ConfidenceOutOfRange(value) => {
510 write!(f, "confidence {value} is outside 0..=1000")
511 }
512 Self::MissingFactValue => f.write_str("asserted facts must include a value"),
513 Self::MissingEvidence => f.write_str("asserted facts must include evidence"),
514 Self::InvalidJson(error) => write!(f, "invalid JSON fact value: {error}"),
515 Self::InvalidValidityInterval => {
516 f.write_str("valid_to must be after valid_from (non-empty validity interval)")
517 }
518 }
519 }
520}
521
522impl std::error::Error for ValidationError {}
523
524#[cfg(test)]
525mod tests {
526 use super::{CanonicalJson, FactValue, ValidationError};
527
528 #[test]
529 fn canonical_json_sorts_keys_and_strips_whitespace() {
530 let c = CanonicalJson::new("{ \"b\": 2, \"a\": 1 }").expect("valid json");
531 assert_eq!(c.as_str(), r#"{"a":1,"b":2}"#);
532 }
533
534 #[test]
535 fn canonical_json_is_idempotent() {
536 let once = CanonicalJson::new(r#"{"b":2,"a":1}"#).expect("valid json");
537 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
538 assert_eq!(once, twice);
539 }
540
541 #[test]
542 fn float_canonicalization_is_idempotent_through_reload() {
543 for raw in [
548 r#"{"x":13e300}"#,
549 r#"{"x":17e300}"#,
550 r#"{"x":37e-300}"#,
551 r#"{"a":0.1,"b":1.0,"c":-0.5,"d":1e10}"#,
552 ] {
553 let once = CanonicalJson::new(raw).expect("valid json");
554 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
555 assert_eq!(once, twice, "not idempotent: {raw}");
556
557 let value = FactValue::Json(once.clone());
559 let bytes = serde_json::to_string(&value).expect("serialize");
560 let reloaded: FactValue = serde_json::from_str(&bytes).expect("deserialize");
561 assert_eq!(value, reloaded, "reload changed the value: {raw}");
562 }
563 }
564
565 #[test]
566 fn semantically_equal_json_is_equal_regardless_of_form() {
567 let a = FactValue::json("{ \"b\": 2, \"a\": 1 }").expect("valid json");
568 let b = FactValue::json(r#"{"a":1,"b":2}"#).expect("valid json");
569 assert_eq!(a, b);
570 }
571
572 #[test]
573 fn invalid_json_is_rejected() {
574 let error = FactValue::json("{not json").unwrap_err();
575 assert!(matches!(error, ValidationError::InvalidJson(_)));
576 }
577
578 #[test]
579 fn oversized_integers_are_lossy_but_idempotent() {
580 let raw = r#"{"n":18446744073709551616}"#;
584 let once = CanonicalJson::new(raw).expect("valid json");
585 assert_ne!(
586 once.as_str(),
587 raw,
588 "an out-of-u64-range integer is reformatted as f64"
589 );
590 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
591 assert_eq!(once, twice, "but canonicalization is idempotent thereafter");
592 }
593
594 #[test]
595 fn deserialize_recanonicalizes_a_non_canonical_stored_value() {
596 let loaded: FactValue =
599 serde_json::from_str(r#"{"Json":"{ \"b\": 2, \"a\": 1 }"}"#).expect("load");
600 assert_eq!(loaded, FactValue::json(r#"{"a":1,"b":2}"#).unwrap());
601 }
602
603 #[test]
604 fn deserialize_rejects_invalid_embedded_json() {
605 let result: Result<FactValue, _> = serde_json::from_str(r#"{"Json":"{not json"}"#);
606 assert!(result.is_err());
607 }
608}