1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::ids::{ActorId, ClaimEventId, ClaimId, EvidenceId, SourceId, TimestampMillis};
6
7#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
8pub struct EntityRef {
9 kind: String,
10 key: String,
11}
12
13impl EntityRef {
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("entity.kind"));
19 }
20 if key.trim().is_empty() {
21 return Err(ValidationError::EmptyField("entity.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 ClaimValue {
109 Text(String),
110 Json(CanonicalJson),
111 Redacted,
112}
113
114impl ClaimValue {
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 fn from_millis(value: u16) -> Result<Self, ValidationError> {
132 if value > Self::MAX {
133 return Err(ValidationError::ConfidenceOutOfRange(value));
134 }
135 Ok(Self(value))
136 }
137
138 #[must_use]
139 pub const fn as_millis(self) -> u16 {
140 self.0
141 }
142}
143
144#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
145pub enum AuthorityLevel {
146 Unknown,
147 Low,
148 Medium,
149 High,
150 Canonical,
151}
152
153impl AuthorityLevel {
154 #[must_use]
159 pub const fn name(self) -> &'static str {
160 match self {
161 Self::Unknown => "Unknown",
162 Self::Low => "Low",
163 Self::Medium => "Medium",
164 Self::High => "High",
165 Self::Canonical => "Canonical",
166 }
167 }
168}
169
170#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
171pub struct Authority {
172 pub level: AuthorityLevel,
173 pub issuer: Option<String>,
174 pub scope: Option<String>,
175}
176
177impl Authority {
178 #[must_use]
179 pub const fn unknown() -> Self {
180 Self {
181 level: AuthorityLevel::Unknown,
182 issuer: None,
183 scope: None,
184 }
185 }
186}
187
188#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
189pub enum Ttl {
190 Never,
191 ExpiresAt(TimestampMillis),
192 DurationMillis(u64),
193}
194
195impl Ttl {
196 #[must_use]
203 pub fn expires_at(&self, anchor: TimestampMillis) -> Option<TimestampMillis> {
204 match self {
205 Self::Never => None,
206 Self::ExpiresAt(at) => Some(*at),
207 Self::DurationMillis(duration) => i64::try_from(*duration)
208 .ok()
209 .and_then(|duration| anchor.as_unix_millis().checked_add(duration))
210 .map(TimestampMillis::from_unix_millis),
211 }
212 }
213
214 #[must_use]
215 pub fn is_expired_at(&self, anchor: TimestampMillis, now: TimestampMillis) -> bool {
216 self.expires_at(anchor)
217 .is_some_and(|expires_at| expires_at <= now)
218 }
219}
220
221#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
222pub struct Provenance {
223 pub source: SourceId,
224 pub actor: ActorId,
225 pub tool: Option<String>,
226 pub run_id: Option<String>,
227 pub input_digest: Option<String>,
228 pub recorded_at: TimestampMillis,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub attestation: Option<WriteAttestation>,
235}
236
237#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
243pub struct WriteAttestation {
244 pub algorithm: AttestationAlgorithm,
245 pub public_key: String,
247 pub signature: String,
249}
250
251#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
254pub enum AttestationAlgorithm {
255 #[serde(rename = "ed25519")]
256 Ed25519,
257}
258
259#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
260pub enum EvidenceKind {
261 DirectObservation,
262 ToolOutput,
263 FileSpan,
264 UserStatement,
265 DerivedSummary,
266 ExternalDocument,
267 DerivedFrom,
271}
272
273#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
274pub struct Evidence {
275 pub id: EvidenceId,
276 pub kind: EvidenceKind,
277 pub locator: String,
278 pub digest: Option<String>,
279 pub summary: Option<String>,
280}
281
282impl ClaimEvent {
283 #[must_use]
287 pub fn dependency_edges(&self) -> Vec<ClaimId> {
288 self.evidence
289 .iter()
290 .filter(|item| item.kind == EvidenceKind::DerivedFrom)
291 .filter_map(|item| ClaimId::new(item.locator.clone()).ok())
292 .collect()
293 }
294}
295
296#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
297pub enum ContradictionBasis {
298 SamePredicateDifferentValue,
299 MutuallyExclusivePredicate,
300 AuthorityChallenge,
301 FreshnessChallenge,
302}
303
304#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
305pub enum SupersessionReason {
306 NewerObservation,
307 HigherAuthority,
308 UserCorrection,
309 SchemaMigration,
310}
311
312#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
313pub enum ExpirationReason {
314 TtlElapsed,
315 PolicyRetention,
316}
317
318#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
319pub enum RetractionReason {
320 SourceInvalidated,
321 PoisoningDetected,
322 UserDeleted,
323 PolicyViolation,
324}
325
326#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
329pub enum ChallengeKind {
330 Supersession,
331 Contradiction,
332 Retraction,
333 Expiration,
334}
335
336#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
340pub enum ChallengeRejection {
341 InsufficientAuthority,
343 LaunderedAuthority,
346 CanonicalContradiction,
348 WeakerCorroboration,
351}
352
353#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
354pub enum ClaimEventKind {
355 Asserted,
356 Reinforced {
357 by: ClaimId,
358 },
359 Contradicted {
360 by: ClaimId,
361 basis: ContradictionBasis,
362 },
363 Superseded {
364 by: ClaimId,
365 reason: SupersessionReason,
366 },
367 Expired {
368 reason: ExpirationReason,
369 },
370 Retracted {
371 reason: RetractionReason,
372 },
373 Retrieved {
374 purpose: String,
375 },
376 UsedInDecision {
377 decision_id: String,
378 },
379 ChallengeRejected {
383 challenge: ChallengeKind,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
387 by: Option<ClaimId>,
388 rejection: ChallengeRejection,
389 },
390}
391
392impl ClaimEventKind {
393 #[must_use]
394 pub const fn name(&self) -> &'static str {
395 match self {
396 Self::Asserted => "claim.asserted",
397 Self::Reinforced { .. } => "claim.reinforced",
398 Self::Contradicted { .. } => "claim.contradicted",
399 Self::Superseded { .. } => "claim.superseded",
400 Self::Expired { .. } => "claim.expired",
401 Self::Retracted { .. } => "claim.retracted",
402 Self::Retrieved { .. } => "claim.retrieved",
403 Self::UsedInDecision { .. } => "claim.used_in_decision",
404 Self::ChallengeRejected { .. } => "claim.challenge_rejected",
405 }
406 }
407
408 #[must_use]
409 pub const fn is_lifecycle_terminal(&self) -> bool {
410 matches!(
411 self,
412 Self::Superseded { .. } | Self::Expired { .. } | Self::Retracted { .. }
413 )
414 }
415}
416
417#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
418pub struct ClaimEvent {
419 pub event_id: ClaimEventId,
420 pub claim_id: ClaimId,
421 pub kind: ClaimEventKind,
422 pub subject: EntityRef,
423 pub predicate: Predicate,
424 pub value: Option<ClaimValue>,
425 pub confidence: Confidence,
426 pub authority: Authority,
427 pub ttl: Ttl,
428 pub provenance: Provenance,
429 pub evidence: Vec<Evidence>,
430 pub observed_at: Option<TimestampMillis>,
431 pub valid_from: Option<TimestampMillis>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub valid_to: Option<TimestampMillis>,
440}
441
442impl ClaimEvent {
443 pub fn validate(&self) -> Result<(), ValidationError> {
444 if let (Some(from), Some(to)) = (self.valid_from, self.valid_to)
445 && to <= from
446 {
447 return Err(ValidationError::InvalidValidityInterval);
448 }
449 match &self.kind {
450 ClaimEventKind::Asserted if self.value.is_none() => {
451 Err(ValidationError::MissingClaimValue)
452 }
453 ClaimEventKind::Asserted if self.evidence.is_empty() => {
454 Err(ValidationError::MissingEvidence)
455 }
456 ClaimEventKind::Retrieved { purpose } if purpose.trim().is_empty() => {
457 Err(ValidationError::EmptyField("retrieval.purpose"))
458 }
459 ClaimEventKind::UsedInDecision { decision_id } if decision_id.trim().is_empty() => {
460 Err(ValidationError::EmptyField("decision_id"))
461 }
462 _ => Ok(()),
463 }
464 }
465}
466
467#[derive(Clone, Debug, Eq, PartialEq)]
468pub enum ValidationError {
469 EmptyField(&'static str),
470 ConfidenceOutOfRange(u16),
471 MissingClaimValue,
472 MissingEvidence,
473 InvalidJson(String),
474 InvalidValidityInterval,
476}
477
478impl fmt::Display for ValidationError {
479 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
480 match self {
481 Self::EmptyField(field) => write!(f, "{field} cannot be empty"),
482 Self::ConfidenceOutOfRange(value) => {
483 write!(f, "confidence {value} is outside 0..=1000")
484 }
485 Self::MissingClaimValue => f.write_str("asserted claims must include a value"),
486 Self::MissingEvidence => f.write_str("asserted claims must include evidence"),
487 Self::InvalidJson(error) => write!(f, "invalid JSON claim value: {error}"),
488 Self::InvalidValidityInterval => {
489 f.write_str("valid_to must be after valid_from (non-empty validity interval)")
490 }
491 }
492 }
493}
494
495impl std::error::Error for ValidationError {}
496
497#[cfg(test)]
498mod tests {
499 use super::{CanonicalJson, ClaimValue, ValidationError};
500
501 #[test]
502 fn canonical_json_sorts_keys_and_strips_whitespace() {
503 let c = CanonicalJson::new("{ \"b\": 2, \"a\": 1 }").expect("valid json");
504 assert_eq!(c.as_str(), r#"{"a":1,"b":2}"#);
505 }
506
507 #[test]
508 fn canonical_json_is_idempotent() {
509 let once = CanonicalJson::new(r#"{"b":2,"a":1}"#).expect("valid json");
510 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
511 assert_eq!(once, twice);
512 }
513
514 #[test]
515 fn float_canonicalization_is_idempotent_through_reload() {
516 for raw in [
521 r#"{"x":13e300}"#,
522 r#"{"x":17e300}"#,
523 r#"{"x":37e-300}"#,
524 r#"{"a":0.1,"b":1.0,"c":-0.5,"d":1e10}"#,
525 ] {
526 let once = CanonicalJson::new(raw).expect("valid json");
527 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
528 assert_eq!(once, twice, "not idempotent: {raw}");
529
530 let value = ClaimValue::Json(once.clone());
532 let bytes = serde_json::to_string(&value).expect("serialize");
533 let reloaded: ClaimValue = serde_json::from_str(&bytes).expect("deserialize");
534 assert_eq!(value, reloaded, "reload changed the value: {raw}");
535 }
536 }
537
538 #[test]
539 fn semantically_equal_json_is_equal_regardless_of_form() {
540 let a = ClaimValue::json("{ \"b\": 2, \"a\": 1 }").expect("valid json");
541 let b = ClaimValue::json(r#"{"a":1,"b":2}"#).expect("valid json");
542 assert_eq!(a, b);
543 }
544
545 #[test]
546 fn invalid_json_is_rejected() {
547 let error = ClaimValue::json("{not json").unwrap_err();
548 assert!(matches!(error, ValidationError::InvalidJson(_)));
549 }
550
551 #[test]
552 fn oversized_integers_are_lossy_but_idempotent() {
553 let raw = r#"{"n":18446744073709551616}"#;
557 let once = CanonicalJson::new(raw).expect("valid json");
558 assert_ne!(
559 once.as_str(),
560 raw,
561 "an out-of-u64-range integer is reformatted as f64"
562 );
563 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
564 assert_eq!(once, twice, "but canonicalization is idempotent thereafter");
565 }
566
567 #[test]
568 fn deserialize_recanonicalizes_a_non_canonical_stored_value() {
569 let loaded: ClaimValue =
572 serde_json::from_str(r#"{"Json":"{ \"b\": 2, \"a\": 1 }"}"#).expect("load");
573 assert_eq!(loaded, ClaimValue::json(r#"{"a":1,"b":2}"#).unwrap());
574 }
575
576 #[test]
577 fn deserialize_rejects_invalid_embedded_json() {
578 let result: Result<ClaimValue, _> = serde_json::from_str(r#"{"Json":"{not json"}"#);
579 assert!(result.is_err());
580 }
581}