Skip to main content

bones_core/event/
mod.rs

1//! Event data model for the bones event log.
2//!
3//! This module defines the core `Event` struct, the `EventType` enum covering
4//! all 11 event types, typed payload data structs, and the canonical JSON
5//! serialization helper needed for deterministic event hashing.
6//!
7//! # TSJSON Format
8//!
9//! Events are stored in TSJSON (tab-separated fields with JSON payload):
10//!
11//! ```text
12//! wall_ts_us \t agent \t itc \t parents \t type \t item_id \t data \t event_hash
13//! ```
14//!
15//! The `Event` struct maps 1:1 to a TSJSON line. Parsing and writing TSJSON
16//! lines is handled by the parser/writer modules (separate beads).
17
18pub mod canonical;
19pub mod data;
20pub mod hash_text;
21pub mod migrate;
22pub mod parser;
23pub mod types;
24pub mod validate;
25pub mod writer;
26
27pub use canonical::{canonicalize_json, canonicalize_json_str};
28pub use data::{
29    AssignAction, AssignData, CommentData, CompactData, CreateData, DataParseError, DeleteData,
30    EventData, LinkData, MoveData, RedactData, SnapshotData, UnlinkData, UpdateData,
31};
32pub use migrate::{RawEvent, migrate_event};
33pub use parser::{
34    CURRENT_VERSION, FIELD_COMMENT, ParseError, ParsedLine, PartialEvent, PartialParsedLine,
35    SHARD_HEADER, detect_version, parse_line, parse_line_partial, parse_lines,
36};
37pub use types::{EventType, UnknownEventType};
38
39use crate::model::item_id::ItemId;
40use serde::{Deserialize, Serialize};
41
42/// A single event in the bones event log.
43///
44/// Each event represents an immutable, content-addressed mutation to a work
45/// item. Events form a Merkle-DAG via the `parents` field, enabling
46/// causally-ordered CRDT replay.
47///
48/// # Fields (TSJSON column order)
49///
50/// 1. `wall_ts_us` — wall-clock microseconds since Unix epoch
51/// 2. `agent` — identifier of the agent/user that produced the event
52/// 3. `itc` — Interval Tree Clock stamp (canonical text encoding)
53/// 4. `parents` — parent event hashes (blake3:...), sorted lexicographically
54/// 5. `event_type` — one of the 11 event types
55/// 6. `item_id` — the work item this event mutates
56/// 7. `data` — typed payload (JSON in TSJSON, deserialized here)
57/// 8. `event_hash` — BLAKE3 hash of fields 1–7
58///
59/// # Serde
60///
61/// Custom `Deserialize` implementation uses `event_type` to drive typed
62/// deserialization of the `data` field. This is necessary because the type
63/// discriminant is external to the JSON payload.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct Event {
66    /// Wall-clock timestamp in microseconds since Unix epoch.
67    ///
68    /// Monotonically increasing per-repo via the clock file.
69    pub wall_ts_us: i64,
70
71    /// Identifier of the agent or user that produced this event.
72    pub agent: String,
73
74    /// Interval Tree Clock stamp in canonical text encoding.
75    ///
76    /// Used for causal ordering independent of wall-clock time.
77    pub itc: String,
78
79    /// Parent event hashes forming the Merkle-DAG.
80    ///
81    /// Sorted lexicographically. Empty for the first event in a repo.
82    /// Format: `["blake3:abcdef...", ...]`
83    pub parents: Vec<String>,
84
85    /// The type of mutation this event represents.
86    pub event_type: EventType,
87
88    /// The work item being mutated.
89    pub item_id: ItemId,
90
91    /// Typed payload data specific to the event type.
92    pub data: EventData,
93
94    /// BLAKE3 content hash of fields 1–7.
95    ///
96    /// Format: `blake3:<payload>` (base64url in new writes). This is the
97    /// event's identity in the
98    /// Merkle-DAG and is used for parent references, shard manifests,
99    /// and sync diffing.
100    pub event_hash: String,
101}
102
103impl<'de> Deserialize<'de> for Event {
104    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
105    where
106        D: serde::Deserializer<'de>,
107    {
108        /// Helper struct for two-pass deserialization: first get the `event_type`,
109        /// then use it to deserialize the data payload.
110        #[derive(Deserialize)]
111        struct EventRaw {
112            wall_ts_us: i64,
113            agent: String,
114            itc: String,
115            parents: Vec<String>,
116            event_type: EventType,
117            item_id: ItemId,
118            data: serde_json::Value,
119            event_hash: String,
120        }
121
122        let raw = EventRaw::deserialize(deserializer)?;
123        let data_json = raw.data.to_string();
124        let data = EventData::deserialize_for(raw.event_type, &data_json)
125            .map_err(serde::de::Error::custom)?;
126
127        Ok(Self {
128            wall_ts_us: raw.wall_ts_us,
129            agent: raw.agent,
130            itc: raw.itc,
131            parents: raw.parents,
132            event_type: raw.event_type,
133            item_id: raw.item_id,
134            data,
135            event_hash: raw.event_hash,
136        })
137    }
138}
139
140impl Event {
141    /// Return the TSJSON parents field string (comma-separated, sorted).
142    ///
143    /// Returns an empty string for root events (no parents).
144    #[must_use]
145    pub fn parents_str(&self) -> String {
146        self.parents.join(",")
147    }
148}
149
150impl std::fmt::Display for Event {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        write!(
153            f,
154            "{}\t{}\t{}\t{}\t{}\t{}",
155            self.wall_ts_us,
156            self.agent,
157            self.event_type,
158            self.item_id,
159            self.event_hash,
160            // Abbreviated data display
161            match &self.data {
162                EventData::Create(d) => format!("create: {}", d.title),
163                EventData::Update(d) => format!("update: {}={}", d.field, d.value),
164                EventData::Move(d) => format!("move: {}", d.state),
165                EventData::Assign(d) => format!("{}: {}", d.action, d.agent),
166                EventData::Comment(d) => {
167                    let preview = if d.body.len() > 40 {
168                        format!("{}...", &d.body[..40])
169                    } else {
170                        d.body.clone()
171                    };
172                    format!("comment: {preview}")
173                }
174                EventData::Link(d) => format!("link: {} {}", d.link_type, d.target),
175                EventData::Unlink(d) => format!("unlink: {}", d.target),
176                EventData::Delete(_) => "delete".to_string(),
177                EventData::Compact(d) => {
178                    let preview = if d.summary.len() > 40 {
179                        format!("{}...", &d.summary[..40])
180                    } else {
181                        d.summary.clone()
182                    };
183                    format!("compact: {preview}")
184                }
185                EventData::Snapshot(_) => "snapshot".to_string(),
186                EventData::Redact(d) => format!("redact: {}", d.target_hash),
187            }
188        )
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Tests
194// ---------------------------------------------------------------------------
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use serde_json::json;
200    use std::collections::BTreeMap;
201
202    fn sample_create_event() -> Event {
203        Event {
204            wall_ts_us: 1_708_012_200_123_456,
205            agent: "claude-abc".into(),
206            itc: "itc:AQ".into(),
207            parents: vec![],
208            event_type: EventType::Create,
209            item_id: ItemId::new_unchecked("bn-a3f8"),
210            data: EventData::Create(CreateData {
211                title: "Fix auth retry".into(),
212                kind: crate::model::item::Kind::Task,
213                size: Some(crate::model::item::Size::M),
214                urgency: crate::model::item::Urgency::Default,
215                labels: vec!["backend".into()],
216                parent: None,
217                causation: None,
218                description: None,
219                extra: BTreeMap::new(),
220            }),
221            event_hash: "blake3:a1b2c3d4e5f6".into(),
222        }
223    }
224
225    fn sample_move_event() -> Event {
226        Event {
227            wall_ts_us: 1_708_012_201_000_000,
228            agent: "claude-abc".into(),
229            itc: "itc:AQ.1".into(),
230            parents: vec!["blake3:a1b2c3d4e5f6".into()],
231            event_type: EventType::Move,
232            item_id: ItemId::new_unchecked("bn-a3f8"),
233            data: EventData::Move(MoveData {
234                state: crate::model::item::State::Doing,
235                reason: None,
236                extra: BTreeMap::new(),
237            }),
238            event_hash: "blake3:d4e5f6789abc".into(),
239        }
240    }
241
242    #[test]
243    fn event_struct_fields() {
244        let event = sample_create_event();
245        assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
246        assert_eq!(event.agent, "claude-abc");
247        assert_eq!(event.itc, "itc:AQ");
248        assert!(event.parents.is_empty());
249        assert_eq!(event.event_type, EventType::Create);
250        assert_eq!(event.item_id.as_str(), "bn-a3f8");
251        assert!(matches!(event.data, EventData::Create(_)));
252        assert_eq!(event.event_hash, "blake3:a1b2c3d4e5f6");
253    }
254
255    #[test]
256    fn event_parents_str_empty() {
257        let event = sample_create_event();
258        assert_eq!(event.parents_str(), "");
259    }
260
261    #[test]
262    fn event_parents_str_single() {
263        let event = sample_move_event();
264        assert_eq!(event.parents_str(), "blake3:a1b2c3d4e5f6");
265    }
266
267    #[test]
268    fn event_parents_str_multiple() {
269        let mut event = sample_move_event();
270        event.parents = vec!["blake3:aaa".into(), "blake3:bbb".into()];
271        assert_eq!(event.parents_str(), "blake3:aaa,blake3:bbb");
272    }
273
274    #[test]
275    fn event_display() {
276        let event = sample_create_event();
277        let display = event.to_string();
278        assert!(display.contains("1708012200123456"));
279        assert!(display.contains("claude-abc"));
280        assert!(display.contains("item.create"));
281        assert!(display.contains("bn-a3f8"));
282        assert!(display.contains("Fix auth retry"));
283    }
284
285    #[test]
286    fn event_serde_json_roundtrip() {
287        let event = sample_create_event();
288        let json = serde_json::to_string(&event).expect("serialize");
289        let deser: Event = serde_json::from_str(&json).expect("deserialize");
290        assert_eq!(event, deser);
291    }
292
293    #[test]
294    fn event_serde_json_roundtrip_with_parents() {
295        let event = sample_move_event();
296        let json = serde_json::to_string(&event).expect("serialize");
297        let deser: Event = serde_json::from_str(&json).expect("deserialize");
298        assert_eq!(event, deser);
299    }
300
301    #[test]
302    fn event_serde_all_types_roundtrip() {
303        let base = || -> (i64, String, String, Vec<String>, ItemId, String) {
304            (
305                1_000_000,
306                "agent".into(),
307                "itc:X".into(),
308                vec![],
309                ItemId::new_unchecked("bn-a7x"),
310                "blake3:000".into(),
311            )
312        };
313
314        let events: Vec<Event> = vec![
315            {
316                let (ts, agent, itc, parents, item_id, hash) = base();
317                Event {
318                    wall_ts_us: ts,
319                    agent,
320                    itc,
321                    parents,
322                    event_type: EventType::Create,
323                    item_id,
324                    data: EventData::Create(CreateData {
325                        title: "T".into(),
326                        kind: crate::model::item::Kind::Task,
327                        size: None,
328                        urgency: crate::model::item::Urgency::Default,
329                        labels: vec![],
330                        parent: None,
331                        causation: None,
332                        description: None,
333                        extra: BTreeMap::new(),
334                    }),
335                    event_hash: hash,
336                }
337            },
338            {
339                let (ts, agent, itc, parents, item_id, hash) = base();
340                Event {
341                    wall_ts_us: ts,
342                    agent,
343                    itc,
344                    parents,
345                    event_type: EventType::Update,
346                    item_id,
347                    data: EventData::Update(UpdateData {
348                        field: "title".into(),
349                        value: json!("New"),
350                        extra: BTreeMap::new(),
351                    }),
352                    event_hash: hash,
353                }
354            },
355            {
356                let (ts, agent, itc, parents, item_id, hash) = base();
357                Event {
358                    wall_ts_us: ts,
359                    agent,
360                    itc,
361                    parents,
362                    event_type: EventType::Move,
363                    item_id,
364                    data: EventData::Move(MoveData {
365                        state: crate::model::item::State::Done,
366                        reason: Some("done".into()),
367                        extra: BTreeMap::new(),
368                    }),
369                    event_hash: hash,
370                }
371            },
372            {
373                let (ts, agent, itc, parents, item_id, hash) = base();
374                Event {
375                    wall_ts_us: ts,
376                    agent,
377                    itc,
378                    parents,
379                    event_type: EventType::Assign,
380                    item_id,
381                    data: EventData::Assign(AssignData {
382                        agent: "alice".into(),
383                        action: AssignAction::Assign,
384                        extra: BTreeMap::new(),
385                    }),
386                    event_hash: hash,
387                }
388            },
389            {
390                let (ts, agent, itc, parents, item_id, hash) = base();
391                Event {
392                    wall_ts_us: ts,
393                    agent,
394                    itc,
395                    parents,
396                    event_type: EventType::Comment,
397                    item_id,
398                    data: EventData::Comment(CommentData {
399                        body: "Note".into(),
400                        extra: BTreeMap::new(),
401                    }),
402                    event_hash: hash,
403                }
404            },
405            {
406                let (ts, agent, itc, parents, item_id, hash) = base();
407                Event {
408                    wall_ts_us: ts,
409                    agent,
410                    itc,
411                    parents,
412                    event_type: EventType::Link,
413                    item_id,
414                    data: EventData::Link(LinkData {
415                        target: "bn-b8y".into(),
416                        link_type: "blocks".into(),
417                        extra: BTreeMap::new(),
418                    }),
419                    event_hash: hash,
420                }
421            },
422            {
423                let (ts, agent, itc, parents, item_id, hash) = base();
424                Event {
425                    wall_ts_us: ts,
426                    agent,
427                    itc,
428                    parents,
429                    event_type: EventType::Unlink,
430                    item_id,
431                    data: EventData::Unlink(UnlinkData {
432                        target: "bn-b8y".into(),
433                        link_type: None,
434                        extra: BTreeMap::new(),
435                    }),
436                    event_hash: hash,
437                }
438            },
439            {
440                let (ts, agent, itc, parents, item_id, hash) = base();
441                Event {
442                    wall_ts_us: ts,
443                    agent,
444                    itc,
445                    parents,
446                    event_type: EventType::Delete,
447                    item_id,
448                    data: EventData::Delete(DeleteData {
449                        reason: None,
450                        extra: BTreeMap::new(),
451                    }),
452                    event_hash: hash,
453                }
454            },
455            {
456                let (ts, agent, itc, parents, item_id, hash) = base();
457                Event {
458                    wall_ts_us: ts,
459                    agent,
460                    itc,
461                    parents,
462                    event_type: EventType::Compact,
463                    item_id,
464                    data: EventData::Compact(CompactData {
465                        summary: "TL;DR".into(),
466                        extra: BTreeMap::new(),
467                    }),
468                    event_hash: hash,
469                }
470            },
471            {
472                let (ts, agent, itc, parents, item_id, hash) = base();
473                Event {
474                    wall_ts_us: ts,
475                    agent,
476                    itc,
477                    parents,
478                    event_type: EventType::Snapshot,
479                    item_id,
480                    data: EventData::Snapshot(SnapshotData {
481                        state: json!({"id": "bn-a7x"}),
482                        extra: BTreeMap::new(),
483                    }),
484                    event_hash: hash,
485                }
486            },
487            {
488                let (ts, agent, itc, parents, item_id, hash) = base();
489                Event {
490                    wall_ts_us: ts,
491                    agent,
492                    itc,
493                    parents,
494                    event_type: EventType::Redact,
495                    item_id,
496                    data: EventData::Redact(RedactData {
497                        target_hash: "blake3:xyz".into(),
498                        reason: "oops".into(),
499                        extra: BTreeMap::new(),
500                    }),
501                    event_hash: hash,
502                }
503            },
504        ];
505
506        assert_eq!(events.len(), 11, "should cover all 11 event types");
507
508        for event in &events {
509            let json = serde_json::to_string(event)
510                .unwrap_or_else(|e| panic!("serialize {} failed: {e}", event.event_type));
511            let deser: Event = serde_json::from_str(&json)
512                .unwrap_or_else(|e| panic!("deserialize {} failed: {e}", event.event_type));
513            assert_eq!(*event, deser, "roundtrip failed for {}", event.event_type);
514        }
515    }
516
517    #[test]
518    fn event_display_all_data_types() {
519        // Smoke test: Display doesn't panic for any variant
520        let events = vec![sample_create_event(), sample_move_event()];
521        for event in events {
522            let _ = event.to_string(); // Should not panic
523        }
524    }
525}