perspt_sdk/model/
projection.rs1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use super::{Conversation, Message};
8use crate::error::{Result, SdkError};
9use crate::ledger::content_hash;
10
11pub const CONVERSATION_EVENT_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct ConversationSeeded {
15 pub event_schema_version: u32,
16 pub conversation: Conversation,
17 pub digest: String,
18}
19
20impl ConversationSeeded {
21 pub fn new(conversation: Conversation) -> Result<Self> {
22 let payload = serde_json::to_vec(&conversation)
23 .map_err(|error| SdkError::Domain(error.to_string()))?;
24 let digest = content_hash(
25 [b"conversation-seeded:v1:".as_slice(), payload.as_slice()]
26 .concat()
27 .as_slice(),
28 );
29 Ok(Self {
30 event_schema_version: CONVERSATION_EVENT_SCHEMA_VERSION,
31 conversation,
32 digest,
33 })
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(tag = "delta", rename_all = "snake_case")]
39pub enum ConversationDelta {
40 Message { message: Message },
41 ToolActivated { name: String },
42 Compacted { control: String },
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct ConversationDeltaRecord {
47 pub event_schema_version: u32,
48 pub prior_digest: String,
49 pub delta: ConversationDelta,
50 pub digest: String,
51}
52
53#[derive(Debug, Clone, PartialEq)]
56pub struct ConversationProjection {
57 conversation: Conversation,
58 activated_tools: BTreeSet<String>,
59 digest: String,
60}
61
62impl ConversationProjection {
63 pub fn from_seed(seed: &ConversationSeeded) -> Result<Self> {
64 anyhow_seed(seed)?;
65 Ok(Self {
66 conversation: seed.conversation.clone(),
67 activated_tools: BTreeSet::new(),
68 digest: seed.digest.clone(),
69 })
70 }
71
72 pub fn prepare(&self, delta: ConversationDelta) -> Result<ConversationDeltaRecord> {
75 let digest = delta_digest(&self.digest, &delta)?;
76 Ok(ConversationDeltaRecord {
77 event_schema_version: CONVERSATION_EVENT_SCHEMA_VERSION,
78 prior_digest: self.digest.clone(),
79 delta,
80 digest,
81 })
82 }
83
84 pub fn apply(&mut self, record: &ConversationDeltaRecord) -> Result<()> {
86 if record.event_schema_version != CONVERSATION_EVENT_SCHEMA_VERSION {
87 return Err(SdkError::Domain(
88 "unsupported conversation event schema".into(),
89 ));
90 }
91 if record.prior_digest != self.digest {
92 return Err(SdkError::Domain(
93 "conversation delta has a stale parent".into(),
94 ));
95 }
96 if delta_digest(&record.prior_digest, &record.delta)? != record.digest {
97 return Err(SdkError::Domain(
98 "conversation delta digest mismatch".into(),
99 ));
100 }
101 match &record.delta {
102 ConversationDelta::Message { message } => self.conversation.push(message.clone()),
103 ConversationDelta::ToolActivated { name } => {
104 self.activated_tools.insert(name.clone());
105 }
106 ConversationDelta::Compacted { control } => {
107 self.conversation.compact_with_control(control.clone());
108 }
109 }
110 self.digest = record.digest.clone();
111 Ok(())
112 }
113
114 pub fn refold(seed: &ConversationSeeded, records: &[ConversationDeltaRecord]) -> Result<Self> {
115 let mut projection = Self::from_seed(seed)?;
116 for record in records {
117 projection.apply(record)?;
118 }
119 Ok(projection)
120 }
121
122 pub fn conversation(&self) -> &Conversation {
123 &self.conversation
124 }
125
126 pub fn activated_tools(&self) -> &BTreeSet<String> {
127 &self.activated_tools
128 }
129
130 pub fn unresolved_call_ids(&self) -> Vec<String> {
131 self.conversation.unresolved_call_ids()
132 }
133
134 pub fn digest(&self) -> &str {
135 &self.digest
136 }
137}
138
139fn anyhow_seed(seed: &ConversationSeeded) -> Result<()> {
140 if seed.event_schema_version != CONVERSATION_EVENT_SCHEMA_VERSION {
141 return Err(SdkError::Domain(
142 "unsupported conversation seed schema".into(),
143 ));
144 }
145 let expected = ConversationSeeded::new(seed.conversation.clone())?;
146 if expected.digest != seed.digest {
147 return Err(SdkError::Domain("conversation seed digest mismatch".into()));
148 }
149 Ok(())
150}
151
152fn delta_digest(parent: &str, delta: &ConversationDelta) -> Result<String> {
153 let payload = serde_json::to_vec(delta).map_err(|error| SdkError::Domain(error.to_string()))?;
154 Ok(content_hash(
155 [parent.as_bytes(), b":", payload.as_slice()]
156 .concat()
157 .as_slice(),
158 ))
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn refold_matches_incremental_application() {
167 let seed = ConversationSeeded::new(Conversation::with_system("governed")).unwrap();
168 let mut live = ConversationProjection::from_seed(&seed).unwrap();
169 let first = live
170 .prepare(ConversationDelta::Message {
171 message: Message::User {
172 content: "hello".into(),
173 },
174 })
175 .unwrap();
176 live.apply(&first).unwrap();
177 let second = live
178 .prepare(ConversationDelta::ToolActivated {
179 name: "lookup".into(),
180 })
181 .unwrap();
182 live.apply(&second).unwrap();
183 let folded = ConversationProjection::refold(&seed, &[first, second]).unwrap();
184 assert_eq!(live, folded);
185 }
186
187 #[test]
188 fn tampered_or_reordered_deltas_fail_closed() {
189 let seed = ConversationSeeded::new(Conversation::with_system("s")).unwrap();
190 let projection = ConversationProjection::from_seed(&seed).unwrap();
191 let mut record = projection
192 .prepare(ConversationDelta::ToolActivated { name: "a".into() })
193 .unwrap();
194 record.delta = ConversationDelta::ToolActivated { name: "b".into() };
195 assert!(ConversationProjection::refold(&seed, &[record]).is_err());
196 }
197}