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, Debug, Eq, PartialEq, Serialize, Deserialize)]
327pub enum ClaimEventKind {
328 Asserted,
329 Reinforced {
330 by: ClaimId,
331 },
332 Contradicted {
333 by: ClaimId,
334 basis: ContradictionBasis,
335 },
336 Superseded {
337 by: ClaimId,
338 reason: SupersessionReason,
339 },
340 Expired {
341 reason: ExpirationReason,
342 },
343 Retracted {
344 reason: RetractionReason,
345 },
346 Retrieved {
347 purpose: String,
348 },
349 UsedInDecision {
350 decision_id: String,
351 },
352}
353
354impl ClaimEventKind {
355 #[must_use]
356 pub const fn name(&self) -> &'static str {
357 match self {
358 Self::Asserted => "claim.asserted",
359 Self::Reinforced { .. } => "claim.reinforced",
360 Self::Contradicted { .. } => "claim.contradicted",
361 Self::Superseded { .. } => "claim.superseded",
362 Self::Expired { .. } => "claim.expired",
363 Self::Retracted { .. } => "claim.retracted",
364 Self::Retrieved { .. } => "claim.retrieved",
365 Self::UsedInDecision { .. } => "claim.used_in_decision",
366 }
367 }
368
369 #[must_use]
370 pub const fn is_lifecycle_terminal(&self) -> bool {
371 matches!(
372 self,
373 Self::Superseded { .. } | Self::Expired { .. } | Self::Retracted { .. }
374 )
375 }
376}
377
378#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
379pub struct ClaimEvent {
380 pub event_id: ClaimEventId,
381 pub claim_id: ClaimId,
382 pub kind: ClaimEventKind,
383 pub subject: EntityRef,
384 pub predicate: Predicate,
385 pub value: Option<ClaimValue>,
386 pub confidence: Confidence,
387 pub authority: Authority,
388 pub ttl: Ttl,
389 pub provenance: Provenance,
390 pub evidence: Vec<Evidence>,
391 pub observed_at: Option<TimestampMillis>,
392 pub valid_from: Option<TimestampMillis>,
393}
394
395impl ClaimEvent {
396 pub fn validate(&self) -> Result<(), ValidationError> {
397 match &self.kind {
398 ClaimEventKind::Asserted if self.value.is_none() => {
399 Err(ValidationError::MissingClaimValue)
400 }
401 ClaimEventKind::Asserted if self.evidence.is_empty() => {
402 Err(ValidationError::MissingEvidence)
403 }
404 ClaimEventKind::Retrieved { purpose } if purpose.trim().is_empty() => {
405 Err(ValidationError::EmptyField("retrieval.purpose"))
406 }
407 ClaimEventKind::UsedInDecision { decision_id } if decision_id.trim().is_empty() => {
408 Err(ValidationError::EmptyField("decision_id"))
409 }
410 _ => Ok(()),
411 }
412 }
413}
414
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub enum ValidationError {
417 EmptyField(&'static str),
418 ConfidenceOutOfRange(u16),
419 MissingClaimValue,
420 MissingEvidence,
421 InvalidJson(String),
422}
423
424impl fmt::Display for ValidationError {
425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 match self {
427 Self::EmptyField(field) => write!(f, "{field} cannot be empty"),
428 Self::ConfidenceOutOfRange(value) => {
429 write!(f, "confidence {value} is outside 0..=1000")
430 }
431 Self::MissingClaimValue => f.write_str("asserted claims must include a value"),
432 Self::MissingEvidence => f.write_str("asserted claims must include evidence"),
433 Self::InvalidJson(error) => write!(f, "invalid JSON claim value: {error}"),
434 }
435 }
436}
437
438impl std::error::Error for ValidationError {}
439
440#[cfg(test)]
441mod tests {
442 use super::{CanonicalJson, ClaimValue, ValidationError};
443
444 #[test]
445 fn canonical_json_sorts_keys_and_strips_whitespace() {
446 let c = CanonicalJson::new("{ \"b\": 2, \"a\": 1 }").expect("valid json");
447 assert_eq!(c.as_str(), r#"{"a":1,"b":2}"#);
448 }
449
450 #[test]
451 fn canonical_json_is_idempotent() {
452 let once = CanonicalJson::new(r#"{"b":2,"a":1}"#).expect("valid json");
453 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
454 assert_eq!(once, twice);
455 }
456
457 #[test]
458 fn float_canonicalization_is_idempotent_through_reload() {
459 for raw in [
464 r#"{"x":13e300}"#,
465 r#"{"x":17e300}"#,
466 r#"{"x":37e-300}"#,
467 r#"{"a":0.1,"b":1.0,"c":-0.5,"d":1e10}"#,
468 ] {
469 let once = CanonicalJson::new(raw).expect("valid json");
470 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
471 assert_eq!(once, twice, "not idempotent: {raw}");
472
473 let value = ClaimValue::Json(once.clone());
475 let bytes = serde_json::to_string(&value).expect("serialize");
476 let reloaded: ClaimValue = serde_json::from_str(&bytes).expect("deserialize");
477 assert_eq!(value, reloaded, "reload changed the value: {raw}");
478 }
479 }
480
481 #[test]
482 fn semantically_equal_json_is_equal_regardless_of_form() {
483 let a = ClaimValue::json("{ \"b\": 2, \"a\": 1 }").expect("valid json");
484 let b = ClaimValue::json(r#"{"a":1,"b":2}"#).expect("valid json");
485 assert_eq!(a, b);
486 }
487
488 #[test]
489 fn invalid_json_is_rejected() {
490 let error = ClaimValue::json("{not json").unwrap_err();
491 assert!(matches!(error, ValidationError::InvalidJson(_)));
492 }
493
494 #[test]
495 fn oversized_integers_are_lossy_but_idempotent() {
496 let raw = r#"{"n":18446744073709551616}"#;
500 let once = CanonicalJson::new(raw).expect("valid json");
501 assert_ne!(
502 once.as_str(),
503 raw,
504 "an out-of-u64-range integer is reformatted as f64"
505 );
506 let twice = CanonicalJson::new(once.as_str()).expect("valid json");
507 assert_eq!(once, twice, "but canonicalization is idempotent thereafter");
508 }
509
510 #[test]
511 fn deserialize_recanonicalizes_a_non_canonical_stored_value() {
512 let loaded: ClaimValue =
515 serde_json::from_str(r#"{"Json":"{ \"b\": 2, \"a\": 1 }"}"#).expect("load");
516 assert_eq!(loaded, ClaimValue::json(r#"{"a":1,"b":2}"#).unwrap());
517 }
518
519 #[test]
520 fn deserialize_rejects_invalid_embedded_json() {
521 let result: Result<ClaimValue, _> = serde_json::from_str(r#"{"Json":"{not json"}"#);
522 assert!(result.is_err());
523 }
524}