Skip to main content

bones_core/crdt/
item_state.rs

1//! `WorkItemState` — composite CRDT aggregating all field-level CRDTs.
2//!
3//! A `WorkItemState` is the mergeable aggregate that the projection layer
4//! materializes and the CLI displays. Each field delegates to the appropriate
5//! CRDT primitive:
6//!
7//! - **LWW** ([`LwwRegister<T>`]): title, description, kind, size, urgency, parent
8//! - **OR-Set** ([`OrSet<String>`]): assignees, labels, `blocked_by`, `related_to`
9//! - **G-Set** ([`GSet<String>`]): comments (event hashes referencing comment content)
10//! - **Epoch+Phase** ([`EpochPhaseState`]): lifecycle state
11//! - **LWW<bool>** ([`LwwRegister<bool>`]): soft-delete flag
12//!
13//! # Merge Semantics
14//!
15//! `merge(a, b)` delegates to each field's CRDT merge. This preserves the
16//! semilattice properties (commutative, associative, idempotent) because
17//! the product of semilattices is itself a semilattice.
18//!
19//! # Event Application
20//!
21//! Given an [`Event`], `apply_event` routes to the correct field based on
22//! the event type and updates the corresponding CRDT with the event's metadata.
23//!
24//! # Snapshot Support
25//!
26//! `to_snapshot` produces a JSON representation with per-field clock metadata
27//! for use during log compaction. `from_snapshot` reconstructs the aggregate
28//! from a snapshot event. Snapshot merge uses lattice join (not overwrite),
29//! so `merge(state, snapshot) == merge(snapshot, state)`.
30
31// Many methods are simple CRDT accessors that benefit from being non-const
32// (they access HashSet which is not const-compatible). Suppress pedantic
33// lints that don't add value for a CRDT module.
34#![allow(
35    clippy::must_use_candidate,
36    clippy::doc_markdown,
37    clippy::use_self,
38    clippy::redundant_closure_for_method_calls,
39    clippy::cast_possible_wrap,
40    clippy::cast_sign_loss,
41    clippy::cast_possible_truncation,
42    clippy::too_many_lines,
43    clippy::redundant_clone,
44    clippy::match_same_arms
45)]
46
47use std::collections::HashSet;
48
49use crate::clock::itc::Stamp;
50use crate::clock::text::stamp_from_text;
51use crate::crdt::OrSet;
52use crate::crdt::gset::GSet;
53use crate::crdt::lww::LwwRegister;
54use crate::crdt::merge::Merge;
55use crate::crdt::state::{EpochPhaseState, Phase};
56use crate::event::Event;
57use crate::event::data::{AssignAction, EventData};
58use crate::event::types::EventType;
59use crate::model::item::{Kind, Size, State, Urgency};
60
61use super::Timestamp;
62
63// ---------------------------------------------------------------------------
64// WorkItemState
65// ---------------------------------------------------------------------------
66
67/// Composite CRDT representing the full state of a work item.
68///
69/// All fields are individually mergeable CRDTs. The aggregate merge
70/// delegates to each field, preserving semilattice laws.
71#[derive(Debug, Clone)]
72pub struct WorkItemState {
73    /// Item title (LWW register).
74    pub title: LwwRegister<String>,
75    /// Item description (LWW register, empty string = no description).
76    pub description: LwwRegister<String>,
77    /// Work item kind (LWW register).
78    pub kind: LwwRegister<Kind>,
79    /// Lifecycle state (epoch+phase CRDT).
80    pub state: EpochPhaseState,
81    /// T-shirt size estimate (LWW register, None encoded as Size::M default).
82    pub size: LwwRegister<Option<Size>>,
83    /// Priority/urgency override (LWW register).
84    pub urgency: LwwRegister<Urgency>,
85    /// Parent item ID (LWW register, empty string = no parent).
86    pub parent: LwwRegister<String>,
87    /// Assigned agents (OR-Set, add-wins).
88    pub assignees: OrSet<String>,
89    /// Labels (OR-Set, add-wins).
90    pub labels: OrSet<String>,
91    /// Blocked-by item IDs (OR-Set, add-wins).
92    pub blocked_by: OrSet<String>,
93    /// Related-to item IDs (OR-Set, add-wins).
94    pub related_to: OrSet<String>,
95    /// Comment event hashes (G-Set, grow-only).
96    pub comments: GSet<String>,
97    /// Soft-delete flag (LWW register).
98    pub deleted: LwwRegister<bool>,
99    /// Wall-clock timestamp of the earliest event (for created_at).
100    pub created_at: u64,
101    /// Wall-clock timestamp of the latest applied event (for updated_at).
102    pub updated_at: u64,
103}
104
105impl WorkItemState {
106    /// Create a new empty `WorkItemState` with default values.
107    ///
108    /// All LWW registers start with a zero stamp (epoch 0, no identity).
109    /// All sets start empty. State starts at epoch 0, phase Open.
110    pub fn new() -> Self {
111        let zero_stamp = Stamp::seed();
112        let zero_ts = 0u64;
113        let zero_agent = String::new();
114        let zero_hash = String::new();
115
116        Self {
117            title: LwwRegister::new(
118                String::new(),
119                zero_stamp.clone(),
120                zero_ts,
121                zero_agent.clone(),
122                zero_hash.clone(),
123            ),
124            description: LwwRegister::new(
125                String::new(),
126                zero_stamp.clone(),
127                zero_ts,
128                zero_agent.clone(),
129                zero_hash.clone(),
130            ),
131            kind: LwwRegister::new(
132                Kind::Task,
133                zero_stamp.clone(),
134                zero_ts,
135                zero_agent.clone(),
136                zero_hash.clone(),
137            ),
138            state: EpochPhaseState::new(),
139            size: LwwRegister::new(
140                None,
141                zero_stamp.clone(),
142                zero_ts,
143                zero_agent.clone(),
144                zero_hash.clone(),
145            ),
146            urgency: LwwRegister::new(
147                Urgency::Default,
148                zero_stamp.clone(),
149                zero_ts,
150                zero_agent.clone(),
151                zero_hash.clone(),
152            ),
153            parent: LwwRegister::new(
154                String::new(),
155                zero_stamp.clone(),
156                zero_ts,
157                zero_agent.clone(),
158                zero_hash.clone(),
159            ),
160            assignees: OrSet::new(),
161            labels: OrSet::new(),
162            blocked_by: OrSet::new(),
163            related_to: OrSet::new(),
164            comments: GSet::new(),
165            deleted: LwwRegister::new(false, zero_stamp, zero_ts, zero_agent, zero_hash),
166            created_at: 0,
167            updated_at: 0,
168        }
169    }
170
171    /// Merge another `WorkItemState` into this one.
172    ///
173    /// Each field delegates to its own CRDT merge. The aggregate merge
174    /// preserves semilattice properties because the product of semilattices
175    /// is a semilattice.
176    pub fn merge(&mut self, other: &WorkItemState) {
177        self.title.merge(&other.title);
178        self.description.merge(&other.description);
179        self.kind.merge(&other.kind);
180        self.state.merge(&other.state);
181        self.size.merge(&other.size);
182        self.urgency.merge(&other.urgency);
183        self.parent.merge(&other.parent);
184
185        // OR-Sets: merge via set union (takes ownership of clone)
186        self.assignees.merge(other.assignees.clone());
187        self.labels.merge(other.labels.clone());
188        self.blocked_by.merge(other.blocked_by.clone());
189        self.related_to.merge(other.related_to.clone());
190
191        // G-Set: merge via set union
192        self.comments.merge(other.comments.clone());
193
194        // Deleted: LWW merge
195        self.deleted.merge(&other.deleted);
196
197        // Timestamps: created_at = min of non-zero, updated_at = max
198        if other.created_at != 0 && (self.created_at == 0 || other.created_at < self.created_at) {
199            self.created_at = other.created_at;
200        }
201        if other.updated_at > self.updated_at {
202            self.updated_at = other.updated_at;
203        }
204    }
205
206    /// Apply an event to this aggregate, updating the appropriate field CRDT.
207    ///
208    /// The event's metadata (wall_ts, agent, event_hash) is used to construct
209    /// the LWW timestamp or OR-Set tag for the update.
210    ///
211    /// Unknown event types and unrecognized update fields are silently ignored
212    /// (no-op), following the principle that invalid events are skipped during
213    /// replay.
214    pub fn apply_event(&mut self, event: &Event) {
215        let wall_ts = event.wall_ts_us as u64;
216
217        // Update created_at / updated_at timestamps.
218        if self.created_at == 0 || wall_ts < self.created_at {
219            self.created_at = wall_ts;
220        }
221        if wall_ts > self.updated_at {
222            self.updated_at = wall_ts;
223        }
224
225        // Build LWW metadata from the event.
226        let stamp = stamp_from_text(&event.itc)
227            .unwrap_or_else(|| derive_stamp_from_hash(&event.event_hash));
228        let agent_id = event.agent.clone();
229        let event_hash = event.event_hash.clone();
230
231        match event.event_type {
232            EventType::Create => {
233                if let EventData::Create(data) = &event.data {
234                    self.title = LwwRegister::new(
235                        data.title.clone(),
236                        stamp.clone(),
237                        wall_ts,
238                        agent_id.clone(),
239                        event_hash.clone(),
240                    );
241                    self.kind = LwwRegister::new(
242                        data.kind,
243                        stamp.clone(),
244                        wall_ts,
245                        agent_id.clone(),
246                        event_hash.clone(),
247                    );
248                    if let Some(size) = data.size {
249                        self.size = LwwRegister::new(
250                            Some(size),
251                            stamp.clone(),
252                            wall_ts,
253                            agent_id.clone(),
254                            event_hash.clone(),
255                        );
256                    }
257                    self.urgency = LwwRegister::new(
258                        data.urgency,
259                        stamp.clone(),
260                        wall_ts,
261                        agent_id.clone(),
262                        event_hash.clone(),
263                    );
264                    if let Some(desc) = &data.description {
265                        self.description = LwwRegister::new(
266                            desc.clone(),
267                            stamp.clone(),
268                            wall_ts,
269                            agent_id.clone(),
270                            event_hash.clone(),
271                        );
272                    }
273                    if let Some(parent) = &data.parent {
274                        self.parent = LwwRegister::new(
275                            parent.clone(),
276                            stamp.clone(),
277                            wall_ts,
278                            agent_id.clone(),
279                            event_hash.clone(),
280                        );
281                    }
282                    // Apply initial labels via OR-Set.
283                    for label in &data.labels {
284                        let tag = make_orset_tag(wall_ts, &agent_id, &event_hash, label);
285                        self.labels.add(label.clone(), tag);
286                    }
287                }
288            }
289
290            EventType::Update => {
291                if let EventData::Update(data) = &event.data {
292                    match data.field.as_str() {
293                        "title" => {
294                            if let Some(s) = data.value.as_str() {
295                                self.title = LwwRegister::new(
296                                    s.to_string(),
297                                    stamp,
298                                    wall_ts,
299                                    agent_id,
300                                    event_hash,
301                                );
302                            }
303                        }
304                        "description" => {
305                            let desc = data
306                                .value
307                                .as_str()
308                                .map(|s| s.to_string())
309                                .unwrap_or_default();
310                            self.description =
311                                LwwRegister::new(desc, stamp, wall_ts, agent_id, event_hash);
312                        }
313                        "kind" => {
314                            if let Some(kind) =
315                                data.value.as_str().and_then(|s| s.parse::<Kind>().ok())
316                            {
317                                self.kind =
318                                    LwwRegister::new(kind, stamp, wall_ts, agent_id, event_hash);
319                            }
320                        }
321                        "size" => {
322                            let size = data.value.as_str().and_then(|s| s.parse::<Size>().ok());
323                            self.size =
324                                LwwRegister::new(size, stamp, wall_ts, agent_id, event_hash);
325                        }
326                        "urgency" => {
327                            if let Some(urgency) =
328                                data.value.as_str().and_then(|s| s.parse::<Urgency>().ok())
329                            {
330                                self.urgency =
331                                    LwwRegister::new(urgency, stamp, wall_ts, agent_id, event_hash);
332                            }
333                        }
334                        "parent" => {
335                            let parent = data
336                                .value
337                                .as_str()
338                                .map(|s| s.to_string())
339                                .unwrap_or_default();
340                            self.parent =
341                                LwwRegister::new(parent, stamp, wall_ts, agent_id, event_hash);
342                        }
343                        "labels" => {
344                            // Labels update via OR-Set add/remove encoded in value.
345                            if let Some(obj) = data.value.as_object() {
346                                let action =
347                                    obj.get("action").and_then(|v| v.as_str()).unwrap_or("");
348                                let label = obj
349                                    .get("label")
350                                    .and_then(|v| v.as_str())
351                                    .unwrap_or("")
352                                    .to_string();
353
354                                if !label.is_empty() {
355                                    match action {
356                                        "add" => {
357                                            let tag = make_orset_tag(
358                                                wall_ts,
359                                                &agent_id,
360                                                &event_hash,
361                                                &label,
362                                            );
363                                            self.labels.add(label, tag);
364                                        }
365                                        "remove" => {
366                                            self.labels.remove(&label);
367                                        }
368                                        _ => {} // Unknown action — no-op.
369                                    }
370                                }
371                            }
372                        }
373                        _ => {} // Unknown field — no-op.
374                    }
375                }
376            }
377
378            EventType::Move => {
379                if let EventData::Move(data) = &event.data {
380                    // Map the model::item::State to crdt::state::Phase.
381                    let target_phase = state_to_phase(data.state);
382                    apply_phase_transition(&mut self.state, target_phase);
383                }
384            }
385
386            EventType::Assign => {
387                if let EventData::Assign(data) = &event.data {
388                    match data.action {
389                        AssignAction::Assign => {
390                            let tag = make_orset_tag(wall_ts, &agent_id, &event_hash, &data.agent);
391                            self.assignees.add(data.agent.clone(), tag);
392                        }
393                        AssignAction::Unassign => {
394                            self.assignees.remove(&data.agent);
395                        }
396                    }
397                }
398            }
399
400            EventType::Comment => {
401                if let EventData::Comment(_) = &event.data {
402                    // Add the event hash as a comment reference.
403                    self.comments.insert(event.event_hash.clone());
404                }
405            }
406
407            EventType::Link => {
408                if let EventData::Link(data) = &event.data {
409                    let tag = make_orset_tag(wall_ts, &agent_id, &event_hash, &data.target);
410                    match data.link_type.as_str() {
411                        "blocks" | "blocked_by" => {
412                            self.blocked_by.add(data.target.clone(), tag);
413                        }
414                        "related_to" | "related" => {
415                            self.related_to.add(data.target.clone(), tag);
416                        }
417                        _ => {} // Unknown link type — no-op.
418                    }
419                }
420            }
421
422            EventType::Unlink => {
423                if let EventData::Unlink(data) = &event.data {
424                    let is_blocked = data
425                        .link_type
426                        .as_ref()
427                        .is_none_or(|lt| lt == "blocks" || lt == "blocked_by");
428                    let is_related = data
429                        .link_type
430                        .as_ref()
431                        .is_none_or(|lt| lt == "related_to" || lt == "related");
432
433                    if is_blocked {
434                        self.blocked_by.remove(&data.target);
435                    }
436                    if is_related {
437                        self.related_to.remove(&data.target);
438                    }
439                }
440            }
441
442            EventType::Delete => {
443                // Set deleted flag via LWW.
444                self.deleted = LwwRegister::new(true, stamp, wall_ts, agent_id, event_hash);
445            }
446
447            EventType::Compact => {
448                if let EventData::Compact(data) = &event.data {
449                    // Replace description with summary.
450                    self.description = LwwRegister::new(
451                        data.summary.clone(),
452                        stamp,
453                        wall_ts,
454                        agent_id,
455                        event_hash,
456                    );
457                }
458            }
459
460            EventType::Snapshot => {
461                // Snapshot application is handled via from_snapshot + merge,
462                // not via apply_event. This is intentionally a no-op here.
463                // Callers should use WorkItemState::from_snapshot() and merge.
464            }
465
466            EventType::Redact => {
467                // Redaction targets a prior event — handled at the projection
468                // level by filtering event hashes. No CRDT state change.
469            }
470        }
471    }
472
473    /// Check if this item is soft-deleted.
474    pub const fn is_deleted(&self) -> bool {
475        self.deleted.value
476    }
477
478    /// Return the current lifecycle phase.
479    pub const fn phase(&self) -> Phase {
480        self.state.phase
481    }
482
483    /// Return the current epoch.
484    pub const fn epoch(&self) -> u64 {
485        self.state.epoch
486    }
487
488    /// Return the set of current assignee names.
489    pub fn assignee_names(&self) -> HashSet<&String> {
490        self.assignees.values()
491    }
492
493    /// Return the set of current label strings.
494    pub fn label_names(&self) -> HashSet<&String> {
495        self.labels.values()
496    }
497
498    /// Return the set of items blocking this one.
499    pub fn blocked_by_ids(&self) -> HashSet<&String> {
500        self.blocked_by.values()
501    }
502
503    /// Return the set of related item IDs.
504    pub fn related_to_ids(&self) -> HashSet<&String> {
505        self.related_to.values()
506    }
507
508    /// Return comment event hashes.
509    pub const fn comment_hashes(&self) -> &HashSet<String> {
510        &self.comments.elements
511    }
512}
513
514impl Default for WorkItemState {
515    fn default() -> Self {
516        Self::new()
517    }
518}
519
520// ---------------------------------------------------------------------------
521// Helpers
522// ---------------------------------------------------------------------------
523
524/// Map a `model::item::State` to a `crdt::state::Phase`.
525const fn state_to_phase(state: State) -> Phase {
526    match state {
527        State::Open => Phase::Open,
528        State::Doing => Phase::Doing,
529        State::Done => Phase::Done,
530        State::Archived => Phase::Archived,
531    }
532}
533
534/// Apply a phase transition, handling epoch increments for reopen.
535///
536/// If the target phase is Open and the current phase is beyond Open,
537/// this triggers a reopen (epoch increment). Otherwise it advances
538/// within the current epoch. If the advance is invalid (backward move
539/// within epoch), we force via reopen.
540fn apply_phase_transition(state: &mut EpochPhaseState, target: Phase) {
541    if target == Phase::Open && state.phase > Phase::Open {
542        // Reopen: increment epoch.
543        state.reopen();
544    } else if target > state.phase {
545        // Forward transition within epoch.
546        let _ = state.advance(target);
547    } else if target < state.phase && target != Phase::Open {
548        // Backward move (e.g., Done -> Doing) requires reopen then advance.
549        state.reopen();
550        let _ = state.advance(target);
551    }
552    // target == state.phase is a no-op.
553}
554
555/// Derive a unique fallback stamp from event hash.
556///
557/// Older events may carry non-decodable legacy ITC text. In that case,
558/// we preserve deterministic replay by deriving a stable fallback stamp
559/// from `event_hash`.
560fn derive_stamp_from_hash(event_hash: &str) -> Stamp {
561    use std::hash::{Hash, Hasher};
562    let mut hasher = std::collections::hash_map::DefaultHasher::new();
563    event_hash.hash(&mut hasher);
564    let bits = hasher.finish();
565
566    // Fork the seed stamp along a path determined by hash bits.
567    // 8 levels of forking gives 256 distinct stamp topologies,
568    // making any two different hashes almost certainly produce
569    // concurrent (incomparable) stamps.
570    let mut stamp = Stamp::seed();
571    for i in 0..8 {
572        let (left, right) = stamp.fork();
573        stamp = if (bits >> i) & 1 == 0 { left } else { right };
574    }
575    stamp.event();
576    stamp
577}
578
579/// Construct an OR-Set tag (Timestamp) from event metadata.
580///
581/// Uses wall_ts as the time, and hashes the agent/event_hash/suffix
582/// to deterministic u64 fields.
583fn make_orset_tag(wall_ts: u64, agent: &str, event_hash: &str, suffix: &str) -> Timestamp {
584    use chrono::TimeZone;
585    use std::hash::{Hash, Hasher};
586
587    let secs = wall_ts / 1_000_000;
588    let nsecs = ((wall_ts % 1_000_000) * 1_000) as u32;
589    let wall = chrono::Utc
590        .timestamp_opt(secs as i64, nsecs)
591        .single()
592        .unwrap_or_else(chrono::Utc::now);
593
594    let mut hasher = std::collections::hash_map::DefaultHasher::new();
595    agent.hash(&mut hasher);
596    let actor = hasher.finish();
597
598    let mut hasher = std::collections::hash_map::DefaultHasher::new();
599    event_hash.hash(&mut hasher);
600    suffix.hash(&mut hasher);
601    let event_hash_u64 = hasher.finish();
602
603    Timestamp {
604        wall,
605        actor,
606        event_hash: event_hash_u64,
607        itc: wall_ts,
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Tests
613// ---------------------------------------------------------------------------
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::clock::itc::Stamp;
619    use crate::event::Event;
620    use crate::event::data::*;
621    use crate::event::types::EventType;
622    use crate::model::item::{Kind, Size, State, Urgency};
623    use crate::model::item_id::ItemId;
624    use std::collections::BTreeMap;
625
626    // -----------------------------------------------------------------------
627    // Test helpers
628    // -----------------------------------------------------------------------
629
630    fn make_event(
631        event_type: EventType,
632        data: EventData,
633        wall_ts_us: i64,
634        agent: &str,
635        event_hash: &str,
636    ) -> Event {
637        let mut stamp = Stamp::seed();
638        stamp.event();
639        Event {
640            wall_ts_us,
641            agent: agent.to_string(),
642            itc: stamp.to_string(),
643            parents: vec![],
644            event_type,
645            item_id: ItemId::new_unchecked("bn-test1"),
646            data,
647            event_hash: event_hash.to_string(),
648        }
649    }
650
651    fn create_event(title: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
652        make_event(
653            EventType::Create,
654            EventData::Create(CreateData {
655                title: title.to_string(),
656                kind: Kind::Task,
657                size: Some(Size::M),
658                urgency: Urgency::Default,
659                labels: vec!["backend".to_string()],
660                parent: None,
661                causation: None,
662                description: Some("A description".to_string()),
663                extra: BTreeMap::new(),
664            }),
665            wall_ts,
666            agent,
667            hash,
668        )
669    }
670
671    fn update_title_event(title: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
672        make_event(
673            EventType::Update,
674            EventData::Update(UpdateData {
675                field: "title".to_string(),
676                value: serde_json::Value::String(title.to_string()),
677                extra: BTreeMap::new(),
678            }),
679            wall_ts,
680            agent,
681            hash,
682        )
683    }
684
685    fn move_event(state: State, wall_ts: i64, agent: &str, hash: &str) -> Event {
686        make_event(
687            EventType::Move,
688            EventData::Move(MoveData {
689                state,
690                reason: None,
691                extra: BTreeMap::new(),
692            }),
693            wall_ts,
694            agent,
695            hash,
696        )
697    }
698
699    fn assign_event(
700        target_agent: &str,
701        action: AssignAction,
702        wall_ts: i64,
703        agent: &str,
704        hash: &str,
705    ) -> Event {
706        make_event(
707            EventType::Assign,
708            EventData::Assign(AssignData {
709                agent: target_agent.to_string(),
710                action,
711                extra: BTreeMap::new(),
712            }),
713            wall_ts,
714            agent,
715            hash,
716        )
717    }
718
719    fn comment_event(body: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
720        make_event(
721            EventType::Comment,
722            EventData::Comment(CommentData {
723                body: body.to_string(),
724                extra: BTreeMap::new(),
725            }),
726            wall_ts,
727            agent,
728            hash,
729        )
730    }
731
732    fn link_event(target: &str, link_type: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
733        make_event(
734            EventType::Link,
735            EventData::Link(LinkData {
736                target: target.to_string(),
737                link_type: link_type.to_string(),
738                extra: BTreeMap::new(),
739            }),
740            wall_ts,
741            agent,
742            hash,
743        )
744    }
745
746    fn unlink_event(
747        target: &str,
748        link_type: Option<&str>,
749        wall_ts: i64,
750        agent: &str,
751        hash: &str,
752    ) -> Event {
753        make_event(
754            EventType::Unlink,
755            EventData::Unlink(UnlinkData {
756                target: target.to_string(),
757                link_type: link_type.map(|s| s.to_string()),
758                extra: BTreeMap::new(),
759            }),
760            wall_ts,
761            agent,
762            hash,
763        )
764    }
765
766    fn delete_event(wall_ts: i64, agent: &str, hash: &str) -> Event {
767        make_event(
768            EventType::Delete,
769            EventData::Delete(DeleteData {
770                reason: Some("duplicate".to_string()),
771                extra: BTreeMap::new(),
772            }),
773            wall_ts,
774            agent,
775            hash,
776        )
777    }
778
779    fn compact_event(summary: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
780        make_event(
781            EventType::Compact,
782            EventData::Compact(CompactData {
783                summary: summary.to_string(),
784                extra: BTreeMap::new(),
785            }),
786            wall_ts,
787            agent,
788            hash,
789        )
790    }
791
792    fn label_add_event(label: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
793        make_event(
794            EventType::Update,
795            EventData::Update(UpdateData {
796                field: "labels".to_string(),
797                value: serde_json::json!({"action": "add", "label": label}),
798                extra: BTreeMap::new(),
799            }),
800            wall_ts,
801            agent,
802            hash,
803        )
804    }
805
806    fn label_remove_event(label: &str, wall_ts: i64, agent: &str, hash: &str) -> Event {
807        make_event(
808            EventType::Update,
809            EventData::Update(UpdateData {
810                field: "labels".to_string(),
811                value: serde_json::json!({"action": "remove", "label": label}),
812                extra: BTreeMap::new(),
813            }),
814            wall_ts,
815            agent,
816            hash,
817        )
818    }
819
820    // -----------------------------------------------------------------------
821    // Default state
822    // -----------------------------------------------------------------------
823
824    #[test]
825    fn default_state_is_empty() {
826        let state = WorkItemState::new();
827        assert_eq!(state.title.value, "");
828        assert_eq!(state.description.value, "");
829        assert_eq!(state.kind.value, Kind::Task);
830        assert_eq!(state.state, EpochPhaseState::new());
831        assert_eq!(state.size.value, None);
832        assert_eq!(state.urgency.value, Urgency::Default);
833        assert_eq!(state.parent.value, "");
834        assert!(state.assignees.is_empty());
835        assert!(state.labels.is_empty());
836        assert!(state.blocked_by.is_empty());
837        assert!(state.related_to.is_empty());
838        assert!(state.comments.is_empty());
839        assert!(!state.is_deleted());
840        assert_eq!(state.created_at, 0);
841        assert_eq!(state.updated_at, 0);
842    }
843
844    #[test]
845    fn default_impl_matches_new() {
846        let a = WorkItemState::new();
847        let b = WorkItemState::default();
848        // Compare field by field since we don't impl PartialEq on the whole struct.
849        assert_eq!(a.title.value, b.title.value);
850        assert_eq!(a.state, b.state);
851        assert_eq!(a.created_at, b.created_at);
852    }
853
854    // -----------------------------------------------------------------------
855    // Event application: Create
856    // -----------------------------------------------------------------------
857
858    #[test]
859    fn apply_create_sets_fields() {
860        let mut state = WorkItemState::new();
861        let event = create_event("Fix auth", 1000, "alice", "blake3:create1");
862        state.apply_event(&event);
863
864        assert_eq!(state.title.value, "Fix auth");
865        assert_eq!(state.kind.value, Kind::Task);
866        assert_eq!(state.size.value, Some(Size::M));
867        assert_eq!(state.urgency.value, Urgency::Default);
868        assert_eq!(state.description.value, "A description");
869        assert!(state.label_names().contains(&"backend".to_string()));
870        assert_eq!(state.created_at, 1000);
871        assert_eq!(state.updated_at, 1000);
872    }
873
874    // -----------------------------------------------------------------------
875    // Event application: Update
876    // -----------------------------------------------------------------------
877
878    #[test]
879    fn apply_update_title() {
880        let mut state = WorkItemState::new();
881        state.apply_event(&create_event("Old", 1000, "alice", "blake3:c1"));
882        state.apply_event(&update_title_event("New Title", 2000, "alice", "blake3:u1"));
883        assert_eq!(state.title.value, "New Title");
884    }
885
886    #[test]
887    fn apply_update_description() {
888        let mut state = WorkItemState::new();
889        let event = make_event(
890            EventType::Update,
891            EventData::Update(UpdateData {
892                field: "description".to_string(),
893                value: serde_json::Value::String("Updated desc".to_string()),
894                extra: BTreeMap::new(),
895            }),
896            2000,
897            "alice",
898            "blake3:u2",
899        );
900        state.apply_event(&event);
901        assert_eq!(state.description.value, "Updated desc");
902    }
903
904    #[test]
905    fn apply_update_kind() {
906        let mut state = WorkItemState::new();
907        let event = make_event(
908            EventType::Update,
909            EventData::Update(UpdateData {
910                field: "kind".to_string(),
911                value: serde_json::Value::String("bug".to_string()),
912                extra: BTreeMap::new(),
913            }),
914            2000,
915            "alice",
916            "blake3:u3",
917        );
918        state.apply_event(&event);
919        assert_eq!(state.kind.value, Kind::Bug);
920    }
921
922    #[test]
923    fn apply_update_size() {
924        let mut state = WorkItemState::new();
925        let event = make_event(
926            EventType::Update,
927            EventData::Update(UpdateData {
928                field: "size".to_string(),
929                value: serde_json::Value::String("xl".to_string()),
930                extra: BTreeMap::new(),
931            }),
932            2000,
933            "alice",
934            "blake3:u4",
935        );
936        state.apply_event(&event);
937        assert_eq!(state.size.value, Some(Size::Xl));
938    }
939
940    #[test]
941    fn apply_update_urgency() {
942        let mut state = WorkItemState::new();
943        let event = make_event(
944            EventType::Update,
945            EventData::Update(UpdateData {
946                field: "urgency".to_string(),
947                value: serde_json::Value::String("urgent".to_string()),
948                extra: BTreeMap::new(),
949            }),
950            2000,
951            "alice",
952            "blake3:u5",
953        );
954        state.apply_event(&event);
955        assert_eq!(state.urgency.value, Urgency::Urgent);
956    }
957
958    #[test]
959    fn apply_update_parent() {
960        let mut state = WorkItemState::new();
961        let event = make_event(
962            EventType::Update,
963            EventData::Update(UpdateData {
964                field: "parent".to_string(),
965                value: serde_json::Value::String("bn-parent1".to_string()),
966                extra: BTreeMap::new(),
967            }),
968            2000,
969            "alice",
970            "blake3:u6",
971        );
972        state.apply_event(&event);
973        assert_eq!(state.parent.value, "bn-parent1");
974    }
975
976    #[test]
977    fn apply_update_labels_add_remove() {
978        let mut state = WorkItemState::new();
979        state.apply_event(&label_add_event("frontend", 1000, "alice", "blake3:la1"));
980        assert!(state.label_names().contains(&"frontend".to_string()));
981
982        state.apply_event(&label_add_event("urgent", 2000, "alice", "blake3:la2"));
983        assert_eq!(state.labels.len(), 2);
984
985        state.apply_event(&label_remove_event("frontend", 3000, "alice", "blake3:lr1"));
986        assert!(!state.label_names().contains(&"frontend".to_string()));
987        assert!(state.label_names().contains(&"urgent".to_string()));
988    }
989
990    #[test]
991    fn apply_update_unknown_field_is_noop() {
992        let mut state = WorkItemState::new();
993        let event = make_event(
994            EventType::Update,
995            EventData::Update(UpdateData {
996                field: "nonexistent_field".to_string(),
997                value: serde_json::Value::String("whatever".to_string()),
998                extra: BTreeMap::new(),
999            }),
1000            2000,
1001            "alice",
1002            "blake3:u7",
1003        );
1004        let before_title = state.title.value.clone();
1005        state.apply_event(&event);
1006        assert_eq!(state.title.value, before_title);
1007    }
1008
1009    // -----------------------------------------------------------------------
1010    // Event application: Move
1011    // -----------------------------------------------------------------------
1012
1013    #[test]
1014    fn apply_move_forward() {
1015        let mut state = WorkItemState::new();
1016        state.apply_event(&move_event(State::Doing, 1000, "alice", "blake3:m1"));
1017        assert_eq!(state.phase(), Phase::Doing);
1018
1019        state.apply_event(&move_event(State::Done, 2000, "alice", "blake3:m2"));
1020        assert_eq!(state.phase(), Phase::Done);
1021    }
1022
1023    #[test]
1024    fn apply_move_reopen() {
1025        let mut state = WorkItemState::new();
1026        state.apply_event(&move_event(State::Done, 1000, "alice", "blake3:m1"));
1027        assert_eq!(state.phase(), Phase::Done);
1028        assert_eq!(state.epoch(), 0);
1029
1030        state.apply_event(&move_event(State::Open, 2000, "alice", "blake3:m2"));
1031        assert_eq!(state.phase(), Phase::Open);
1032        assert_eq!(state.epoch(), 1);
1033    }
1034
1035    #[test]
1036    fn apply_move_archived_then_reopen() {
1037        let mut state = WorkItemState::new();
1038        state.apply_event(&move_event(State::Done, 1000, "alice", "blake3:m1"));
1039        state.apply_event(&move_event(State::Archived, 2000, "alice", "blake3:m2"));
1040        assert_eq!(state.phase(), Phase::Archived);
1041        assert_eq!(state.epoch(), 0);
1042
1043        state.apply_event(&move_event(State::Open, 3000, "alice", "blake3:m3"));
1044        assert_eq!(state.phase(), Phase::Open);
1045        assert_eq!(state.epoch(), 1);
1046    }
1047
1048    // -----------------------------------------------------------------------
1049    // Event application: Assign
1050    // -----------------------------------------------------------------------
1051
1052    #[test]
1053    fn apply_assign_and_unassign() {
1054        let mut state = WorkItemState::new();
1055        state.apply_event(&assign_event(
1056            "alice",
1057            AssignAction::Assign,
1058            1000,
1059            "admin",
1060            "blake3:a1",
1061        ));
1062        assert!(state.assignee_names().contains(&"alice".to_string()));
1063
1064        state.apply_event(&assign_event(
1065            "bob",
1066            AssignAction::Assign,
1067            2000,
1068            "admin",
1069            "blake3:a2",
1070        ));
1071        assert_eq!(state.assignees.len(), 2);
1072
1073        state.apply_event(&assign_event(
1074            "alice",
1075            AssignAction::Unassign,
1076            3000,
1077            "admin",
1078            "blake3:a3",
1079        ));
1080        assert!(!state.assignee_names().contains(&"alice".to_string()));
1081        assert!(state.assignee_names().contains(&"bob".to_string()));
1082    }
1083
1084    // -----------------------------------------------------------------------
1085    // Event application: Comment
1086    // -----------------------------------------------------------------------
1087
1088    #[test]
1089    fn apply_comment_adds_to_gset() {
1090        let mut state = WorkItemState::new();
1091        state.apply_event(&comment_event("hello", 1000, "alice", "blake3:c1"));
1092        state.apply_event(&comment_event("world", 2000, "bob", "blake3:c2"));
1093
1094        assert_eq!(state.comments.len(), 2);
1095        assert!(state.comment_hashes().contains("blake3:c1"));
1096        assert!(state.comment_hashes().contains("blake3:c2"));
1097    }
1098
1099    #[test]
1100    fn apply_duplicate_comment_is_idempotent() {
1101        let mut state = WorkItemState::new();
1102        let event = comment_event("hello", 1000, "alice", "blake3:c1");
1103        state.apply_event(&event);
1104        state.apply_event(&event);
1105        assert_eq!(state.comments.len(), 1);
1106    }
1107
1108    // -----------------------------------------------------------------------
1109    // Event application: Link/Unlink
1110    // -----------------------------------------------------------------------
1111
1112    #[test]
1113    fn apply_link_blocks() {
1114        let mut state = WorkItemState::new();
1115        state.apply_event(&link_event(
1116            "bn-blocker",
1117            "blocks",
1118            1000,
1119            "alice",
1120            "blake3:l1",
1121        ));
1122        assert!(state.blocked_by_ids().contains(&"bn-blocker".to_string()));
1123    }
1124
1125    #[test]
1126    fn apply_link_related() {
1127        let mut state = WorkItemState::new();
1128        state.apply_event(&link_event(
1129            "bn-related",
1130            "related_to",
1131            1000,
1132            "alice",
1133            "blake3:l2",
1134        ));
1135        assert!(state.related_to_ids().contains(&"bn-related".to_string()));
1136    }
1137
1138    #[test]
1139    fn apply_unlink_blocks() {
1140        let mut state = WorkItemState::new();
1141        state.apply_event(&link_event("bn-b1", "blocks", 1000, "alice", "blake3:l1"));
1142        assert!(!state.blocked_by.is_empty());
1143
1144        state.apply_event(&unlink_event(
1145            "bn-b1",
1146            Some("blocks"),
1147            2000,
1148            "alice",
1149            "blake3:ul1",
1150        ));
1151        assert!(state.blocked_by.is_empty());
1152    }
1153
1154    #[test]
1155    fn apply_unlink_related() {
1156        let mut state = WorkItemState::new();
1157        state.apply_event(&link_event(
1158            "bn-r1",
1159            "related_to",
1160            1000,
1161            "alice",
1162            "blake3:l1",
1163        ));
1164        state.apply_event(&unlink_event(
1165            "bn-r1",
1166            Some("related_to"),
1167            2000,
1168            "alice",
1169            "blake3:ul1",
1170        ));
1171        assert!(state.related_to.is_empty());
1172    }
1173
1174    // -----------------------------------------------------------------------
1175    // Event application: Delete
1176    // -----------------------------------------------------------------------
1177
1178    #[test]
1179    fn apply_delete_sets_flag() {
1180        let mut state = WorkItemState::new();
1181        assert!(!state.is_deleted());
1182
1183        state.apply_event(&delete_event(1000, "alice", "blake3:d1"));
1184        assert!(state.is_deleted());
1185    }
1186
1187    // -----------------------------------------------------------------------
1188    // Event application: Compact
1189    // -----------------------------------------------------------------------
1190
1191    #[test]
1192    fn apply_compact_replaces_description() {
1193        let mut state = WorkItemState::new();
1194        state.apply_event(&create_event("Title", 1000, "alice", "blake3:c1"));
1195        assert_eq!(state.description.value, "A description");
1196
1197        state.apply_event(&compact_event("TL;DR summary", 2000, "alice", "blake3:cp1"));
1198        assert_eq!(state.description.value, "TL;DR summary");
1199    }
1200
1201    // -----------------------------------------------------------------------
1202    // Event application: Timestamps
1203    // -----------------------------------------------------------------------
1204
1205    #[test]
1206    fn timestamps_track_min_max() {
1207        let mut state = WorkItemState::new();
1208        state.apply_event(&create_event("T", 5000, "alice", "blake3:c1"));
1209        assert_eq!(state.created_at, 5000);
1210        assert_eq!(state.updated_at, 5000);
1211
1212        state.apply_event(&update_title_event("T2", 3000, "bob", "blake3:u1"));
1213        assert_eq!(state.created_at, 3000); // min
1214        assert_eq!(state.updated_at, 5000); // still max
1215
1216        state.apply_event(&update_title_event("T3", 8000, "carol", "blake3:u2"));
1217        assert_eq!(state.created_at, 3000);
1218        assert_eq!(state.updated_at, 8000);
1219    }
1220
1221    // -----------------------------------------------------------------------
1222    // Merge: field delegation
1223    // -----------------------------------------------------------------------
1224
1225    #[test]
1226    fn merge_lww_fields() {
1227        let mut a = WorkItemState::new();
1228        a.apply_event(&create_event("Title A", 1000, "alice", "blake3:a1"));
1229
1230        let mut b = WorkItemState::new();
1231        b.apply_event(&create_event("Title B", 2000, "bob", "blake3:b1"));
1232
1233        a.merge(&b);
1234        // b has higher wall_ts → b's title wins.
1235        assert_eq!(a.title.value, "Title B");
1236    }
1237
1238    #[test]
1239    fn merge_epoch_phase() {
1240        let mut a = WorkItemState::new();
1241        a.apply_event(&move_event(State::Doing, 1000, "alice", "blake3:m1"));
1242
1243        let mut b = WorkItemState::new();
1244        b.apply_event(&move_event(State::Done, 2000, "bob", "blake3:m2"));
1245
1246        a.merge(&b);
1247        // Both epoch 0, Done > Doing.
1248        assert_eq!(a.phase(), Phase::Done);
1249    }
1250
1251    #[test]
1252    fn merge_epoch_phase_reopen_wins() {
1253        let mut a = WorkItemState::new();
1254        a.apply_event(&move_event(State::Done, 1000, "alice", "blake3:m1"));
1255
1256        let mut b = WorkItemState::new();
1257        b.apply_event(&move_event(State::Done, 1000, "bob", "blake3:m2"));
1258        b.apply_event(&move_event(State::Open, 2000, "bob", "blake3:m3"));
1259
1260        a.merge(&b);
1261        // b has epoch 1, a has epoch 0. Higher epoch wins.
1262        assert_eq!(a.epoch(), 1);
1263        assert_eq!(a.phase(), Phase::Open);
1264    }
1265
1266    #[test]
1267    fn merge_orset_assignees() {
1268        let mut a = WorkItemState::new();
1269        a.apply_event(&assign_event(
1270            "alice",
1271            AssignAction::Assign,
1272            1000,
1273            "admin",
1274            "blake3:a1",
1275        ));
1276
1277        let mut b = WorkItemState::new();
1278        b.apply_event(&assign_event(
1279            "bob",
1280            AssignAction::Assign,
1281            1000,
1282            "admin",
1283            "blake3:a2",
1284        ));
1285
1286        a.merge(&b);
1287        assert!(a.assignee_names().contains(&"alice".to_string()));
1288        assert!(a.assignee_names().contains(&"bob".to_string()));
1289    }
1290
1291    #[test]
1292    fn merge_gset_comments() {
1293        let mut a = WorkItemState::new();
1294        a.apply_event(&comment_event("c1", 1000, "alice", "blake3:c1"));
1295
1296        let mut b = WorkItemState::new();
1297        b.apply_event(&comment_event("c2", 2000, "bob", "blake3:c2"));
1298
1299        a.merge(&b);
1300        assert_eq!(a.comments.len(), 2);
1301        assert!(a.comment_hashes().contains("blake3:c1"));
1302        assert!(a.comment_hashes().contains("blake3:c2"));
1303    }
1304
1305    #[test]
1306    fn merge_deleted_lww() {
1307        let mut a = WorkItemState::new();
1308        // a is not deleted.
1309
1310        let mut b = WorkItemState::new();
1311        b.apply_event(&delete_event(2000, "bob", "blake3:d1"));
1312
1313        a.merge(&b);
1314        // b's delete has higher wall_ts → deleted wins.
1315        assert!(a.is_deleted());
1316    }
1317
1318    #[test]
1319    fn merge_timestamps() {
1320        let mut a = WorkItemState::new();
1321        a.apply_event(&create_event("A", 5000, "alice", "blake3:a1"));
1322
1323        let mut b = WorkItemState::new();
1324        b.apply_event(&create_event("B", 3000, "bob", "blake3:b1"));
1325        b.apply_event(&update_title_event("B2", 8000, "bob", "blake3:b2"));
1326
1327        a.merge(&b);
1328        assert_eq!(a.created_at, 3000);
1329        assert_eq!(a.updated_at, 8000);
1330    }
1331
1332    // -----------------------------------------------------------------------
1333    // Merge: semilattice properties
1334    // -----------------------------------------------------------------------
1335
1336    fn make_state_a() -> WorkItemState {
1337        let mut s = WorkItemState::new();
1338        s.apply_event(&create_event("Title A", 1000, "alice", "blake3:a1"));
1339        s.apply_event(&move_event(State::Doing, 2000, "alice", "blake3:a2"));
1340        s.apply_event(&assign_event(
1341            "alice",
1342            AssignAction::Assign,
1343            3000,
1344            "admin",
1345            "blake3:a3",
1346        ));
1347        s.apply_event(&comment_event("comment a", 4000, "alice", "blake3:a4"));
1348        s.apply_event(&link_event("bn-b1", "blocks", 5000, "alice", "blake3:a5"));
1349        s
1350    }
1351
1352    fn make_state_b() -> WorkItemState {
1353        let mut s = WorkItemState::new();
1354        s.apply_event(&create_event("Title B", 1500, "bob", "blake3:b1"));
1355        s.apply_event(&move_event(State::Done, 2500, "bob", "blake3:b2"));
1356        s.apply_event(&assign_event(
1357            "bob",
1358            AssignAction::Assign,
1359            3500,
1360            "admin",
1361            "blake3:b3",
1362        ));
1363        s.apply_event(&comment_event("comment b", 4500, "bob", "blake3:b4"));
1364        s.apply_event(&link_event("bn-r1", "related_to", 5500, "bob", "blake3:b5"));
1365        s
1366    }
1367
1368    fn make_state_c() -> WorkItemState {
1369        let mut s = WorkItemState::new();
1370        s.apply_event(&create_event("Title C", 1200, "carol", "blake3:c1"));
1371        s.apply_event(&assign_event(
1372            "carol",
1373            AssignAction::Assign,
1374            3200,
1375            "admin",
1376            "blake3:c3",
1377        ));
1378        s.apply_event(&label_add_event("urgent", 4200, "carol", "blake3:c4"));
1379        s
1380    }
1381
1382    /// Compare two WorkItemStates for equivalence (all fields).
1383    fn states_equal(a: &WorkItemState, b: &WorkItemState) -> bool {
1384        a.title.value == b.title.value
1385            && a.title.wall_ts == b.title.wall_ts
1386            && a.description.value == b.description.value
1387            && a.kind.value == b.kind.value
1388            && a.state == b.state
1389            && a.size.value == b.size.value
1390            && a.urgency.value == b.urgency.value
1391            && a.parent.value == b.parent.value
1392            && a.assignees == b.assignees
1393            && a.labels == b.labels
1394            && a.blocked_by == b.blocked_by
1395            && a.related_to == b.related_to
1396            && a.comments == b.comments
1397            && a.deleted.value == b.deleted.value
1398            && a.created_at == b.created_at
1399            && a.updated_at == b.updated_at
1400    }
1401
1402    #[test]
1403    fn merge_commutative() {
1404        let a = make_state_a();
1405        let b = make_state_b();
1406
1407        let mut ab = a.clone();
1408        ab.merge(&b);
1409
1410        let mut ba = b.clone();
1411        ba.merge(&a);
1412
1413        assert!(
1414            states_equal(&ab, &ba),
1415            "merge should be commutative\n  ab.title={}, ba.title={}\n  ab.state={:?}, ba.state={:?}",
1416            ab.title.value,
1417            ba.title.value,
1418            ab.state,
1419            ba.state,
1420        );
1421    }
1422
1423    #[test]
1424    fn merge_associative() {
1425        let a = make_state_a();
1426        let b = make_state_b();
1427        let c = make_state_c();
1428
1429        // (a ⊔ b) ⊔ c
1430        let mut ab_c = a.clone();
1431        ab_c.merge(&b);
1432        ab_c.merge(&c);
1433
1434        // a ⊔ (b ⊔ c)
1435        let mut bc = b.clone();
1436        bc.merge(&c);
1437        let mut a_bc = a.clone();
1438        a_bc.merge(&bc);
1439
1440        assert!(states_equal(&ab_c, &a_bc), "merge should be associative");
1441    }
1442
1443    #[test]
1444    fn merge_idempotent() {
1445        let a = make_state_a();
1446        let before = a.clone();
1447        let mut merged = a.clone();
1448        merged.merge(&before);
1449
1450        assert!(
1451            states_equal(&merged, &before),
1452            "merge with self should be idempotent"
1453        );
1454    }
1455
1456    // -----------------------------------------------------------------------
1457    // Full event sequence
1458    // -----------------------------------------------------------------------
1459
1460    #[test]
1461    fn full_lifecycle() {
1462        let mut state = WorkItemState::new();
1463
1464        // Create.
1465        state.apply_event(&create_event("Fix auth", 1000, "alice", "blake3:e1"));
1466        assert_eq!(state.title.value, "Fix auth");
1467        assert_eq!(state.phase(), Phase::Open);
1468
1469        // Assign.
1470        state.apply_event(&assign_event(
1471            "bob",
1472            AssignAction::Assign,
1473            2000,
1474            "alice",
1475            "blake3:e2",
1476        ));
1477        assert!(state.assignee_names().contains(&"bob".to_string()));
1478
1479        // Move to Doing.
1480        state.apply_event(&move_event(State::Doing, 3000, "bob", "blake3:e3"));
1481        assert_eq!(state.phase(), Phase::Doing);
1482
1483        // Add comment.
1484        state.apply_event(&comment_event("Found root cause", 4000, "bob", "blake3:e4"));
1485        assert_eq!(state.comments.len(), 1);
1486
1487        // Update title.
1488        state.apply_event(&update_title_event(
1489            "Fix auth retry logic",
1490            5000,
1491            "bob",
1492            "blake3:e5",
1493        ));
1494        assert_eq!(state.title.value, "Fix auth retry logic");
1495
1496        // Add blocker.
1497        state.apply_event(&link_event("bn-dep1", "blocks", 6000, "bob", "blake3:e6"));
1498        assert!(!state.blocked_by.is_empty());
1499
1500        // Remove blocker.
1501        state.apply_event(&unlink_event(
1502            "bn-dep1",
1503            Some("blocks"),
1504            7000,
1505            "bob",
1506            "blake3:e7",
1507        ));
1508        assert!(state.blocked_by.is_empty());
1509
1510        // Move to Done.
1511        state.apply_event(&move_event(State::Done, 8000, "bob", "blake3:e8"));
1512        assert_eq!(state.phase(), Phase::Done);
1513
1514        // Add label.
1515        state.apply_event(&label_add_event("shipped", 9000, "alice", "blake3:e9"));
1516        assert!(state.label_names().contains(&"shipped".to_string()));
1517
1518        assert_eq!(state.created_at, 1000);
1519        assert_eq!(state.updated_at, 9000);
1520    }
1521
1522    // -----------------------------------------------------------------------
1523    // Concurrent divergent merge
1524    // -----------------------------------------------------------------------
1525
1526    #[test]
1527    fn divergent_branches_merge_correctly() {
1528        // Simulate two agents forking from the same create event,
1529        // making concurrent changes, then merging.
1530
1531        // Shared base.
1532        let create = create_event("Shared Title", 1000, "alice", "blake3:c1");
1533
1534        // Branch A: alice updates title, moves to Doing, assigns self.
1535        let mut branch_a = WorkItemState::new();
1536        branch_a.apply_event(&create);
1537        branch_a.apply_event(&update_title_event(
1538            "Alice's Title",
1539            2000,
1540            "alice",
1541            "blake3:a1",
1542        ));
1543        branch_a.apply_event(&move_event(State::Doing, 3000, "alice", "blake3:a2"));
1544        branch_a.apply_event(&assign_event(
1545            "alice",
1546            AssignAction::Assign,
1547            4000,
1548            "alice",
1549            "blake3:a3",
1550        ));
1551
1552        // Branch B: bob updates title (later ts), adds label, assigns self.
1553        let mut branch_b = WorkItemState::new();
1554        branch_b.apply_event(&create);
1555        branch_b.apply_event(&update_title_event("Bob's Title", 2500, "bob", "blake3:b1"));
1556        branch_b.apply_event(&label_add_event("urgent", 3500, "bob", "blake3:b2"));
1557        branch_b.apply_event(&assign_event(
1558            "bob",
1559            AssignAction::Assign,
1560            4500,
1561            "bob",
1562            "blake3:b3",
1563        ));
1564
1565        // Merge A into B and B into A — should converge.
1566        let mut merged_ab = branch_a.clone();
1567        merged_ab.merge(&branch_b);
1568
1569        let mut merged_ba = branch_b.clone();
1570        merged_ba.merge(&branch_a);
1571
1572        assert!(states_equal(&merged_ab, &merged_ba));
1573
1574        // Bob's title wins (higher wall_ts: 2500 > 2000).
1575        assert_eq!(merged_ab.title.value, "Bob's Title");
1576
1577        // Phase: Doing from branch A (Doing > Open).
1578        assert_eq!(merged_ab.phase(), Phase::Doing);
1579
1580        // Both assignees present (OR-Set union).
1581        assert!(merged_ab.assignee_names().contains(&"alice".to_string()));
1582        assert!(merged_ab.assignee_names().contains(&"bob".to_string()));
1583
1584        // Bob's label present.
1585        assert!(merged_ab.label_names().contains(&"urgent".to_string()));
1586        // Create's label present.
1587        assert!(merged_ab.label_names().contains(&"backend".to_string()));
1588    }
1589
1590    // -----------------------------------------------------------------------
1591    // Edge cases
1592    // -----------------------------------------------------------------------
1593
1594    #[test]
1595    fn merge_default_with_default() {
1596        let a = WorkItemState::new();
1597        let b = WorkItemState::new();
1598        let mut merged = a.clone();
1599        merged.merge(&b);
1600
1601        assert_eq!(merged.title.value, "");
1602        assert_eq!(merged.state, EpochPhaseState::new());
1603        assert_eq!(merged.created_at, 0);
1604    }
1605
1606    #[test]
1607    fn merge_with_default_is_identity() {
1608        let a = make_state_a();
1609        let mut merged = a.clone();
1610        merged.merge(&WorkItemState::new());
1611
1612        assert!(states_equal(&merged, &a));
1613    }
1614
1615    #[test]
1616    fn apply_events_then_merge_equals_merge_then_apply() {
1617        // Commutativity test: apply events to separate states, merge
1618        // vs. apply all events to one state.
1619        let e1 = create_event("Title", 1000, "alice", "blake3:e1");
1620        let e2 = update_title_event("Updated", 2000, "bob", "blake3:e2");
1621        let e3 = assign_event("carol", AssignAction::Assign, 3000, "admin", "blake3:e3");
1622
1623        // Path 1: apply e1, e2 to A; apply e1, e3 to B; merge.
1624        let mut path1_a = WorkItemState::new();
1625        path1_a.apply_event(&e1);
1626        path1_a.apply_event(&e2);
1627
1628        let mut path1_b = WorkItemState::new();
1629        path1_b.apply_event(&e1);
1630        path1_b.apply_event(&e3);
1631
1632        path1_a.merge(&path1_b);
1633
1634        // Path 2: apply e1, e3 to B; apply e1, e2 to A; merge.
1635        let mut path2_b = WorkItemState::new();
1636        path2_b.apply_event(&e1);
1637        path2_b.apply_event(&e3);
1638
1639        let mut path2_a = WorkItemState::new();
1640        path2_a.apply_event(&e1);
1641        path2_a.apply_event(&e2);
1642
1643        path2_b.merge(&path2_a);
1644
1645        assert!(states_equal(&path1_a, &path2_b));
1646    }
1647
1648    #[test]
1649    fn snapshot_event_is_noop() {
1650        let mut state = WorkItemState::new();
1651        state.apply_event(&create_event("Title", 1000, "alice", "blake3:c1"));
1652
1653        let snapshot_event = make_event(
1654            EventType::Snapshot,
1655            EventData::Snapshot(SnapshotData {
1656                state: serde_json::json!({"title": "Snapshot Title"}),
1657                extra: BTreeMap::new(),
1658            }),
1659            2000,
1660            "compactor",
1661            "blake3:s1",
1662        );
1663
1664        let title_before = state.title.value.clone();
1665        state.apply_event(&snapshot_event);
1666        // Snapshot event doesn't change title (handled separately).
1667        assert_eq!(state.title.value, title_before);
1668    }
1669
1670    #[test]
1671    fn redact_event_is_noop() {
1672        let mut state = WorkItemState::new();
1673        state.apply_event(&create_event("Title", 1000, "alice", "blake3:c1"));
1674
1675        let redact_event = make_event(
1676            EventType::Redact,
1677            EventData::Redact(RedactData {
1678                target_hash: "blake3:c1".to_string(),
1679                reason: "secret".to_string(),
1680                extra: BTreeMap::new(),
1681            }),
1682            2000,
1683            "admin",
1684            "blake3:r1",
1685        );
1686
1687        let title_before = state.title.value.clone();
1688        state.apply_event(&redact_event);
1689        assert_eq!(state.title.value, title_before);
1690    }
1691
1692    // -----------------------------------------------------------------------
1693    // Accessor methods
1694    // -----------------------------------------------------------------------
1695
1696    #[test]
1697    fn accessor_methods() {
1698        let mut state = WorkItemState::new();
1699        state.apply_event(&create_event("T", 1000, "alice", "blake3:c1"));
1700        state.apply_event(&assign_event(
1701            "alice",
1702            AssignAction::Assign,
1703            2000,
1704            "admin",
1705            "blake3:a1",
1706        ));
1707        state.apply_event(&link_event("bn-b1", "blocks", 3000, "alice", "blake3:l1"));
1708        state.apply_event(&link_event(
1709            "bn-r1",
1710            "related_to",
1711            4000,
1712            "alice",
1713            "blake3:l2",
1714        ));
1715        state.apply_event(&comment_event("hi", 5000, "alice", "blake3:cm1"));
1716
1717        assert!(state.assignee_names().contains(&"alice".to_string()));
1718        assert!(state.label_names().contains(&"backend".to_string()));
1719        assert!(state.blocked_by_ids().contains(&"bn-b1".to_string()));
1720        assert!(state.related_to_ids().contains(&"bn-r1".to_string()));
1721        assert!(state.comment_hashes().contains("blake3:cm1"));
1722        assert_eq!(state.phase(), Phase::Open);
1723        assert_eq!(state.epoch(), 0);
1724        assert!(!state.is_deleted());
1725    }
1726}