1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::{PendingHostEventPublication, SessionStoreError, SessionStoreResult};
7
8pub const MODEL_SELECTION_OPERATION: &str = "model_selection.select";
10
11#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16pub struct DurableModelSelection {
17 pub authority_binding: String,
19 pub selected_profile: String,
21 pub model_id: String,
23 pub revision: u64,
25 pub updated_at: DateTime<Utc>,
27}
28
29impl starweaver_core::VersionedRecord for DurableModelSelection {
30 const SCHEMA: &'static str = "starweaver.session.model_selection";
31}
32
33impl DurableModelSelection {
34 pub fn validate(&self) -> SessionStoreResult<()> {
40 require_non_empty("model selection authority binding", &self.authority_binding)?;
41 require_non_empty("model selection profile", &self.selected_profile)?;
42 require_non_empty("model selection model id", &self.model_id)?;
43 if self.revision == 0 {
44 return Err(SessionStoreError::Failed(
45 "model selection revision must be greater than zero".to_string(),
46 ));
47 }
48 Ok(())
49 }
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct InitializeModelSelection {
55 pub authority_binding: String,
57 pub selected_profile: String,
59 pub model_id: String,
61 pub initialized_at: DateTime<Utc>,
63}
64
65impl InitializeModelSelection {
66 pub fn validate(&self) -> SessionStoreResult<()> {
72 require_non_empty("model selection authority binding", &self.authority_binding)?;
73 require_non_empty("model selection profile", &self.selected_profile)?;
74 require_non_empty("model selection model id", &self.model_id)
75 }
76}
77
78#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80pub struct MutationReceipt {
81 pub receipt_id: String,
83 pub idempotency_key: String,
85 pub fingerprint: String,
87 pub operation: String,
89 pub state: String,
91 pub target_ref: String,
93 pub reconciliation_required: bool,
95 pub replayed: bool,
100 pub created_at: DateTime<Utc>,
102}
103
104impl starweaver_core::VersionedRecord for MutationReceipt {
105 const SCHEMA: &'static str = "starweaver.session.mutation_receipt";
106}
107
108impl MutationReceipt {
109 #[must_use]
111 pub fn replayed_projection(&self) -> Self {
112 let mut projection = self.clone();
113 projection.replayed = true;
114 projection
115 }
116
117 pub fn validate(&self) -> SessionStoreResult<()> {
123 require_non_empty("mutation receipt id", &self.receipt_id)?;
124 require_non_empty("mutation receipt idempotency key", &self.idempotency_key)?;
125 require_non_empty("mutation receipt fingerprint", &self.fingerprint)?;
126 require_non_empty("mutation receipt operation", &self.operation)?;
127 require_non_empty("mutation receipt state", &self.state)?;
128 require_non_empty("mutation receipt target", &self.target_ref)
129 }
130}
131
132#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct SelectModel {
135 pub authority_binding: String,
137 pub selected_profile: String,
139 pub model_id: String,
141 pub idempotency_key: String,
143 pub command_fingerprint: String,
145 pub occurred_at: DateTime<Utc>,
147 pub host_event_publication: Option<PendingHostEventPublication>,
149}
150
151impl SelectModel {
152 pub fn validate(&self) -> SessionStoreResult<()> {
158 require_non_empty("model selection authority binding", &self.authority_binding)?;
159 require_non_empty("model selection profile", &self.selected_profile)?;
160 require_non_empty("model selection model id", &self.model_id)?;
161 require_non_empty("model selection idempotency key", &self.idempotency_key)?;
162 require_non_empty(
163 "model selection command fingerprint",
164 &self.command_fingerprint,
165 )?;
166 if let Some(publication) = &self.host_event_publication {
167 publication.validate()?;
168 }
169 Ok(())
170 }
171}
172
173#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
175pub struct ModelSelectionMutationReceipt {
176 pub selection: DurableModelSelection,
178 pub receipt: MutationReceipt,
180}
181
182impl starweaver_core::VersionedRecord for ModelSelectionMutationReceipt {
183 const SCHEMA: &'static str = "starweaver.session.model_selection_mutation_receipt";
184}
185
186impl ModelSelectionMutationReceipt {
187 #[must_use]
189 pub fn replayed_projection(&self) -> Self {
190 Self {
191 selection: self.selection.clone(),
192 receipt: self.receipt.replayed_projection(),
193 }
194 }
195
196 pub fn validate(&self) -> SessionStoreResult<()> {
202 self.selection.validate()?;
203 self.receipt.validate()?;
204 if self.receipt.target_ref != self.selection.authority_binding {
205 return Err(SessionStoreError::Conflict(
206 "model selection receipt target does not match selection authority".to_string(),
207 ));
208 }
209 if self.receipt.operation != MODEL_SELECTION_OPERATION {
210 return Err(SessionStoreError::Conflict(
211 "model selection receipt operation is invalid".to_string(),
212 ));
213 }
214 Ok(())
215 }
216}
217
218fn require_non_empty(label: &str, value: &str) -> SessionStoreResult<()> {
219 if value.is_empty() {
220 return Err(SessionStoreError::Failed(format!(
221 "{label} cannot be empty"
222 )));
223 }
224 Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229 use chrono::Utc;
230
231 use super::{DurableModelSelection, ModelSelectionMutationReceipt, MutationReceipt};
232
233 #[test]
234 fn replay_projection_does_not_mutate_durable_original() {
235 let original = ModelSelectionMutationReceipt {
236 selection: DurableModelSelection {
237 authority_binding: "authority-a".to_string(),
238 selected_profile: "coding".to_string(),
239 model_id: "model-a".to_string(),
240 revision: 2,
241 updated_at: Utc::now(),
242 },
243 receipt: MutationReceipt {
244 receipt_id: "receipt-a".to_string(),
245 idempotency_key: "key-a".to_string(),
246 fingerprint: "sha256:a".to_string(),
247 operation: super::MODEL_SELECTION_OPERATION.to_string(),
248 state: "applied".to_string(),
249 target_ref: "authority-a".to_string(),
250 reconciliation_required: false,
251 replayed: false,
252 created_at: Utc::now(),
253 },
254 };
255
256 let replay = original.replayed_projection();
257 assert!(replay.receipt.replayed);
258 assert!(!original.receipt.replayed);
259 assert_eq!(replay.selection, original.selection);
260 }
261}