Skip to main content

dent8_core/
model.rs

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/// 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 [`FactValue::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 FactValue {
109    Text(String),
110    Json(CanonicalJson),
111    Redacted,
112}
113
114impl FactValue {
115    /// A canonical JSON fact 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    /// The confidence a fresh assertion carries (0.9). High but not certain, leaving headroom for
132    /// corroboration. This is the common construction, so it is a named infallible constant
133    /// rather than `from_millis(900).unwrap()` repeated at every call site.
134    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    /// A fresh assertion's confidence ([`Confidence::ASSERTED`]).
151    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    /// The stable lowercase name for the level, matching both its serde representation and the
168    /// CLI/MCP input spelling (`--authority high`), so a level round-trips: what you write is what
169    /// you read back. Use this — not `format!("{self:?}")` — anywhere the name is persisted or
170    /// becomes a query key (e.g. the Parquet export's `authority` column).
171    #[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    /// The lowercase [`AuthorityLevel::name`], so error messages read like the input (`high`)
185    /// rather than the Rust variant (`High`). Use `{}` — not `{:?}` — in user-facing messages.
186    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    /// The absolute instant at which this TTL elapses, anchored at `anchor`, if ever.
218    ///
219    /// `Never` has no expiry. A `DurationMillis` whose `anchor + duration` is not
220    /// representable in `i64` milliseconds (durations beyond ~292 million years)
221    /// returns `None` and is therefore treated as non-expiring — a deliberate,
222    /// fail-open choice for an unreachable boundary.
223    #[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    /// Optional signed write attestation (ADR 0013): the writer's persisted proof that the
251    /// holder of `public_key` signed this event's content. `None` (the pre-attestation
252    /// default) is **skipped during serialization**, so events written before this field
253    /// existed keep byte-identical canonical form and their stored hashes.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub attestation: Option<WriteAttestation>,
256}
257
258/// A per-write source signature carried inside [`Provenance`] (ADR 0013). The signed message
259/// is [`crate::attestation_message`] — the domain-framed canonical bytes of the event with
260/// `attestation` stripped — so any holder of `public_key` can re-verify offline that the
261/// event content is exactly what the source key signed. The hash chain covers the attestation
262/// bytes themselves, so the attestation is as tamper-evident as the rest of the event.
263#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
264pub struct WriteAttestation {
265    pub algorithm: AttestationAlgorithm,
266    /// The source's Ed25519 verifying key, lowercase hex (32 bytes).
267    pub public_key: String,
268    /// Signature over [`crate::attestation_message`], lowercase hex (64 bytes).
269    pub signature: String,
270}
271
272/// The attestation signature scheme. A closed enum (not a free string) so an unknown or
273/// misspelled algorithm fails loudly at deserialize time instead of silently "verifying".
274#[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    /// The fact was **derived from another fact**: the [`Evidence::locator`] holds the
289    /// source `fact:` id (ADR 0010). These items form the fact->fact dependency graph that
290    /// retraction-taint analysis walks (poison must not survive in its derivatives).
291    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    /// The fact ids this event was **derived from** — its [`EvidenceKind::DerivedFrom`]
305    /// evidence items, whose `locator` is the source `fact:` id (ADR 0010). A malformed
306    /// locator is skipped (it simply contributes no edge), so this never fails.
307    #[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/// What kind of write the incumbent was challenged by (ADR 0015). Only challenges that
348/// were real contests lost on strength are recorded — see [`ChallengeRejection`].
349#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
350pub enum ChallengeKind {
351    Supersession,
352    Contradiction,
353    Retraction,
354    Expiration,
355}
356
357/// Why the firewall rejected the challenge (ADR 0015). These are the strength-based
358/// rejections; malformed writes, duplicates, and terminal-state mutations are not
359/// "survived" challenges and are never recorded.
360#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
361pub enum ChallengeRejection {
362    /// The challenge's stated authority was below the incumbent's.
363    InsufficientAuthority,
364    /// The supersession stated enough authority, but its backing fact was actually
365    /// weaker (the subject-aware anti-laundering check).
366    LaunderedAuthority,
367    /// A contradiction against a canonical incumbent (the LFI hard-alarm).
368    CanonicalContradiction,
369    /// An equal-authority supersession whose backing fact had strictly weaker
370    /// authority-weighted corroboration (the earned-supersession gate, opt-in). Superseded
371    /// by [`Self::WeakerEntrenchment`] as of ADR 0017; kept so pre-0.3 challenge-rejected
372    /// events still deserialize.
373    WeakerCorroboration,
374    /// An equal-authority supersession whose challenger had strictly weaker **earned
375    /// entrenchment** — corroboration plus survived challenges — than the incumbent
376    /// (the earned-supersession gate, opt-in; ADR 0017).
377    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    /// A challenge against this fact was rejected by the firewall (ADR 0015). Written to
407    /// the **incumbent's** stream with the **challenger's** provenance and *effective*
408    /// authority, so surviving it is replayable, attributed entrenchment evidence.
409    ChallengeRejected {
410        challenge: ChallengeKind,
411        /// The challenging fact, when the challenge named one (a supersession's
412        /// replacement, a contradiction's contradictor).
413        #[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    /// Valid-time upper bound (ADR 0016): the instant this fact is asserted to stop
460    /// holding. Read-time freshness treats it like an elapsed TTL. `None` (the
461    /// pre-interval default) is **skipped during serialization**, so events written
462    /// before this field existed keep byte-identical canonical form and their stored
463    /// hashes (the ADR 0013 optional-field rule; unlike `observed_at`/`valid_from`,
464    /// which predate it and serialize as explicit nulls).
465    #[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    /// `valid_to` at or before `valid_from` — an empty or inverted validity interval.
502    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        // `serde_json`'s `float_roundtrip` feature makes f64 parse to its shortest round-trip
544        // form, so canonicalization is idempotent for floats. Without it, values like 13e300
545        // drift on the second pass and the re-canonicalizing Deserialize would change a
546        // legitimately-written value on reload — a false hash-chain tamper alarm.
547        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            // The reload path (custom Deserialize re-canonicalizes) must not change it.
558            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        // Documented limitation: a JSON integer beyond u64 is parsed as f64 (lossy), so the
581        // canonical form differs from the input — but it is stable thereafter, so it never
582        // trips the hash chain on reload.
583        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        // A hand-edited or legacy non-canonical encoding is re-canonicalized on load, so
597        // the canonical invariant holds even off the constructor path.
598        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}