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
150fn truncate_for_display(value: &str, max_chars: usize) -> String {
151    if value.chars().count() <= max_chars {
152        return value.to_string();
153    }
154
155    let mut preview: String = value.chars().take(max_chars).collect();
156    preview.push_str("...");
157    preview
158}
159
160impl std::fmt::Display for Event {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(
163            f,
164            "{}\t{}\t{}\t{}\t{}\t{}",
165            self.wall_ts_us,
166            self.agent,
167            self.event_type,
168            self.item_id,
169            self.event_hash,
170            // Abbreviated data display
171            match &self.data {
172                EventData::Create(d) => format!("create: {}", d.title),
173                EventData::Update(d) => format!("update: {}={}", d.field, d.value),
174                EventData::Move(d) => format!("move: {}", d.state),
175                EventData::Assign(d) => format!("{}: {}", d.action, d.agent),
176                EventData::Comment(d) => {
177                    let preview = truncate_for_display(&d.body, 40);
178                    format!("comment: {preview}")
179                }
180                EventData::Link(d) => format!("link: {} {}", d.link_type, d.target),
181                EventData::Unlink(d) => format!("unlink: {}", d.target),
182                EventData::Delete(_) => "delete".to_string(),
183                EventData::Compact(d) => {
184                    let preview = truncate_for_display(&d.summary, 40);
185                    format!("compact: {preview}")
186                }
187                EventData::Snapshot(_) => "snapshot".to_string(),
188                EventData::Redact(d) => format!("redact: {}", d.target_hash),
189            }
190        )
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Tests
196// ---------------------------------------------------------------------------
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use serde_json::json;
202    use std::collections::BTreeMap;
203
204    fn sample_create_event() -> Event {
205        Event {
206            wall_ts_us: 1_708_012_200_123_456,
207            agent: "claude-abc".into(),
208            itc: "itc:AQ".into(),
209            parents: vec![],
210            event_type: EventType::Create,
211            item_id: ItemId::new_unchecked("bn-a3f8"),
212            data: EventData::Create(CreateData {
213                title: "Fix auth retry".into(),
214                kind: crate::model::item::Kind::Task,
215                size: Some(crate::model::item::Size::M),
216                urgency: crate::model::item::Urgency::Default,
217                labels: vec!["backend".into()],
218                parent: None,
219                causation: None,
220                description: None,
221                extra: BTreeMap::new(),
222            }),
223            event_hash: "blake3:a1b2c3d4e5f6".into(),
224        }
225    }
226
227    fn sample_move_event() -> Event {
228        Event {
229            wall_ts_us: 1_708_012_201_000_000,
230            agent: "claude-abc".into(),
231            itc: "itc:AQ.1".into(),
232            parents: vec!["blake3:a1b2c3d4e5f6".into()],
233            event_type: EventType::Move,
234            item_id: ItemId::new_unchecked("bn-a3f8"),
235            data: EventData::Move(MoveData {
236                state: crate::model::item::State::Doing,
237                reason: None,
238                extra: BTreeMap::new(),
239            }),
240            event_hash: "blake3:d4e5f6789abc".into(),
241        }
242    }
243
244    #[test]
245    fn event_struct_fields() {
246        let event = sample_create_event();
247        assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
248        assert_eq!(event.agent, "claude-abc");
249        assert_eq!(event.itc, "itc:AQ");
250        assert!(event.parents.is_empty());
251        assert_eq!(event.event_type, EventType::Create);
252        assert_eq!(event.item_id.as_str(), "bn-a3f8");
253        assert!(matches!(event.data, EventData::Create(_)));
254        assert_eq!(event.event_hash, "blake3:a1b2c3d4e5f6");
255    }
256
257    #[test]
258    fn event_parents_str_empty() {
259        let event = sample_create_event();
260        assert_eq!(event.parents_str(), "");
261    }
262
263    #[test]
264    fn event_parents_str_single() {
265        let event = sample_move_event();
266        assert_eq!(event.parents_str(), "blake3:a1b2c3d4e5f6");
267    }
268
269    #[test]
270    fn event_parents_str_multiple() {
271        let mut event = sample_move_event();
272        event.parents = vec!["blake3:aaa".into(), "blake3:bbb".into()];
273        assert_eq!(event.parents_str(), "blake3:aaa,blake3:bbb");
274    }
275
276    #[test]
277    fn event_display() {
278        let event = sample_create_event();
279        let display = event.to_string();
280        assert!(display.contains("1708012200123456"));
281        assert!(display.contains("claude-abc"));
282        assert!(display.contains("item.create"));
283        assert!(display.contains("bn-a3f8"));
284        assert!(display.contains("Fix auth retry"));
285    }
286
287    #[test]
288    fn event_display_truncates_unicode_comment_without_panicking() {
289        let mut event = sample_create_event();
290        event.event_type = EventType::Comment;
291        event.data = EventData::Comment(CommentData {
292            body: "é".repeat(60),
293            extra: BTreeMap::new(),
294        });
295
296        let display = event.to_string();
297        assert!(display.contains("comment:"));
298        assert!(display.contains("..."));
299    }
300
301    #[test]
302    fn event_serde_json_roundtrip() {
303        let event = sample_create_event();
304        let json = serde_json::to_string(&event).expect("serialize");
305        let deser: Event = serde_json::from_str(&json).expect("deserialize");
306        assert_eq!(event, deser);
307    }
308
309    #[test]
310    fn event_serde_json_roundtrip_with_parents() {
311        let event = sample_move_event();
312        let json = serde_json::to_string(&event).expect("serialize");
313        let deser: Event = serde_json::from_str(&json).expect("deserialize");
314        assert_eq!(event, deser);
315    }
316
317    #[test]
318    fn event_serde_all_types_roundtrip() {
319        let base = || -> (i64, String, String, Vec<String>, ItemId, String) {
320            (
321                1_000_000,
322                "agent".into(),
323                "itc:X".into(),
324                vec![],
325                ItemId::new_unchecked("bn-a7x"),
326                "blake3:000".into(),
327            )
328        };
329
330        let events: Vec<Event> = vec![
331            {
332                let (ts, agent, itc, parents, item_id, hash) = base();
333                Event {
334                    wall_ts_us: ts,
335                    agent,
336                    itc,
337                    parents,
338                    event_type: EventType::Create,
339                    item_id,
340                    data: EventData::Create(CreateData {
341                        title: "T".into(),
342                        kind: crate::model::item::Kind::Task,
343                        size: None,
344                        urgency: crate::model::item::Urgency::Default,
345                        labels: vec![],
346                        parent: None,
347                        causation: None,
348                        description: None,
349                        extra: BTreeMap::new(),
350                    }),
351                    event_hash: hash,
352                }
353            },
354            {
355                let (ts, agent, itc, parents, item_id, hash) = base();
356                Event {
357                    wall_ts_us: ts,
358                    agent,
359                    itc,
360                    parents,
361                    event_type: EventType::Update,
362                    item_id,
363                    data: EventData::Update(UpdateData {
364                        field: "title".into(),
365                        value: json!("New"),
366                        extra: BTreeMap::new(),
367                    }),
368                    event_hash: hash,
369                }
370            },
371            {
372                let (ts, agent, itc, parents, item_id, hash) = base();
373                Event {
374                    wall_ts_us: ts,
375                    agent,
376                    itc,
377                    parents,
378                    event_type: EventType::Move,
379                    item_id,
380                    data: EventData::Move(MoveData {
381                        state: crate::model::item::State::Done,
382                        reason: Some("done".into()),
383                        extra: BTreeMap::new(),
384                    }),
385                    event_hash: hash,
386                }
387            },
388            {
389                let (ts, agent, itc, parents, item_id, hash) = base();
390                Event {
391                    wall_ts_us: ts,
392                    agent,
393                    itc,
394                    parents,
395                    event_type: EventType::Assign,
396                    item_id,
397                    data: EventData::Assign(AssignData {
398                        agent: "alice".into(),
399                        action: AssignAction::Assign,
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::Comment,
413                    item_id,
414                    data: EventData::Comment(CommentData {
415                        body: "Note".into(),
416                        extra: BTreeMap::new(),
417                    }),
418                    event_hash: hash,
419                }
420            },
421            {
422                let (ts, agent, itc, parents, item_id, hash) = base();
423                Event {
424                    wall_ts_us: ts,
425                    agent,
426                    itc,
427                    parents,
428                    event_type: EventType::Link,
429                    item_id,
430                    data: EventData::Link(LinkData {
431                        target: "bn-b8y".into(),
432                        link_type: "blocks".into(),
433                        extra: BTreeMap::new(),
434                    }),
435                    event_hash: hash,
436                }
437            },
438            {
439                let (ts, agent, itc, parents, item_id, hash) = base();
440                Event {
441                    wall_ts_us: ts,
442                    agent,
443                    itc,
444                    parents,
445                    event_type: EventType::Unlink,
446                    item_id,
447                    data: EventData::Unlink(UnlinkData {
448                        target: "bn-b8y".into(),
449                        link_type: 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::Delete,
463                    item_id,
464                    data: EventData::Delete(DeleteData {
465                        reason: None,
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::Compact,
479                    item_id,
480                    data: EventData::Compact(CompactData {
481                        summary: "TL;DR".into(),
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::Snapshot,
495                    item_id,
496                    data: EventData::Snapshot(SnapshotData {
497                        state: json!({"id": "bn-a7x"}),
498                        extra: BTreeMap::new(),
499                    }),
500                    event_hash: hash,
501                }
502            },
503            {
504                let (ts, agent, itc, parents, item_id, hash) = base();
505                Event {
506                    wall_ts_us: ts,
507                    agent,
508                    itc,
509                    parents,
510                    event_type: EventType::Redact,
511                    item_id,
512                    data: EventData::Redact(RedactData {
513                        target_hash: "blake3:xyz".into(),
514                        reason: "oops".into(),
515                        extra: BTreeMap::new(),
516                    }),
517                    event_hash: hash,
518                }
519            },
520        ];
521
522        assert_eq!(events.len(), 11, "should cover all 11 event types");
523
524        for event in &events {
525            let json = serde_json::to_string(event)
526                .unwrap_or_else(|e| panic!("serialize {} failed: {e}", event.event_type));
527            let deser: Event = serde_json::from_str(&json)
528                .unwrap_or_else(|e| panic!("deserialize {} failed: {e}", event.event_type));
529            assert_eq!(*event, deser, "roundtrip failed for {}", event.event_type);
530        }
531    }
532
533    #[test]
534    fn event_display_all_data_types() {
535        // Smoke test: Display doesn't panic for any variant
536        let events = vec![sample_create_event(), sample_move_event()];
537        for event in events {
538            let _ = event.to_string(); // Should not panic
539        }
540    }
541}