Skip to main content

omni_dev/voice/
events.rs

1//! Reflection event schema (the `events.jsonl` contract from #799).
2//!
3//! These types form the wire format for the append-only reflection log
4//! produced by `voice reflect` (this issue) and consumed by `voice review`
5//! (#804). The shape is the load-bearing contract — see the umbrella
6//! issue #799 for the full design rationale and reconciliation
7//! invariants. The [`project`] helper here implements the subset of those
8//! invariants that `voice reflect` itself needs (to render the
9//! `<current_state>` block in its prompt). TTL eviction lives in `voice
10//! review` and is not implemented here.
11
12use std::collections::BTreeMap;
13use std::time::Duration;
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17use tracing::warn;
18
19use crate::voice::EventId;
20
21/// Identifier for an item (todo / research / question).
22pub type ItemId = ulid::Ulid;
23
24/// Identifier for a recorded decision.
25pub type DecisionId = ulid::Ulid;
26
27/// Identifier for a standalone research note.
28pub type NoteId = ulid::Ulid;
29
30/// Identifies the reflection invocation that produced a batch of events.
31///
32/// `Ulid(_)` for events emitted by `voice reflect`; `Review` for events
33/// written by reconciliation (`voice review`, #804) — currently just
34/// `item.expire { reason: ttl }`. Serialised on the wire as either a
35/// ULID string or the literal `"review"`.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum ReflectionId {
38    /// A specific reflection invocation.
39    Ulid(ulid::Ulid),
40    /// Emitted by reconciliation, not by a reflection.
41    Review,
42}
43
44impl Serialize for ReflectionId {
45    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
46        match self {
47            Self::Ulid(u) => s.serialize_str(&u.to_string()),
48            Self::Review => s.serialize_str("review"),
49        }
50    }
51}
52
53impl<'de> Deserialize<'de> for ReflectionId {
54    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
55        let s = String::deserialize(d)?;
56        if s == "review" {
57            Ok(Self::Review)
58        } else {
59            ulid::Ulid::from_string(&s)
60                .map(Self::Ulid)
61                .map_err(serde::de::Error::custom)
62        }
63    }
64}
65
66/// Range of consumed `TranscriptEvent::Final` IDs that motivated an event.
67#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
68pub struct TranscriptSpan {
69    /// First `Final` event in the consumed transcript range.
70    pub start_event_id: EventId,
71    /// Last `Final` event in the consumed transcript range.
72    pub end_event_id: EventId,
73}
74
75/// What motivated an event — for audit and reconciliation.
76#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
77pub struct Provenance {
78    /// Range of transcript events this reflection consumed (null for
79    /// review-emitted events, which consume no transcript).
80    #[serde(skip_serializing_if = "Option::is_none", default)]
81    pub transcript_span: Option<TranscriptSpan>,
82    /// Model identifier (null for review-emitted events).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub model: Option<String>,
85    /// Prompt-template fingerprint (null for review-emitted events).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub prompt_version: Option<String>,
88}
89
90/// Class of an item — present-tense intent the user expressed.
91#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum ItemClass {
94    /// A thing the user wants done.
95    Todo,
96    /// Background context worth keeping but not actionable.
97    Research,
98    /// An open question the user wants answered.
99    Question,
100}
101
102/// Priority of an item.
103#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
104#[serde(rename_all = "snake_case")]
105pub enum Priority {
106    /// Bumped above the rest of the list.
107    High,
108    /// The default.
109    Normal,
110    /// Demoted below the rest of the list.
111    Low,
112}
113
114/// Why an item expired.
115#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum ExpireReason {
118    /// User explicitly retracted the item.
119    Retracted,
120    /// `valid_until` elapsed (emitted by `voice review`, not `reflect`).
121    Ttl,
122    /// Replaced by a more recent item — see `superseded_by`.
123    Superseded,
124}
125
126/// `item.create` payload.
127#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
128pub struct ItemCreate {
129    /// Stable identifier minted by the LLM, referenced by later events.
130    pub item_id: ItemId,
131    /// Class of the item.
132    pub class: ItemClass,
133    /// Item text.
134    pub text: String,
135    /// Optional priority; absent means [`Priority::Normal`] at projection.
136    #[serde(skip_serializing_if = "Option::is_none", default)]
137    pub priority: Option<Priority>,
138    /// Optional expiry time; absent means "use class default" at projection.
139    #[serde(skip_serializing_if = "Option::is_none", default)]
140    pub valid_until: Option<DateTime<Utc>>,
141    /// Optional tags.
142    #[serde(skip_serializing_if = "Option::is_none", default)]
143    pub tags: Option<Vec<String>>,
144}
145
146/// `item.update` payload. All fields besides `item_id` are optional — any
147/// present field denotes a change.
148#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
149pub struct ItemUpdate {
150    /// Identifier of the existing item to update.
151    pub item_id: ItemId,
152    /// New text (optional).
153    #[serde(skip_serializing_if = "Option::is_none", default)]
154    pub text: Option<String>,
155    /// New priority (optional).
156    #[serde(skip_serializing_if = "Option::is_none", default)]
157    pub priority: Option<Priority>,
158    /// New expiry (optional; sliding extension on re-mention).
159    #[serde(skip_serializing_if = "Option::is_none", default)]
160    pub valid_until: Option<DateTime<Utc>>,
161    /// Replacement tags (optional).
162    #[serde(skip_serializing_if = "Option::is_none", default)]
163    pub tags: Option<Vec<String>>,
164}
165
166/// `item.expire` payload.
167#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
168pub struct ItemExpire {
169    /// Identifier of the item to expire.
170    pub item_id: ItemId,
171    /// Why the item is being expired.
172    pub reason: ExpireReason,
173    /// New item replacing this one — present iff `reason == Superseded`.
174    #[serde(skip_serializing_if = "Option::is_none", default)]
175    pub superseded_by: Option<ItemId>,
176}
177
178/// `item.complete` payload.
179#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
180pub struct ItemComplete {
181    /// Identifier of the completed item.
182    pub item_id: ItemId,
183    /// Optional completion note.
184    #[serde(skip_serializing_if = "Option::is_none", default)]
185    pub note: Option<String>,
186}
187
188/// `decision.record` payload.
189#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
190pub struct DecisionRecord {
191    /// Stable identifier for this decision.
192    pub decision_id: DecisionId,
193    /// Decision text.
194    pub text: String,
195    /// Optional list of alternatives that were considered.
196    #[serde(skip_serializing_if = "Option::is_none", default)]
197    pub alternatives: Option<Vec<String>>,
198}
199
200/// `research.note` payload.
201#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
202pub struct ResearchNote {
203    /// Stable identifier for this note.
204    pub note_id: NoteId,
205    /// Note text.
206    pub text: String,
207    /// Optional supporting links.
208    #[serde(skip_serializing_if = "Option::is_none", default)]
209    pub links: Option<Vec<String>>,
210    /// Optional expiry (default P30D at projection).
211    #[serde(skip_serializing_if = "Option::is_none", default)]
212    pub valid_until: Option<DateTime<Utc>>,
213}
214
215/// `reflection.error` payload — captured for audit when the LLM output
216/// fails schema validation. Skipped by projection.
217#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
218pub struct ReflectionError {
219    /// Raw LLM output that failed validation.
220    pub raw_output: String,
221    /// Human-readable error description.
222    pub error: String,
223}
224
225/// Tagged union of event payloads, paired with the `event_type`
226/// discriminator in the on-wire envelope.
227///
228/// The serde representation is *adjacently tagged* so the envelope ends
229/// up with sibling `event_type` and `payload` fields — matching #799's
230/// example shape. Flattening this enum into [`Event`] then puts those
231/// fields at the same level as the envelope fields (`event_id`, `ts`, …).
232#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
233#[serde(tag = "event_type", content = "payload")]
234pub enum EventKind {
235    /// Mint a new todo / research note / question.
236    #[serde(rename = "item.create")]
237    ItemCreate(ItemCreate),
238    /// Refine an existing item.
239    #[serde(rename = "item.update")]
240    ItemUpdate(ItemUpdate),
241    /// Item no longer applies.
242    #[serde(rename = "item.expire")]
243    ItemExpire(ItemExpire),
244    /// User completed the item.
245    #[serde(rename = "item.complete")]
246    ItemComplete(ItemComplete),
247    /// A decision was made.
248    #[serde(rename = "decision.record")]
249    DecisionRecord(DecisionRecord),
250    /// Standalone research note.
251    #[serde(rename = "research.note")]
252    ResearchNote(ResearchNote),
253    /// Schema-invalid LLM output captured for audit.
254    #[serde(rename = "reflection.error")]
255    ReflectionError(ReflectionError),
256}
257
258/// One event in the `events.jsonl` log.
259#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
260pub struct Event {
261    /// Sortable, monotonic, no collisions. Used as the canonical
262    /// ordering key by reconciliation.
263    pub event_id: EventId,
264    /// Emission time (UTC RFC3339).
265    pub ts: DateTime<Utc>,
266    /// Identifies the reflection invocation that produced this event.
267    pub reflection_id: ReflectionId,
268    /// What motivated the event.
269    pub provenance: Provenance,
270    /// Discriminated payload + type tag (flattened into the envelope so
271    /// `event_type` and `payload` are top-level keys, per #799).
272    #[serde(flatten)]
273    pub kind: EventKind,
274}
275
276// ─── Projection ────────────────────────────────────────────────────────
277
278/// Materialised item, as projected from the event log.
279#[derive(Clone, Debug, PartialEq, Eq)]
280pub struct ProjectedItem {
281    /// Identifier of the item.
282    pub id: ItemId,
283    /// Class (todo / research / question).
284    pub class: ItemClass,
285    /// Current text.
286    pub text: String,
287    /// Current priority (defaults to [`Priority::Normal`] when unspecified).
288    pub priority: Priority,
289    /// Current expiry, if explicitly set.
290    pub valid_until: Option<DateTime<Utc>>,
291    /// Current tags.
292    pub tags: Vec<String>,
293    /// `true` when an `item.complete` has been seen.
294    pub completed: bool,
295    /// `Some(reason)` when an `item.expire` has been seen.
296    pub expired: Option<ExpireReason>,
297}
298
299/// Materialised decision.
300#[derive(Clone, Debug, PartialEq, Eq)]
301pub struct ProjectedDecision {
302    /// Identifier of the decision.
303    pub id: DecisionId,
304    /// Decision text.
305    pub text: String,
306    /// Alternatives that were considered.
307    pub alternatives: Vec<String>,
308}
309
310/// Materialised research note.
311#[derive(Clone, Debug, PartialEq, Eq)]
312pub struct ProjectedNote {
313    /// Identifier of the note.
314    pub id: NoteId,
315    /// Note text.
316    pub text: String,
317    /// Supporting links.
318    pub links: Vec<String>,
319    /// Expiry, if set.
320    pub valid_until: Option<DateTime<Utc>>,
321}
322
323/// Materialised state after applying an event log.
324#[derive(Clone, Debug, Default, PartialEq, Eq)]
325pub struct ProjectedState {
326    /// Items keyed by id. Includes completed and expired items so callers
327    /// can filter as they see fit (`voice reflect` excludes them from
328    /// `<current_state>`; `voice review` may render them under a separate
329    /// heading).
330    pub items: BTreeMap<ItemId, ProjectedItem>,
331    /// Decisions in insertion order.
332    pub decisions: Vec<ProjectedDecision>,
333    /// Notes in insertion order.
334    pub notes: Vec<ProjectedNote>,
335}
336
337/// Projects a sequence of [`Event`]s into a [`ProjectedState`] per #799's
338/// reconciliation invariants:
339///
340/// - Sort by `event_id` (ULIDs are sortable).
341/// - Last-write-wins per item field on `item.update`.
342/// - Idempotent expiry.
343/// - Unknown `item_id` in `item.update` / `item.expire` / `item.complete`
344///   → drop the operation, log a warning.
345/// - `reflection.error` skipped entirely.
346///
347/// **Not** implemented here (deferred to `voice review` / #804):
348/// - TTL eviction (synthesising `item.expire { reason: ttl }`).
349pub fn project<I: IntoIterator<Item = Event>>(events: I) -> ProjectedState {
350    let mut sorted: Vec<Event> = events.into_iter().collect();
351    sorted.sort_by_key(|e| e.event_id);
352    let mut state = ProjectedState::default();
353    for event in sorted {
354        apply_event(&mut state, event);
355    }
356    state
357}
358
359fn apply_event(state: &mut ProjectedState, event: Event) {
360    match event.kind {
361        EventKind::ItemCreate(c) => {
362            state.items.insert(
363                c.item_id,
364                ProjectedItem {
365                    id: c.item_id,
366                    class: c.class,
367                    text: c.text,
368                    priority: c.priority.unwrap_or(Priority::Normal),
369                    valid_until: c.valid_until,
370                    tags: c.tags.unwrap_or_default(),
371                    completed: false,
372                    expired: None,
373                },
374            );
375        }
376        EventKind::ItemUpdate(u) => {
377            let Some(item) = state.items.get_mut(&u.item_id) else {
378                warn!(
379                    item_id = %u.item_id,
380                    "item.update references unknown item; dropping per #799 invariant"
381                );
382                return;
383            };
384            if let Some(text) = u.text {
385                item.text = text;
386            }
387            if let Some(priority) = u.priority {
388                item.priority = priority;
389            }
390            if u.valid_until.is_some() {
391                item.valid_until = u.valid_until;
392            }
393            if let Some(tags) = u.tags {
394                item.tags = tags;
395            }
396        }
397        EventKind::ItemExpire(e) => {
398            let Some(item) = state.items.get_mut(&e.item_id) else {
399                warn!(
400                    item_id = %e.item_id,
401                    "item.expire references unknown item; dropping per #799 invariant"
402                );
403                return;
404            };
405            // Idempotent: ignore if already expired.
406            if item.expired.is_none() {
407                item.expired = Some(e.reason);
408            }
409        }
410        EventKind::ItemComplete(c) => {
411            let Some(item) = state.items.get_mut(&c.item_id) else {
412                warn!(
413                    item_id = %c.item_id,
414                    "item.complete references unknown item; dropping per #799 invariant"
415                );
416                return;
417            };
418            item.completed = true;
419        }
420        EventKind::DecisionRecord(d) => {
421            state.decisions.push(ProjectedDecision {
422                id: d.decision_id,
423                text: d.text,
424                alternatives: d.alternatives.unwrap_or_default(),
425            });
426        }
427        EventKind::ResearchNote(n) => {
428            state.notes.push(ProjectedNote {
429                id: n.note_id,
430                text: n.text,
431                links: n.links.unwrap_or_default(),
432                valid_until: n.valid_until,
433            });
434        }
435        EventKind::ReflectionError(_) => {
436            // Skipped by projection — captured in the log for audit only.
437        }
438    }
439}
440
441/// Default TTLs per item class (per #799). Used when an `item.create`
442/// omits `valid_until`. Callers compute the effective expiry as
443/// `event.ts + class_default_ttl(item.class)`.
444#[must_use]
445pub fn class_default_ttl(class: &ItemClass) -> Duration {
446    match class {
447        ItemClass::Todo => Duration::from_secs(7 * 24 * 60 * 60),
448        ItemClass::Research => Duration::from_secs(30 * 24 * 60 * 60),
449        ItemClass::Question => Duration::from_secs(14 * 24 * 60 * 60),
450    }
451}
452
453#[cfg(test)]
454#[allow(clippy::unwrap_used, clippy::expect_used)]
455mod tests {
456    use super::*;
457    use chrono::TimeZone;
458
459    fn ts() -> DateTime<Utc> {
460        Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
461    }
462
463    fn span() -> TranscriptSpan {
464        TranscriptSpan {
465            start_event_id: ulid::Ulid::from_parts(0, 1),
466            end_event_id: ulid::Ulid::from_parts(0, 2),
467        }
468    }
469
470    fn provenance() -> Provenance {
471        Provenance {
472            transcript_span: Some(span()),
473            model: Some("claude-sonnet-4-6".to_string()),
474            prompt_version: Some("abcd1234".to_string()),
475        }
476    }
477
478    fn event(event_id: u128, kind: EventKind) -> Event {
479        Event {
480            event_id: ulid::Ulid::from_parts(0, event_id),
481            ts: ts(),
482            reflection_id: ReflectionId::Ulid(ulid::Ulid::from_parts(0, 100)),
483            provenance: provenance(),
484            kind,
485        }
486    }
487
488    fn id(n: u128) -> ItemId {
489        ulid::Ulid::from_parts(0, n)
490    }
491
492    #[test]
493    fn item_create_serialises_with_adjacent_tag() {
494        let e = event(
495            10,
496            EventKind::ItemCreate(ItemCreate {
497                item_id: id(200),
498                class: ItemClass::Todo,
499                text: "wire it up".to_string(),
500                priority: None,
501                valid_until: None,
502                tags: None,
503            }),
504        );
505        let json = serde_json::to_string(&e).unwrap();
506        assert!(
507            json.contains(r#""event_type":"item.create""#),
508            "missing event_type discriminator: {json}"
509        );
510        assert!(
511            json.contains(r#""payload":{"#),
512            "missing payload object: {json}"
513        );
514        assert!(
515            json.contains(r#""class":"todo""#),
516            "missing class enum rename: {json}"
517        );
518    }
519
520    #[test]
521    fn event_round_trips_through_serde_json() {
522        let original = event(
523            10,
524            EventKind::ItemCreate(ItemCreate {
525                item_id: id(200),
526                class: ItemClass::Research,
527                text: "look into LocalAgreement-2".to_string(),
528                priority: Some(Priority::High),
529                valid_until: Some(ts()),
530                tags: Some(vec!["asr".to_string(), "whisper".to_string()]),
531            }),
532        );
533        let s = serde_json::to_string(&original).unwrap();
534        let back: Event = serde_json::from_str(&s).unwrap();
535        assert_eq!(original, back);
536    }
537
538    #[test]
539    fn reflection_id_review_serialises_as_string_literal() {
540        let id = ReflectionId::Review;
541        let s = serde_json::to_string(&id).unwrap();
542        assert_eq!(s, r#""review""#);
543        let back: ReflectionId = serde_json::from_str(&s).unwrap();
544        assert_eq!(back, ReflectionId::Review);
545    }
546
547    #[test]
548    fn reflection_id_ulid_round_trips() {
549        let u = ulid::Ulid::from_parts(0, 42);
550        let id = ReflectionId::Ulid(u);
551        let s = serde_json::to_string(&id).unwrap();
552        let back: ReflectionId = serde_json::from_str(&s).unwrap();
553        assert_eq!(back, ReflectionId::Ulid(u));
554    }
555
556    #[test]
557    fn project_orders_by_event_id_not_insertion_order() {
558        // Insert items out of order; projection should sort them.
559        let item_a = id(1);
560        let events = vec![
561            event(
562                20,
563                EventKind::ItemUpdate(ItemUpdate {
564                    item_id: item_a,
565                    text: Some("third write".to_string()),
566                    ..Default::default()
567                }),
568            ),
569            event(
570                10,
571                EventKind::ItemCreate(ItemCreate {
572                    item_id: item_a,
573                    class: ItemClass::Todo,
574                    text: "first write".to_string(),
575                    priority: None,
576                    valid_until: None,
577                    tags: None,
578                }),
579            ),
580            event(
581                15,
582                EventKind::ItemUpdate(ItemUpdate {
583                    item_id: item_a,
584                    text: Some("second write".to_string()),
585                    ..Default::default()
586                }),
587            ),
588        ];
589        let state = project(events);
590        assert_eq!(state.items.get(&item_a).unwrap().text, "third write");
591    }
592
593    #[test]
594    fn project_drops_update_for_unknown_item() {
595        let state = project(vec![event(
596            10,
597            EventKind::ItemUpdate(ItemUpdate {
598                item_id: id(999),
599                text: Some("no such item".to_string()),
600                ..Default::default()
601            }),
602        )]);
603        assert!(state.items.is_empty());
604    }
605
606    #[test]
607    fn project_applies_all_item_update_fields() {
608        let i = id(1);
609        let state = project(vec![
610            event(
611                10,
612                EventKind::ItemCreate(ItemCreate {
613                    item_id: i,
614                    class: ItemClass::Todo,
615                    text: "original".into(),
616                    priority: None,
617                    valid_until: None,
618                    tags: None,
619                }),
620            ),
621            event(
622                11,
623                EventKind::ItemUpdate(ItemUpdate {
624                    item_id: i,
625                    text: Some("updated".into()),
626                    priority: Some(Priority::High),
627                    valid_until: Some(ts()),
628                    tags: Some(vec!["urgent".into()]),
629                }),
630            ),
631        ]);
632        let item = state.items.get(&i).unwrap();
633        assert_eq!(item.text, "updated");
634        assert_eq!(item.priority, Priority::High);
635        assert_eq!(item.valid_until, Some(ts()));
636        assert_eq!(item.tags, vec!["urgent".to_string()]);
637    }
638
639    #[test]
640    fn project_drops_expire_and_complete_for_unknown_items() {
641        let state = project(vec![
642            event(
643                10,
644                EventKind::ItemExpire(ItemExpire {
645                    item_id: id(99),
646                    reason: ExpireReason::Retracted,
647                    superseded_by: None,
648                }),
649            ),
650            event(
651                11,
652                EventKind::ItemComplete(ItemComplete {
653                    item_id: id(99),
654                    note: None,
655                }),
656            ),
657        ]);
658        assert!(state.items.is_empty());
659    }
660
661    #[test]
662    fn project_marks_completed_and_expired() {
663        let i = id(1);
664        let state = project(vec![
665            event(
666                10,
667                EventKind::ItemCreate(ItemCreate {
668                    item_id: i,
669                    class: ItemClass::Todo,
670                    text: "x".into(),
671                    priority: None,
672                    valid_until: None,
673                    tags: None,
674                }),
675            ),
676            event(
677                11,
678                EventKind::ItemComplete(ItemComplete {
679                    item_id: i,
680                    note: Some("done".into()),
681                }),
682            ),
683            event(
684                12,
685                EventKind::ItemExpire(ItemExpire {
686                    item_id: i,
687                    reason: ExpireReason::Superseded,
688                    superseded_by: Some(id(2)),
689                }),
690            ),
691        ]);
692        let item = state.items.get(&i).unwrap();
693        assert!(item.completed);
694        assert_eq!(item.expired, Some(ExpireReason::Superseded));
695    }
696
697    #[test]
698    fn project_expire_is_idempotent() {
699        let i = id(1);
700        let state = project(vec![
701            event(
702                10,
703                EventKind::ItemCreate(ItemCreate {
704                    item_id: i,
705                    class: ItemClass::Todo,
706                    text: "x".into(),
707                    priority: None,
708                    valid_until: None,
709                    tags: None,
710                }),
711            ),
712            event(
713                11,
714                EventKind::ItemExpire(ItemExpire {
715                    item_id: i,
716                    reason: ExpireReason::Retracted,
717                    superseded_by: None,
718                }),
719            ),
720            event(
721                12,
722                EventKind::ItemExpire(ItemExpire {
723                    item_id: i,
724                    reason: ExpireReason::Superseded,
725                    superseded_by: Some(id(2)),
726                }),
727            ),
728        ]);
729        // First expire wins; second is a no-op.
730        assert_eq!(
731            state.items.get(&i).unwrap().expired,
732            Some(ExpireReason::Retracted)
733        );
734    }
735
736    #[test]
737    fn project_skips_reflection_errors() {
738        let state = project(vec![event(
739            10,
740            EventKind::ReflectionError(ReflectionError {
741                raw_output: "garbage".into(),
742                error: "missing item_id".into(),
743            }),
744        )]);
745        assert!(state.items.is_empty());
746        assert!(state.decisions.is_empty());
747        assert!(state.notes.is_empty());
748    }
749
750    #[test]
751    fn project_appends_decisions_and_notes() {
752        let state = project(vec![
753            event(
754                10,
755                EventKind::DecisionRecord(DecisionRecord {
756                    decision_id: id(1),
757                    text: "use ULIDs".into(),
758                    alternatives: Some(vec!["UUIDv7".into()]),
759                }),
760            ),
761            event(
762                11,
763                EventKind::ResearchNote(ResearchNote {
764                    note_id: id(2),
765                    text: "AssemblyAI is immutable-finals".into(),
766                    links: None,
767                    valid_until: None,
768                }),
769            ),
770        ]);
771        assert_eq!(state.decisions.len(), 1);
772        assert_eq!(state.notes.len(), 1);
773    }
774
775    #[test]
776    fn class_default_ttls_match_799() {
777        assert_eq!(
778            class_default_ttl(&ItemClass::Todo),
779            Duration::from_secs(7 * 86_400)
780        );
781        assert_eq!(
782            class_default_ttl(&ItemClass::Research),
783            Duration::from_secs(30 * 86_400)
784        );
785        assert_eq!(
786            class_default_ttl(&ItemClass::Question),
787            Duration::from_secs(14 * 86_400)
788        );
789    }
790}