Skip to main content

pardarsh_core/
event.rs

1//! Append-only, attributable record history.
2//!
3//! Every change to a record is an [`Event`] with a per-record, gap-free
4//! `sequence` (1, 2, 3...). The current [`crate::model::Record`] is a
5//! projection of those events. Events are never edited or deleted, with one
6//! exception: [`EventKind::Redacted`] scrubs the redacted values out of
7//! earlier events so personal data can actually be removed (ADR 0006).
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13use crate::extension::ExtValue;
14use crate::id::{EventId, NamespacedName, RecordId, RelationId, SourceId};
15use crate::model::{ActorRole, Attribution, Record, Timestamp, Visibility};
16use crate::provenance::SourceRef;
17use crate::relation::Relation;
18
19/// Placeholder that replaces redacted text.
20pub const REDACTED: &str = "[redacted]";
21
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
23pub struct Event {
24    pub id: EventId,
25    pub record_id: RecordId,
26    /// Position in the record's history, starting at 1, without gaps.
27    pub sequence: u64,
28    pub kind: EventKind,
29    /// Who caused the event.
30    pub attribution: Attribution,
31    /// When the change happened according to the actor (may be earlier than
32    /// `recorded_at`, never later than it plus allowed clock skew).
33    pub occurred_at: Timestamp,
34    /// When this system recorded the event. Set by the repository.
35    pub recorded_at: Timestamp,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub idempotency_key: Option<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub reason: Option<String>,
40    /// Application-defined label, e.g. `app:status_change`.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub tag: Option<NamespacedName>,
43    /// Who may see this event (e.g. private moderation notes).
44    #[serde(default)]
45    pub visibility: Visibility,
46}
47
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
49#[serde(tag = "type", rename_all = "snake_case")]
50pub enum EventKind {
51    /// The record's initial state.
52    Created {
53        record: Box<Record>,
54    },
55    /// Ordinary edits.
56    Amended {
57        changes: Vec<Change>,
58    },
59    /// Edits that fix an error. A reason is required; the event being
60    /// corrected may be named.
61    Corrected {
62        changes: Vec<Change>,
63        corrects: Option<EventId>,
64    },
65    RelationAdded {
66        relation: Relation,
67    },
68    RelationRetracted {
69        relation_id: RelationId,
70    },
71    /// The record was withdrawn. Reason required.
72    Retracted,
73    /// The record was replaced by another record.
74    Superseded {
75        by: RecordId,
76    },
77    /// Values at the given paths were removed from the record and its history.
78    Redacted {
79        fields: Vec<FieldPath>,
80    },
81    /// An application-defined annotation that does not change the envelope
82    /// (for example a moderation note). Data is typed.
83    Annotated {
84        name: NamespacedName,
85        data: BTreeMap<String, ExtValue>,
86    },
87}
88
89impl EventKind {
90    pub fn name(&self) -> &'static str {
91        match self {
92            Self::Created { .. } => "created",
93            Self::Amended { .. } => "amended",
94            Self::Corrected { .. } => "corrected",
95            Self::RelationAdded { .. } => "relation_added",
96            Self::RelationRetracted { .. } => "relation_retracted",
97            Self::Retracted => "retracted",
98            Self::Superseded { .. } => "superseded",
99            Self::Redacted { .. } => "redacted",
100            Self::Annotated { .. } => "annotated",
101        }
102    }
103}
104
105/// A single field-level change.
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107#[serde(tag = "field", content = "value", rename_all = "snake_case")]
108pub enum Change {
109    Title(String),
110    Summary(Option<String>),
111    Body(Option<String>),
112    PublishedAt(Option<Timestamp>),
113    EffectiveFrom(Option<Timestamp>),
114    EffectiveUntil(Option<Timestamp>),
115    ObservedAt(Option<Timestamp>),
116    Visibility(Visibility),
117    AddActor(ActorRole),
118    AddSource(SourceRef),
119    SetExtensionField { namespace: String, field: String, value: Option<ExtValue> },
120}
121
122impl Change {
123    /// The path this change writes to, if it writes a redactable value.
124    pub fn path(&self) -> Option<FieldPath> {
125        match self {
126            Self::Title(_) => Some(FieldPath::Title),
127            Self::Summary(_) => Some(FieldPath::Summary),
128            Self::Body(_) => Some(FieldPath::Body),
129            Self::AddSource(s) => Some(FieldPath::Source { id: s.id }),
130            Self::SetExtensionField { namespace, field, .. } => {
131                Some(FieldPath::Extension { namespace: namespace.clone(), field: field.clone() })
132            }
133            _ => None,
134        }
135    }
136}
137
138/// An addressable, redactable value in a record.
139#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
140#[serde(tag = "path", rename_all = "snake_case")]
141pub enum FieldPath {
142    Title,
143    Summary,
144    Body,
145    /// A source reference's locator and descriptive metadata.
146    Source {
147        id: SourceId,
148    },
149    Extension {
150        namespace: String,
151        field: String,
152    },
153    /// Free-text in an annotation's data.
154    Annotation {
155        event: EventId,
156        key: String,
157    },
158}
159
160/// Remove the values at `paths` from a record in place.
161pub fn redact_record(record: &mut Record, paths: &[FieldPath]) {
162    for path in paths {
163        match path {
164            FieldPath::Title => record.title = REDACTED.to_string(),
165            FieldPath::Summary => {
166                if record.summary.is_some() {
167                    record.summary = Some(REDACTED.to_string())
168                }
169            }
170            FieldPath::Body => {
171                if record.body.is_some() {
172                    record.body = Some(REDACTED.to_string())
173                }
174            }
175            FieldPath::Source { id } => {
176                for s in record.sources.iter_mut().filter(|s| s.id == *id) {
177                    redact_source(s);
178                }
179            }
180            FieldPath::Extension { namespace, field } => {
181                if let Some(v) = record.extensions.get_mut(namespace).and_then(|e| e.fields.get_mut(field)) {
182                    *v = ExtValue::Redacted;
183                }
184            }
185            FieldPath::Annotation { .. } => {}
186        }
187    }
188}
189
190fn redact_source(s: &mut SourceRef) {
191    s.locator = format!("urn:redacted:{}", s.id);
192    s.title = None;
193    s.issuer = None;
194    s.content_hash = None;
195    s.media_type = None;
196}
197
198/// Scrub redacted values out of an earlier event (used by stores when a
199/// [`EventKind::Redacted`] event is committed).
200pub fn scrub_event(event: &mut Event, paths: &[FieldPath]) {
201    let redact_changes = |changes: &mut Vec<Change>| {
202        for change in changes.iter_mut() {
203            let Some(p) = change.path() else { continue };
204            if !paths.contains(&p) {
205                continue;
206            }
207            match change {
208                Change::Title(t) => *t = REDACTED.to_string(),
209                Change::Summary(s) | Change::Body(s) => {
210                    if s.is_some() {
211                        *s = Some(REDACTED.to_string())
212                    }
213                }
214                Change::AddSource(s) => redact_source(s),
215                Change::SetExtensionField { value: value @ Some(_), .. } => *value = Some(ExtValue::Redacted),
216                _ => {}
217            }
218        }
219    };
220    match &mut event.kind {
221        EventKind::Created { record } => redact_record(record, paths),
222        EventKind::Amended { changes } | EventKind::Corrected { changes, .. } => redact_changes(changes),
223        EventKind::Annotated { data, .. } => {
224            for p in paths {
225                if let FieldPath::Annotation { event: eid, key } = p {
226                    if *eid == event.id {
227                        if let Some(v) = data.get_mut(key) {
228                            *v = ExtValue::Redacted;
229                        }
230                    }
231                }
232            }
233        }
234        EventKind::RelationAdded { relation } => {
235            for s in relation.sources.iter_mut() {
236                if paths.contains(&FieldPath::Source { id: s.id }) {
237                    redact_source(s);
238                }
239            }
240        }
241        _ => {}
242    }
243}