1use 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
21pub type ItemId = ulid::Ulid;
23
24pub type DecisionId = ulid::Ulid;
26
27pub type NoteId = ulid::Ulid;
29
30#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum ReflectionId {
38 Ulid(ulid::Ulid),
40 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
68pub struct TranscriptSpan {
69 pub start_event_id: EventId,
71 pub end_event_id: EventId,
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
77pub struct Provenance {
78 #[serde(skip_serializing_if = "Option::is_none", default)]
81 pub transcript_span: Option<TranscriptSpan>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub model: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub prompt_version: Option<String>,
88}
89
90#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum ItemClass {
94 Todo,
96 Research,
98 Question,
100}
101
102#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
104#[serde(rename_all = "snake_case")]
105pub enum Priority {
106 High,
108 Normal,
110 Low,
112}
113
114#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum ExpireReason {
118 Retracted,
120 Ttl,
122 Superseded,
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
128pub struct ItemCreate {
129 pub item_id: ItemId,
131 pub class: ItemClass,
133 pub text: String,
135 #[serde(skip_serializing_if = "Option::is_none", default)]
137 pub priority: Option<Priority>,
138 #[serde(skip_serializing_if = "Option::is_none", default)]
140 pub valid_until: Option<DateTime<Utc>>,
141 #[serde(skip_serializing_if = "Option::is_none", default)]
143 pub tags: Option<Vec<String>>,
144}
145
146#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
149pub struct ItemUpdate {
150 pub item_id: ItemId,
152 #[serde(skip_serializing_if = "Option::is_none", default)]
154 pub text: Option<String>,
155 #[serde(skip_serializing_if = "Option::is_none", default)]
157 pub priority: Option<Priority>,
158 #[serde(skip_serializing_if = "Option::is_none", default)]
160 pub valid_until: Option<DateTime<Utc>>,
161 #[serde(skip_serializing_if = "Option::is_none", default)]
163 pub tags: Option<Vec<String>>,
164}
165
166#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
168pub struct ItemExpire {
169 pub item_id: ItemId,
171 pub reason: ExpireReason,
173 #[serde(skip_serializing_if = "Option::is_none", default)]
175 pub superseded_by: Option<ItemId>,
176}
177
178#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
180pub struct ItemComplete {
181 pub item_id: ItemId,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub note: Option<String>,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
190pub struct DecisionRecord {
191 pub decision_id: DecisionId,
193 pub text: String,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
197 pub alternatives: Option<Vec<String>>,
198}
199
200#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
202pub struct ResearchNote {
203 pub note_id: NoteId,
205 pub text: String,
207 #[serde(skip_serializing_if = "Option::is_none", default)]
209 pub links: Option<Vec<String>>,
210 #[serde(skip_serializing_if = "Option::is_none", default)]
212 pub valid_until: Option<DateTime<Utc>>,
213}
214
215#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
218pub struct ReflectionError {
219 pub raw_output: String,
221 pub error: String,
223}
224
225#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
233#[serde(tag = "event_type", content = "payload")]
234pub enum EventKind {
235 #[serde(rename = "item.create")]
237 ItemCreate(ItemCreate),
238 #[serde(rename = "item.update")]
240 ItemUpdate(ItemUpdate),
241 #[serde(rename = "item.expire")]
243 ItemExpire(ItemExpire),
244 #[serde(rename = "item.complete")]
246 ItemComplete(ItemComplete),
247 #[serde(rename = "decision.record")]
249 DecisionRecord(DecisionRecord),
250 #[serde(rename = "research.note")]
252 ResearchNote(ResearchNote),
253 #[serde(rename = "reflection.error")]
255 ReflectionError(ReflectionError),
256}
257
258#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
260pub struct Event {
261 pub event_id: EventId,
264 pub ts: DateTime<Utc>,
266 pub reflection_id: ReflectionId,
268 pub provenance: Provenance,
270 #[serde(flatten)]
273 pub kind: EventKind,
274}
275
276#[derive(Clone, Debug, PartialEq, Eq)]
280pub struct ProjectedItem {
281 pub id: ItemId,
283 pub class: ItemClass,
285 pub text: String,
287 pub priority: Priority,
289 pub valid_until: Option<DateTime<Utc>>,
291 pub tags: Vec<String>,
293 pub completed: bool,
295 pub expired: Option<ExpireReason>,
297}
298
299#[derive(Clone, Debug, PartialEq, Eq)]
301pub struct ProjectedDecision {
302 pub id: DecisionId,
304 pub text: String,
306 pub alternatives: Vec<String>,
308}
309
310#[derive(Clone, Debug, PartialEq, Eq)]
312pub struct ProjectedNote {
313 pub id: NoteId,
315 pub text: String,
317 pub links: Vec<String>,
319 pub valid_until: Option<DateTime<Utc>>,
321}
322
323#[derive(Clone, Debug, Default, PartialEq, Eq)]
325pub struct ProjectedState {
326 pub items: BTreeMap<ItemId, ProjectedItem>,
331 pub decisions: Vec<ProjectedDecision>,
333 pub notes: Vec<ProjectedNote>,
335}
336
337pub 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 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 }
438 }
439}
440
441#[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 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 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}