use std::collections::BTreeMap;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use tracing::warn;
use crate::voice::EventId;
pub type ItemId = ulid::Ulid;
pub type DecisionId = ulid::Ulid;
pub type NoteId = ulid::Ulid;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReflectionId {
Ulid(ulid::Ulid),
Review,
}
impl Serialize for ReflectionId {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Self::Ulid(u) => s.serialize_str(&u.to_string()),
Self::Review => s.serialize_str("review"),
}
}
}
impl<'de> Deserialize<'de> for ReflectionId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
if s == "review" {
Ok(Self::Review)
} else {
ulid::Ulid::from_string(&s)
.map(Self::Ulid)
.map_err(serde::de::Error::custom)
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TranscriptSpan {
pub start_event_id: EventId,
pub end_event_id: EventId,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Provenance {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub transcript_span: Option<TranscriptSpan>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_version: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ItemClass {
Todo,
Research,
Question,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
High,
Normal,
Low,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExpireReason {
Retracted,
Ttl,
Superseded,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ItemCreate {
pub item_id: ItemId,
pub class: ItemClass,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub priority: Option<Priority>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub valid_until: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tags: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ItemUpdate {
pub item_id: ItemId,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub priority: Option<Priority>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub valid_until: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tags: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ItemExpire {
pub item_id: ItemId,
pub reason: ExpireReason,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub superseded_by: Option<ItemId>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ItemComplete {
pub item_id: ItemId,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub note: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DecisionRecord {
pub decision_id: DecisionId,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub alternatives: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResearchNote {
pub note_id: NoteId,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub links: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub valid_until: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReflectionError {
pub raw_output: String,
pub error: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "event_type", content = "payload")]
pub enum EventKind {
#[serde(rename = "item.create")]
ItemCreate(ItemCreate),
#[serde(rename = "item.update")]
ItemUpdate(ItemUpdate),
#[serde(rename = "item.expire")]
ItemExpire(ItemExpire),
#[serde(rename = "item.complete")]
ItemComplete(ItemComplete),
#[serde(rename = "decision.record")]
DecisionRecord(DecisionRecord),
#[serde(rename = "research.note")]
ResearchNote(ResearchNote),
#[serde(rename = "reflection.error")]
ReflectionError(ReflectionError),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Event {
pub event_id: EventId,
pub ts: DateTime<Utc>,
pub reflection_id: ReflectionId,
pub provenance: Provenance,
#[serde(flatten)]
pub kind: EventKind,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectedItem {
pub id: ItemId,
pub class: ItemClass,
pub text: String,
pub priority: Priority,
pub valid_until: Option<DateTime<Utc>>,
pub tags: Vec<String>,
pub completed: bool,
pub expired: Option<ExpireReason>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectedDecision {
pub id: DecisionId,
pub text: String,
pub alternatives: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectedNote {
pub id: NoteId,
pub text: String,
pub links: Vec<String>,
pub valid_until: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProjectedState {
pub items: BTreeMap<ItemId, ProjectedItem>,
pub decisions: Vec<ProjectedDecision>,
pub notes: Vec<ProjectedNote>,
}
pub fn project<I: IntoIterator<Item = Event>>(events: I) -> ProjectedState {
let mut sorted: Vec<Event> = events.into_iter().collect();
sorted.sort_by_key(|e| e.event_id);
let mut state = ProjectedState::default();
for event in sorted {
apply_event(&mut state, event);
}
state
}
fn apply_event(state: &mut ProjectedState, event: Event) {
match event.kind {
EventKind::ItemCreate(c) => {
state.items.insert(
c.item_id,
ProjectedItem {
id: c.item_id,
class: c.class,
text: c.text,
priority: c.priority.unwrap_or(Priority::Normal),
valid_until: c.valid_until,
tags: c.tags.unwrap_or_default(),
completed: false,
expired: None,
},
);
}
EventKind::ItemUpdate(u) => {
let Some(item) = state.items.get_mut(&u.item_id) else {
warn!(
item_id = %u.item_id,
"item.update references unknown item; dropping per #799 invariant"
);
return;
};
if let Some(text) = u.text {
item.text = text;
}
if let Some(priority) = u.priority {
item.priority = priority;
}
if u.valid_until.is_some() {
item.valid_until = u.valid_until;
}
if let Some(tags) = u.tags {
item.tags = tags;
}
}
EventKind::ItemExpire(e) => {
let Some(item) = state.items.get_mut(&e.item_id) else {
warn!(
item_id = %e.item_id,
"item.expire references unknown item; dropping per #799 invariant"
);
return;
};
if item.expired.is_none() {
item.expired = Some(e.reason);
}
}
EventKind::ItemComplete(c) => {
let Some(item) = state.items.get_mut(&c.item_id) else {
warn!(
item_id = %c.item_id,
"item.complete references unknown item; dropping per #799 invariant"
);
return;
};
item.completed = true;
}
EventKind::DecisionRecord(d) => {
state.decisions.push(ProjectedDecision {
id: d.decision_id,
text: d.text,
alternatives: d.alternatives.unwrap_or_default(),
});
}
EventKind::ResearchNote(n) => {
state.notes.push(ProjectedNote {
id: n.note_id,
text: n.text,
links: n.links.unwrap_or_default(),
valid_until: n.valid_until,
});
}
EventKind::ReflectionError(_) => {
}
}
}
#[must_use]
pub fn class_default_ttl(class: &ItemClass) -> Duration {
match class {
ItemClass::Todo => Duration::from_secs(7 * 24 * 60 * 60),
ItemClass::Research => Duration::from_secs(30 * 24 * 60 * 60),
ItemClass::Question => Duration::from_secs(14 * 24 * 60 * 60),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use chrono::TimeZone;
fn ts() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
}
fn span() -> TranscriptSpan {
TranscriptSpan {
start_event_id: ulid::Ulid::from_parts(0, 1),
end_event_id: ulid::Ulid::from_parts(0, 2),
}
}
fn provenance() -> Provenance {
Provenance {
transcript_span: Some(span()),
model: Some("claude-sonnet-4-6".to_string()),
prompt_version: Some("abcd1234".to_string()),
}
}
fn event(event_id: u128, kind: EventKind) -> Event {
Event {
event_id: ulid::Ulid::from_parts(0, event_id),
ts: ts(),
reflection_id: ReflectionId::Ulid(ulid::Ulid::from_parts(0, 100)),
provenance: provenance(),
kind,
}
}
fn id(n: u128) -> ItemId {
ulid::Ulid::from_parts(0, n)
}
#[test]
fn item_create_serialises_with_adjacent_tag() {
let e = event(
10,
EventKind::ItemCreate(ItemCreate {
item_id: id(200),
class: ItemClass::Todo,
text: "wire it up".to_string(),
priority: None,
valid_until: None,
tags: None,
}),
);
let json = serde_json::to_string(&e).unwrap();
assert!(
json.contains(r#""event_type":"item.create""#),
"missing event_type discriminator: {json}"
);
assert!(
json.contains(r#""payload":{"#),
"missing payload object: {json}"
);
assert!(
json.contains(r#""class":"todo""#),
"missing class enum rename: {json}"
);
}
#[test]
fn event_round_trips_through_serde_json() {
let original = event(
10,
EventKind::ItemCreate(ItemCreate {
item_id: id(200),
class: ItemClass::Research,
text: "look into LocalAgreement-2".to_string(),
priority: Some(Priority::High),
valid_until: Some(ts()),
tags: Some(vec!["asr".to_string(), "whisper".to_string()]),
}),
);
let s = serde_json::to_string(&original).unwrap();
let back: Event = serde_json::from_str(&s).unwrap();
assert_eq!(original, back);
}
#[test]
fn reflection_id_review_serialises_as_string_literal() {
let id = ReflectionId::Review;
let s = serde_json::to_string(&id).unwrap();
assert_eq!(s, r#""review""#);
let back: ReflectionId = serde_json::from_str(&s).unwrap();
assert_eq!(back, ReflectionId::Review);
}
#[test]
fn reflection_id_ulid_round_trips() {
let u = ulid::Ulid::from_parts(0, 42);
let id = ReflectionId::Ulid(u);
let s = serde_json::to_string(&id).unwrap();
let back: ReflectionId = serde_json::from_str(&s).unwrap();
assert_eq!(back, ReflectionId::Ulid(u));
}
#[test]
fn project_orders_by_event_id_not_insertion_order() {
let item_a = id(1);
let events = vec![
event(
20,
EventKind::ItemUpdate(ItemUpdate {
item_id: item_a,
text: Some("third write".to_string()),
..Default::default()
}),
),
event(
10,
EventKind::ItemCreate(ItemCreate {
item_id: item_a,
class: ItemClass::Todo,
text: "first write".to_string(),
priority: None,
valid_until: None,
tags: None,
}),
),
event(
15,
EventKind::ItemUpdate(ItemUpdate {
item_id: item_a,
text: Some("second write".to_string()),
..Default::default()
}),
),
];
let state = project(events);
assert_eq!(state.items.get(&item_a).unwrap().text, "third write");
}
#[test]
fn project_drops_update_for_unknown_item() {
let state = project(vec![event(
10,
EventKind::ItemUpdate(ItemUpdate {
item_id: id(999),
text: Some("no such item".to_string()),
..Default::default()
}),
)]);
assert!(state.items.is_empty());
}
#[test]
fn project_applies_all_item_update_fields() {
let i = id(1);
let state = project(vec![
event(
10,
EventKind::ItemCreate(ItemCreate {
item_id: i,
class: ItemClass::Todo,
text: "original".into(),
priority: None,
valid_until: None,
tags: None,
}),
),
event(
11,
EventKind::ItemUpdate(ItemUpdate {
item_id: i,
text: Some("updated".into()),
priority: Some(Priority::High),
valid_until: Some(ts()),
tags: Some(vec!["urgent".into()]),
}),
),
]);
let item = state.items.get(&i).unwrap();
assert_eq!(item.text, "updated");
assert_eq!(item.priority, Priority::High);
assert_eq!(item.valid_until, Some(ts()));
assert_eq!(item.tags, vec!["urgent".to_string()]);
}
#[test]
fn project_drops_expire_and_complete_for_unknown_items() {
let state = project(vec![
event(
10,
EventKind::ItemExpire(ItemExpire {
item_id: id(99),
reason: ExpireReason::Retracted,
superseded_by: None,
}),
),
event(
11,
EventKind::ItemComplete(ItemComplete {
item_id: id(99),
note: None,
}),
),
]);
assert!(state.items.is_empty());
}
#[test]
fn project_marks_completed_and_expired() {
let i = id(1);
let state = project(vec![
event(
10,
EventKind::ItemCreate(ItemCreate {
item_id: i,
class: ItemClass::Todo,
text: "x".into(),
priority: None,
valid_until: None,
tags: None,
}),
),
event(
11,
EventKind::ItemComplete(ItemComplete {
item_id: i,
note: Some("done".into()),
}),
),
event(
12,
EventKind::ItemExpire(ItemExpire {
item_id: i,
reason: ExpireReason::Superseded,
superseded_by: Some(id(2)),
}),
),
]);
let item = state.items.get(&i).unwrap();
assert!(item.completed);
assert_eq!(item.expired, Some(ExpireReason::Superseded));
}
#[test]
fn project_expire_is_idempotent() {
let i = id(1);
let state = project(vec![
event(
10,
EventKind::ItemCreate(ItemCreate {
item_id: i,
class: ItemClass::Todo,
text: "x".into(),
priority: None,
valid_until: None,
tags: None,
}),
),
event(
11,
EventKind::ItemExpire(ItemExpire {
item_id: i,
reason: ExpireReason::Retracted,
superseded_by: None,
}),
),
event(
12,
EventKind::ItemExpire(ItemExpire {
item_id: i,
reason: ExpireReason::Superseded,
superseded_by: Some(id(2)),
}),
),
]);
assert_eq!(
state.items.get(&i).unwrap().expired,
Some(ExpireReason::Retracted)
);
}
#[test]
fn project_skips_reflection_errors() {
let state = project(vec![event(
10,
EventKind::ReflectionError(ReflectionError {
raw_output: "garbage".into(),
error: "missing item_id".into(),
}),
)]);
assert!(state.items.is_empty());
assert!(state.decisions.is_empty());
assert!(state.notes.is_empty());
}
#[test]
fn project_appends_decisions_and_notes() {
let state = project(vec![
event(
10,
EventKind::DecisionRecord(DecisionRecord {
decision_id: id(1),
text: "use ULIDs".into(),
alternatives: Some(vec!["UUIDv7".into()]),
}),
),
event(
11,
EventKind::ResearchNote(ResearchNote {
note_id: id(2),
text: "AssemblyAI is immutable-finals".into(),
links: None,
valid_until: None,
}),
),
]);
assert_eq!(state.decisions.len(), 1);
assert_eq!(state.notes.len(), 1);
}
#[test]
fn class_default_ttls_match_799() {
assert_eq!(
class_default_ttl(&ItemClass::Todo),
Duration::from_secs(7 * 86_400)
);
assert_eq!(
class_default_ttl(&ItemClass::Research),
Duration::from_secs(30 * 86_400)
);
assert_eq!(
class_default_ttl(&ItemClass::Question),
Duration::from_secs(14 * 86_400)
);
}
}