Skip to main content

bitemporal_runtime/
types.rs

1//! Core bitemporal types.
2
3#[cfg(feature = "schema")]
4use schemars::JsonSchema;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9/// A globally unique record identifier.
10pub type RecordId = String;
11
12/// A bitemporal record.
13///
14/// Type parameter `T` is the domain value being recorded.
15/// The record carries two orthogonal timelines:
16/// - `valid_time`: when the value is true in the domain (business time)
17/// - `recorded_time`: when the system captured the value (system time)
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(JsonSchema))]
20#[cfg_attr(feature = "schema", schemars(bound = "T: ::schemars::JsonSchema + Default"))]
21pub struct BitemporalRecord<T = ()> {
22    /// Unique identifier for this record (stable across versions).
23    /// Used to link superseding records.
24    pub id: RecordId,
25
26    /// Valid time — when this record's value is true in the domain.
27    /// Backward-bounded (valid_time is set to the moment the fact became true).
28    pub valid_time: DateTime<Utc>,
29
30    /// Recorded time — when this record was inserted into the system.
31    /// Forward-bounded (recorded_time is the moment of system insertion).
32    pub recorded_time: DateTime<Utc>,
33
34    /// The domain value this record captures. Defaults to `T::default()`
35    /// when missing from the wire format — both with and without the
36    /// `schema` feature. The schema feature adds `T: Default` to the
37    /// schemars bound, which is also the bound `serde(default)` requires.
38    #[serde(default)]
39    pub value: T,
40}
41
42impl<T> BitemporalRecord<T> {
43    /// Map the value of this record through a function, preserving temporal fields.
44    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> BitemporalRecord<U> {
45        BitemporalRecord {
46            id: self.id,
47            valid_time: self.valid_time,
48            recorded_time: self.recorded_time,
49            value: f(self.value),
50        }
51    }
52
53    /// Returns the record's ID.
54    pub fn id(&self) -> &str {
55        &self.id
56    }
57
58    /// Returns the valid time (when the value is true in the domain).
59    pub fn valid_time(&self) -> DateTime<Utc> {
60        self.valid_time
61    }
62
63    /// Returns the recorded time (when the system captured this).
64    pub fn recorded_time(&self) -> DateTime<Utc> {
65        self.recorded_time
66    }
67}
68
69/// Reference to the record that was superseded by a supersession event.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[cfg_attr(feature = "schema", derive(JsonSchema))]
72pub struct SupersessionTarget {
73    /// ID of the record that was superseded.
74    pub superseded_id: RecordId,
75    /// Recorded time of the superseded record.
76    pub superseded_recorded_time: DateTime<Utc>,
77}
78
79/// Receipt for a supersession event.
80///
81/// Cryptographically identifies both the superseded record and the superseding record,
82/// providing an auditable chain of custody.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[cfg_attr(feature = "schema", derive(JsonSchema))]
85pub struct SupersessionReceipt {
86    /// ID of the record that superseded another.
87    pub superseding_id: RecordId,
88    /// Recorded time of the superseding record.
89    pub superseding_recorded_time: DateTime<Utc>,
90    /// The target that was superseded.
91    pub superseded: SupersessionTarget,
92    /// SHA-256 digest of the superseding record content (for integrity verification).
93    pub superseding_digest: String,
94    /// SHA-256 digest of the superseded record content (for integrity verification).
95    pub superseded_digest: String,
96    /// SHA-256 digest of the receipt itself (self-checksum).
97    pub receipt_digest: String,
98}
99
100impl SupersessionReceipt {
101    /// Create a new supersession receipt from superseded and superseding records.
102    ///
103    /// The receipt is the cryptographic audit handle for a supersession
104    /// event. The digests bind **all** content of both records (id,
105    /// temporal fields, AND the JSON-serialized value) so that two
106    /// records differing only in their value produce different digests.
107    /// The receipt_digest additionally binds the two record digests so
108    /// the supersession relationship is itself tamper-evident.
109    pub fn new<T>(superseded: BitemporalRecord<T>, superseding: BitemporalRecord<T>) -> Self
110    where
111        T: Serialize,
112    {
113        let superseded_digest = Self::digest_record(&superseded);
114        let superseding_digest = Self::digest_record(&superseding);
115
116        let receipt_content = format!(
117            "supersession:v1:{}:{}:{}:{}:{}:{}",
118            superseding.id,
119            superseding.recorded_time.timestamp(),
120            superseded.id,
121            superseded.recorded_time.timestamp(),
122            superseding_digest,
123            superseded_digest
124        );
125        let receipt_digest = format!("{:x}", Sha256::digest(receipt_content.as_bytes()));
126
127        Self {
128            superseding_id: superseding.id,
129            superseding_recorded_time: superseding.recorded_time,
130            superseded: SupersessionTarget {
131                superseded_id: superseded.id,
132                superseded_recorded_time: superseded.recorded_time,
133            },
134            superseding_digest,
135            superseded_digest,
136            receipt_digest,
137        }
138    }
139
140    /// Compute the SHA-256 digest of a record's full content (id,
141    /// temporal fields, and JSON-serialized value). Two records with
142    /// different values produce different digests.
143    fn digest_record<T: Serialize>(record: &BitemporalRecord<T>) -> String {
144        let value_json = serde_json::to_string(&record.value).unwrap_or_default();
145        let content = format!(
146            "record:v1:{}:{}:{}:{}",
147            record.id,
148            record.valid_time.timestamp(),
149            record.recorded_time.timestamp(),
150            value_json
151        );
152        format!("{:x}", Sha256::digest(content.as_bytes()))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_record_temporal_fields() {
162        let now = Utc::now();
163        let record = BitemporalRecord::<String> {
164            id: "test1".to_string(),
165            valid_time: now,
166            recorded_time: now,
167            value: "hello".to_string(),
168        };
169        assert_eq!(record.id(), "test1");
170        assert_eq!(record.valid_time(), now);
171        assert_eq!(record.recorded_time(), now);
172    }
173
174    #[test]
175    fn test_supersession_receipt_digest() {
176        let now = Utc::now();
177        let superseded = BitemporalRecord {
178            id: "v1".to_string(),
179            valid_time: now,
180            recorded_time: now,
181            value: (),
182        };
183        let superseding = BitemporalRecord {
184            id: "v2".to_string(),
185            valid_time: now,
186            recorded_time: now,
187            value: (),
188        };
189        let receipt = SupersessionReceipt::new(superseded, superseding);
190        assert!(!receipt.receipt_digest.is_empty());
191        assert_eq!(receipt.superseded.superseded_id, "v1");
192        assert_eq!(receipt.superseding_id, "v2");
193    }
194}