Skip to main content

dent8_core/
model.rs

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/// JSON held in **canonical form** — parsed and re-emitted with sorted object keys and no
56/// insignificant whitespace — so two semantically-equal JSON values share identical bytes
57/// and therefore identical hashes ([ADR 0004](../../docs/decisions/0004-canonicalization-and-hash-chain.md)
58/// item 6). The inner form is an invariant: there is no way to construct a non-canonical
59/// value. Build via [`ClaimValue::json`] / [`CanonicalJson::new`]; the canonicalization is
60/// re-applied on deserialize, so the invariant also holds on the trusted-reload path.
61///
62/// **Number model.** Numbers follow `serde_json`'s `f64`/`i64`/`u64` model, not JCS:
63/// floats are normalized to their shortest round-tripping form (the `float_roundtrip`
64/// feature, which keeps canonicalization idempotent), but a JSON integer beyond `u64`
65/// range or a high-precision decimal is parsed as `f64` and **loses precision on the first
66/// canonicalization** (e.g. `18446744073709551616` → `1.8446744073709552e19`). Pass such
67/// values as JSON *strings* if exact preservation matters. This is idempotent after the
68/// first pass (so it never trips the hash chain), but it is lossy — consistent with the
69/// "not JCS" caveat in [`crate::hash`].
70///
71/// **Keys.** Object keys are sorted by Rust `String` (UTF-8 byte) order at every depth.
72/// Embedded JSON may legitimately contain non-ASCII/dynamic keys; the ordering is
73/// deterministic and idempotent (sufficient for dent8's hash chain) but, like the rest of
74/// the encoding, is **not** JCS's UTF-16 ordering.
75#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
76pub struct CanonicalJson(String);
77
78impl CanonicalJson {
79    /// Parse `raw` and re-emit it in canonical form. Errors if `raw` is not valid JSON.
80    ///
81    /// Canonicalization routes through `serde_json`'s default (`BTreeMap`-backed) `Value`,
82    /// which sorts object keys and drops whitespace — the same canonical form
83    /// [`crate::hash::canonical_bytes`] relies on, and idempotent on already-canonical input.
84    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        // Re-canonicalize on load so a hand-edited or legacy non-canonical value cannot
101        // re-enter as canonical; idempotent for a value written through `new`.
102        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    /// A canonical JSON claim value (see [`CanonicalJson`]). Errors on invalid JSON.
116    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    /// The minimum confidence. Used as the identity confidence floor in
128    /// [`crate::policy::EpistemicPolicy`].
129    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    /// A stable string name for the level, matching its serde representation. Use this — not
155    /// `format!("{self:?}")` — anywhere the name is persisted or becomes a query key (e.g. the
156    /// Parquet export's `authority` column), so a future `Debug` change cannot silently break
157    /// downstream consumers.
158    #[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    /// The absolute instant at which this TTL elapses, anchored at `anchor`, if ever.
197    ///
198    /// `Never` has no expiry. A `DurationMillis` whose `anchor + duration` is not
199    /// representable in `i64` milliseconds (durations beyond ~292 million years)
200    /// returns `None` and is therefore treated as non-expiring — a deliberate,
201    /// fail-open choice for an unreachable boundary.
202    #[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    /// Optional signed write attestation (ADR 0013): the writer's persisted proof that the
230    /// holder of `public_key` signed this event's content. `None` (the pre-attestation
231    /// default) is **skipped during serialization**, so events written before this field
232    /// existed keep byte-identical canonical form and their stored hashes.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub attestation: Option<WriteAttestation>,
235}
236
237/// A per-write source signature carried inside [`Provenance`] (ADR 0013). The signed message
238/// is [`crate::attestation_message`] — the domain-framed canonical bytes of the event with
239/// `attestation` stripped — so any holder of `public_key` can re-verify offline that the
240/// event content is exactly what the source key signed. The hash chain covers the attestation
241/// bytes themselves, so the attestation is as tamper-evident as the rest of the event.
242#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
243pub struct WriteAttestation {
244    pub algorithm: AttestationAlgorithm,
245    /// The source's Ed25519 verifying key, lowercase hex (32 bytes).
246    pub public_key: String,
247    /// Signature over [`crate::attestation_message`], lowercase hex (64 bytes).
248    pub signature: String,
249}
250
251/// The attestation signature scheme. A closed enum (not a free string) so an unknown or
252/// misspelled algorithm fails loudly at deserialize time instead of silently "verifying".
253#[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    /// The claim was **derived from another claim**: the [`Evidence::locator`] holds the
268    /// source `claim:` id (ADR 0010). These items form the claim->claim dependency graph that
269    /// retraction-taint analysis walks (poison must not survive in its derivatives).
270    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    /// The claim ids this event was **derived from** — its [`EvidenceKind::DerivedFrom`]
284    /// evidence items, whose `locator` is the source `claim:` id (ADR 0010). A malformed
285    /// locator is skipped (it simply contributes no edge), so this never fails.
286    #[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        // `serde_json`'s `float_roundtrip` feature makes f64 parse to its shortest round-trip
460        // form, so canonicalization is idempotent for floats. Without it, values like 13e300
461        // drift on the second pass and the re-canonicalizing Deserialize would change a
462        // legitimately-written value on reload — a false hash-chain tamper alarm.
463        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            // The reload path (custom Deserialize re-canonicalizes) must not change it.
474            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        // Documented limitation: a JSON integer beyond u64 is parsed as f64 (lossy), so the
497        // canonical form differs from the input — but it is stable thereafter, so it never
498        // trips the hash chain on reload.
499        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        // A hand-edited or legacy non-canonical encoding is re-canonicalized on load, so
513        // the canonical invariant holds even off the constructor path.
514        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}