1use 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
19pub 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 pub sequence: u64,
28 pub kind: EventKind,
29 pub attribution: Attribution,
31 pub occurred_at: Timestamp,
34 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 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub tag: Option<NamespacedName>,
43 #[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 Created {
53 record: Box<Record>,
54 },
55 Amended {
57 changes: Vec<Change>,
58 },
59 Corrected {
62 changes: Vec<Change>,
63 corrects: Option<EventId>,
64 },
65 RelationAdded {
66 relation: Relation,
67 },
68 RelationRetracted {
69 relation_id: RelationId,
70 },
71 Retracted,
73 Superseded {
75 by: RecordId,
76 },
77 Redacted {
79 fields: Vec<FieldPath>,
80 },
81 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#[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 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#[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 Source {
147 id: SourceId,
148 },
149 Extension {
150 namespace: String,
151 field: String,
152 },
153 Annotation {
155 event: EventId,
156 key: String,
157 },
158}
159
160pub 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
198pub 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}