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/// What kind of write the incumbent was challenged by (ADR 0015). Only challenges that
327/// were real contests lost on strength are recorded — see [`ChallengeRejection`].
328#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
329pub enum ChallengeKind {
330    Supersession,
331    Contradiction,
332    Retraction,
333    Expiration,
334}
335
336/// Why the firewall rejected the challenge (ADR 0015). These are the strength-based
337/// rejections; malformed writes, duplicates, and terminal-state mutations are not
338/// "survived" challenges and are never recorded.
339#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
340pub enum ChallengeRejection {
341    /// The challenge's stated authority was below the incumbent's.
342    InsufficientAuthority,
343    /// The supersession stated enough authority, but its backing claim was actually
344    /// weaker (the entity-aware anti-laundering check).
345    LaunderedAuthority,
346    /// A contradiction against a canonical incumbent (the LFI hard-alarm).
347    CanonicalContradiction,
348    /// An equal-authority supersession whose backing claim had strictly weaker
349    /// authority-weighted corroboration (the earned-supersession gate, opt-in).
350    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    /// A challenge against this claim was rejected by the firewall (ADR 0015). Written to
380    /// the **incumbent's** stream with the **challenger's** provenance and *effective*
381    /// authority, so surviving it is replayable, attributed entrenchment evidence.
382    ChallengeRejected {
383        challenge: ChallengeKind,
384        /// The challenging claim, when the challenge named one (a supersession's
385        /// replacement, a contradiction's contradictor).
386        #[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    /// Valid-time upper bound (ADR 0016): the instant this fact is asserted to stop
433    /// holding. Read-time freshness treats it like an elapsed TTL. `None` (the
434    /// pre-interval default) is **skipped during serialization**, so events written
435    /// before this field existed keep byte-identical canonical form and their stored
436    /// hashes (the ADR 0013 optional-field rule; unlike `observed_at`/`valid_from`,
437    /// which predate it and serialize as explicit nulls).
438    #[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    /// `valid_to` at or before `valid_from` — an empty or inverted validity interval.
475    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        // `serde_json`'s `float_roundtrip` feature makes f64 parse to its shortest round-trip
517        // form, so canonicalization is idempotent for floats. Without it, values like 13e300
518        // drift on the second pass and the re-canonicalizing Deserialize would change a
519        // legitimately-written value on reload — a false hash-chain tamper alarm.
520        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            // The reload path (custom Deserialize re-canonicalizes) must not change it.
531            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        // Documented limitation: a JSON integer beyond u64 is parsed as f64 (lossy), so the
554        // canonical form differs from the input — but it is stable thereafter, so it never
555        // trips the hash chain on reload.
556        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        // A hand-edited or legacy non-canonical encoding is re-canonicalized on load, so
570        // the canonical invariant holds even off the constructor path.
571        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}