Skip to main content

starweaver_session/
model_selection.rs

1//! Product-neutral durable model-selection and mutation-receipt contracts.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::{PendingHostEventPublication, SessionStoreError, SessionStoreResult};
7
8/// Stable operation identifier recorded for model-selection mutations.
9pub const MODEL_SELECTION_OPERATION: &str = "model_selection.select";
10
11/// Authority-bound durable model selection.
12///
13/// `authority_binding` is a trusted host-derived binding (for example an authorization-scope
14/// fingerprint), not a caller-selected display name.
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
16pub struct DurableModelSelection {
17    /// Stable authority binding that owns this selection.
18    pub authority_binding: String,
19    /// Selected profile identifier.
20    pub selected_profile: String,
21    /// Effective model identifier resolved by the caller's catalog.
22    pub model_id: String,
23    /// Monotonic selection revision, beginning at one.
24    pub revision: u64,
25    /// Time at which this revision was committed.
26    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    /// Validate durable model-selection invariants.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error when an identity is empty or the revision is zero.
39    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/// Caller-supplied default used only when an authority has no durable selection yet.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct InitializeModelSelection {
55    /// Stable trusted authority binding.
56    pub authority_binding: String,
57    /// Default selected profile.
58    pub selected_profile: String,
59    /// Effective model for the default profile.
60    pub model_id: String,
61    /// Initialization timestamp persisted with revision one.
62    pub initialized_at: DateTime<Utc>,
63}
64
65impl InitializeModelSelection {
66    /// Validate initialization input.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error when a required string is empty.
71    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/// Durable, wire-independent projection of an idempotent mutation receipt.
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80pub struct MutationReceipt {
81    /// Stable receipt identity.
82    pub receipt_id: String,
83    /// Idempotency key bound to the canonical fingerprint.
84    pub idempotency_key: String,
85    /// Canonical normalized command fingerprint.
86    pub fingerprint: String,
87    /// Product-neutral operation identifier.
88    pub operation: String,
89    /// Durable mutation outcome.
90    pub state: String,
91    /// Stable target binding.
92    pub target_ref: String,
93    /// Whether external reconciliation is required.
94    pub reconciliation_required: bool,
95    /// Whether this returned projection came from an exact replay.
96    ///
97    /// The durable original is always stored with `false`; storage sets this to `true` only on a
98    /// cloned return projection for an exact idempotent replay.
99    pub replayed: bool,
100    /// Time at which the original mutation committed.
101    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    /// Return a replay projection without mutating the durable original receipt.
110    #[must_use]
111    pub fn replayed_projection(&self) -> Self {
112        let mut projection = self.clone();
113        projection.replayed = true;
114        projection
115    }
116
117    /// Validate mutation-receipt invariants.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error when a required identity is empty.
122    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/// Atomic model-selection mutation request.
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct SelectModel {
135    /// Stable trusted authority binding that owns the state and idempotency namespace.
136    pub authority_binding: String,
137    /// Profile to select.
138    pub selected_profile: String,
139    /// Effective model identifier resolved by the caller's catalog.
140    pub model_id: String,
141    /// Idempotency key scoped to `authority_binding`.
142    pub idempotency_key: String,
143    /// Canonical fingerprint of the normalized authorized command.
144    pub command_fingerprint: String,
145    /// Authoritative mutation timestamp.
146    pub occurred_at: DateTime<Utc>,
147    /// Optional product-neutral durable host event to enqueue in the same transaction.
148    pub host_event_publication: Option<PendingHostEventPublication>,
149}
150
151impl SelectModel {
152    /// Validate mutation input.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error when required strings or optional event evidence are invalid.
157    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/// Durable result of one model-selection mutation.
174#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
175pub struct ModelSelectionMutationReceipt {
176    /// Selection committed by the original mutation.
177    pub selection: DurableModelSelection,
178    /// Durable mutation receipt, projected with `replayed = true` for exact retries.
179    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    /// Return an exact-replay projection while preserving the durable original value.
188    #[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    /// Validate persisted result invariants.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error when selection and receipt identities disagree or are invalid.
201    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}