Skip to main content

starweaver_session/
interaction.rs

1//! Product-neutral commands and results for durable human-interaction mutations.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use starweaver_core::{Metadata, RunId, SessionId};
9
10use crate::{
11    ApprovalDecision, ApprovalRecord, ApprovalStatus, DeferredToolRecord, ExecutionStatus,
12    MutationReceipt, PendingHostEventPublication, SessionStoreError, SessionStoreResult,
13};
14
15/// Stable operation identifier for approval decisions.
16pub const APPROVAL_DECIDE_OPERATION: &str = "approval.decide";
17/// Stable operation identifier for successful deferred-tool completion.
18pub const DEFERRED_COMPLETE_OPERATION: &str = "deferred.complete";
19/// Stable operation identifier for failed deferred-tool completion.
20pub const DEFERRED_FAIL_OPERATION: &str = "deferred.fail";
21/// Stable operation identifier for clarification resolution.
22pub const CLARIFICATION_RESOLVE_OPERATION: &str = "clarification.resolve";
23/// Tool name whose approval records represent clarification requests.
24pub const ASK_USER_QUESTION_ACTION: &str = "ask_user_question";
25/// Approval-decision metadata key containing validated clarification answers.
26pub const CLARIFICATION_ANSWERS_METADATA_KEY: &str = "clarification_answers";
27/// Approval-decision metadata key containing an optional aggregate response.
28pub const CLARIFICATION_RESPONSE_METADATA_KEY: &str = "clarification_response";
29
30/// Shared authority, concurrency, idempotency, and publication evidence for an interaction command.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct InteractionMutationContext {
33    /// Trusted authority binding owning the idempotency namespace.
34    pub authority_binding: String,
35    /// Expected current revision of the authoritative interaction record.
36    pub expected_revision: u64,
37    /// Idempotency key scoped to `authority_binding`.
38    pub idempotency_key: String,
39    /// Canonical fingerprint of the normalized authorized command.
40    pub command_fingerprint: String,
41    /// Authoritative mutation timestamp.
42    pub occurred_at: DateTime<Utc>,
43    /// Optional product-neutral host event committed with state and receipt.
44    pub host_event_publication: Option<PendingHostEventPublication>,
45}
46
47impl InteractionMutationContext {
48    /// Validate shared mutation evidence.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error for empty authority/idempotency evidence, revision zero, or an invalid
53    /// caller-supplied host-event publication.
54    pub fn validate(&self) -> SessionStoreResult<()> {
55        require_non_empty("interaction authority binding", &self.authority_binding)?;
56        require_non_empty("interaction idempotency key", &self.idempotency_key)?;
57        require_non_empty("interaction command fingerprint", &self.command_fingerprint)?;
58        if self.expected_revision == 0 {
59            return Err(SessionStoreError::Failed(
60                "interaction expected revision must be greater than zero".to_string(),
61            ));
62        }
63        if let Some(publication) = &self.host_event_publication {
64            publication.validate()?;
65        }
66        Ok(())
67    }
68}
69
70/// Atomic approval-decision command.
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct DecideApproval {
73    /// Shared mutation evidence.
74    pub context: InteractionMutationContext,
75    /// Owning session.
76    pub session_id: SessionId,
77    /// Owning run.
78    pub run_id: RunId,
79    /// Approval identity.
80    pub approval_id: String,
81    /// Terminal decision to persist. Only approved and denied are accepted.
82    pub decision: ApprovalDecision,
83}
84
85impl DecideApproval {
86    /// Validate command-local invariants.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error when shared evidence is invalid, the id is empty, the decision is not
91    /// approved/denied, or the decision timestamp differs from the mutation timestamp.
92    pub fn validate(&self) -> SessionStoreResult<()> {
93        self.context.validate()?;
94        require_non_empty("approval id", &self.approval_id)?;
95        if !matches!(
96            self.decision.status,
97            ApprovalStatus::Approved | ApprovalStatus::Denied
98        ) {
99            return Err(SessionStoreError::Failed(
100                "approval decision must be approved or denied".to_string(),
101            ));
102        }
103        if self.decision.decided_at != self.context.occurred_at {
104            return Err(SessionStoreError::Failed(
105                "approval decision time must match mutation time".to_string(),
106            ));
107        }
108        Ok(())
109    }
110}
111
112/// Durable result of an atomic approval decision.
113#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
114pub struct ApprovalMutationResult {
115    /// Authoritative post-mutation approval record.
116    pub approval: ApprovalRecord,
117    /// Durable mutation receipt.
118    pub receipt: MutationReceipt,
119}
120
121impl starweaver_core::VersionedRecord for ApprovalMutationResult {
122    const SCHEMA: &'static str = "starweaver.session.approval_mutation_result";
123}
124
125impl ApprovalMutationResult {
126    /// Return an exact-replay projection without changing durable evidence.
127    #[must_use]
128    pub fn replayed_projection(&self) -> Self {
129        Self {
130            approval: self.approval.clone(),
131            receipt: self.receipt.replayed_projection(),
132        }
133    }
134}
135
136/// Terminal outcome supplied for a deferred tool.
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138#[serde(tag = "status", rename_all = "snake_case")]
139pub enum DeferredMutationOutcome {
140    /// Tool completed successfully.
141    Completed {
142        /// Tool response.
143        response: Value,
144        /// Product-neutral result metadata.
145        #[serde(default, skip_serializing_if = "Metadata::is_empty")]
146        metadata: Metadata,
147    },
148    /// Tool failed.
149    Failed {
150        /// Safe failure response.
151        response: Value,
152        /// Product-neutral result metadata.
153        #[serde(default, skip_serializing_if = "Metadata::is_empty")]
154        metadata: Metadata,
155    },
156}
157
158impl DeferredMutationOutcome {
159    /// Stable operation selected by this terminal outcome.
160    #[must_use]
161    pub const fn operation(&self) -> &'static str {
162        match self {
163            Self::Completed { .. } => DEFERRED_COMPLETE_OPERATION,
164            Self::Failed { .. } => DEFERRED_FAIL_OPERATION,
165        }
166    }
167
168    /// Durable execution status selected by this terminal outcome.
169    #[must_use]
170    pub const fn status(&self) -> ExecutionStatus {
171        match self {
172            Self::Completed { .. } => ExecutionStatus::Completed,
173            Self::Failed { .. } => ExecutionStatus::Failed,
174        }
175    }
176
177    /// Borrow the response and metadata.
178    #[must_use]
179    pub const fn parts(&self) -> (&Value, &Metadata) {
180        match self {
181            Self::Completed { response, metadata } | Self::Failed { response, metadata } => {
182                (response, metadata)
183            }
184        }
185    }
186}
187
188/// Atomic deferred-tool completion/failure command.
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct ResolveDeferredTool {
191    /// Shared mutation evidence.
192    pub context: InteractionMutationContext,
193    /// Owning session.
194    pub session_id: SessionId,
195    /// Owning run.
196    pub run_id: RunId,
197    /// Deferred request identity.
198    pub deferred_id: String,
199    /// Terminal outcome.
200    pub outcome: DeferredMutationOutcome,
201}
202
203impl ResolveDeferredTool {
204    /// Validate command-local invariants.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error when shared mutation evidence is invalid or the deferred id is empty.
209    pub fn validate(&self) -> SessionStoreResult<()> {
210        self.context.validate()?;
211        require_non_empty("deferred id", &self.deferred_id)
212    }
213}
214
215/// Durable result of an atomic deferred-tool mutation.
216#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
217pub struct DeferredMutationResult {
218    /// Authoritative post-mutation deferred record.
219    pub deferred: DeferredToolRecord,
220    /// Durable mutation receipt.
221    pub receipt: MutationReceipt,
222}
223
224impl starweaver_core::VersionedRecord for DeferredMutationResult {
225    const SCHEMA: &'static str = "starweaver.session.deferred_mutation_result";
226}
227
228impl DeferredMutationResult {
229    /// Return an exact-replay projection without changing durable evidence.
230    #[must_use]
231    pub fn replayed_projection(&self) -> Self {
232        Self {
233            deferred: self.deferred.clone(),
234            receipt: self.receipt.replayed_projection(),
235        }
236    }
237}
238
239/// One option in a durable clarification question.
240#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
241pub struct ClarificationOption {
242    /// Stable option label returned in answers.
243    pub label: String,
244    /// Human-readable option description.
245    #[serde(default)]
246    pub description: String,
247    /// Optional preview.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub preview: Option<String>,
250}
251
252/// One question parsed from durable `ask_user_question` request evidence.
253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
254pub struct ClarificationQuestion {
255    /// Human-readable short header.
256    #[serde(default)]
257    pub header: String,
258    /// Stable full question text and answer binding.
259    pub question: String,
260    /// Whether more than one listed option may be selected.
261    #[serde(default, alias = "multiSelect")]
262    pub multi_select: bool,
263    /// Listed options. An empty list means a free-text-only question.
264    #[serde(default)]
265    pub options: Vec<ClarificationOption>,
266}
267
268/// Validated answer to one clarification question.
269#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
270pub struct ClarificationAnswer {
271    /// Exact question text identifying the durable request question.
272    pub question: String,
273    /// Selected durable option labels.
274    #[serde(default, alias = "selectedOptions")]
275    pub selected_options: Vec<String>,
276    /// Optional non-empty free-text answer.
277    #[serde(default, alias = "freeText", skip_serializing_if = "Option::is_none")]
278    pub free_text: Option<String>,
279}
280
281/// Atomic clarification-resolution command.
282#[derive(Clone, Debug, Eq, PartialEq)]
283pub struct ResolveClarification {
284    /// Shared mutation evidence.
285    pub context: InteractionMutationContext,
286    /// Owning session.
287    pub session_id: SessionId,
288    /// Owning run.
289    pub run_id: RunId,
290    /// Approval identity representing the clarification.
291    pub clarification_id: String,
292    /// Answers, exactly one per durable request question.
293    pub answers: Vec<ClarificationAnswer>,
294    /// Optional aggregate response retained with answer metadata.
295    pub response: Option<String>,
296    /// Actor resolving the clarification.
297    pub resolved_by: Option<String>,
298}
299
300impl ResolveClarification {
301    /// Validate command-local invariants that do not require the durable request.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error when shared mutation evidence is invalid, an identity is empty, or an
306    /// optional response/resolver is present but empty.
307    pub fn validate(&self) -> SessionStoreResult<()> {
308        self.context.validate()?;
309        require_non_empty("clarification id", &self.clarification_id)?;
310        if self.response.as_deref().is_some_and(str::is_empty) {
311            return Err(SessionStoreError::Failed(
312                "clarification response cannot be empty".to_string(),
313            ));
314        }
315        if self.resolved_by.as_deref().is_some_and(str::is_empty) {
316            return Err(SessionStoreError::Failed(
317                "clarification resolver cannot be empty".to_string(),
318            ));
319        }
320        Ok(())
321    }
322}
323
324/// Durable typed clarification resolution.
325#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
326pub struct ClarificationResolution {
327    /// Clarification/approval identity.
328    pub clarification_id: String,
329    /// Owning session.
330    pub session_id: SessionId,
331    /// Owning run.
332    pub run_id: RunId,
333    /// Questions as read from the authoritative durable request.
334    pub questions: Vec<ClarificationQuestion>,
335    /// Validated answers in durable question order.
336    pub answers: Vec<ClarificationAnswer>,
337    /// Optional aggregate response.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub response: Option<String>,
340    /// Post-mutation revision of the backing approval record.
341    pub revision: u64,
342    /// Resolution timestamp.
343    pub resolved_at: DateTime<Utc>,
344}
345
346/// Durable result of an atomic clarification resolution.
347#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
348pub struct ClarificationMutationResult {
349    /// Typed, validated resolution projection.
350    pub clarification: ClarificationResolution,
351    /// Authoritative post-mutation approval record.
352    pub approval: ApprovalRecord,
353    /// Durable mutation receipt.
354    pub receipt: MutationReceipt,
355}
356
357impl starweaver_core::VersionedRecord for ClarificationMutationResult {
358    const SCHEMA: &'static str = "starweaver.session.clarification_mutation_result";
359}
360
361impl ClarificationMutationResult {
362    /// Return an exact-replay projection without changing durable evidence.
363    #[must_use]
364    pub fn replayed_projection(&self) -> Self {
365        Self {
366            clarification: self.clarification.clone(),
367            approval: self.approval.clone(),
368            receipt: self.receipt.replayed_projection(),
369        }
370    }
371}
372
373/// Parse and strictly validate a durable clarification request and caller answers.
374///
375/// The request must be an object containing a non-empty `questions` array. Questions and option
376/// labels must be unique. Answers must bind one-to-one by exact question text; selected labels
377/// must exist, single-select questions accept at most one label, and every answer must contain a
378/// selection or non-empty free text.
379///
380/// # Errors
381///
382/// Returns an error when the durable request is malformed or answers do not bind one-to-one to
383/// its questions and option constraints.
384#[allow(clippy::too_many_lines)]
385pub fn validate_clarification_answers(
386    request: &Value,
387    answers: &[ClarificationAnswer],
388) -> SessionStoreResult<(Vec<ClarificationQuestion>, Vec<ClarificationAnswer>)> {
389    let questions_value = request
390        .as_object()
391        .and_then(|object| object.get("questions"))
392        .ok_or_else(|| {
393            SessionStoreError::Failed(
394                "clarification request must contain a questions array".to_string(),
395            )
396        })?;
397    let questions = serde_json::from_value::<Vec<ClarificationQuestion>>(questions_value.clone())
398        .map_err(|error| {
399        SessionStoreError::Failed(format!("malformed clarification questions: {error}"))
400    })?;
401    if questions.is_empty() {
402        return Err(SessionStoreError::Failed(
403            "clarification request must contain at least one question".to_string(),
404        ));
405    }
406
407    let mut by_question = BTreeMap::new();
408    for question in &questions {
409        require_non_empty("clarification question", &question.question)?;
410        let mut labels = BTreeSet::new();
411        for option in &question.options {
412            require_non_empty("clarification option label", &option.label)?;
413            if !labels.insert(option.label.as_str()) {
414                return Err(SessionStoreError::Failed(format!(
415                    "duplicate clarification option {} for question {}",
416                    option.label, question.question
417                )));
418            }
419        }
420        if by_question
421            .insert(question.question.as_str(), question)
422            .is_some()
423        {
424            return Err(SessionStoreError::Failed(format!(
425                "duplicate clarification question {}",
426                question.question
427            )));
428        }
429    }
430
431    if answers.len() != questions.len() {
432        return Err(SessionStoreError::Failed(
433            "clarification answers must match every durable question exactly once".to_string(),
434        ));
435    }
436    let mut answer_by_question = BTreeMap::new();
437    for answer in answers {
438        let question = by_question.get(answer.question.as_str()).ok_or_else(|| {
439            SessionStoreError::Failed(format!(
440                "clarification answer does not match durable question {}",
441                answer.question
442            ))
443        })?;
444        if answer_by_question
445            .insert(answer.question.as_str(), answer)
446            .is_some()
447        {
448            return Err(SessionStoreError::Failed(format!(
449                "duplicate clarification answer for {}",
450                answer.question
451            )));
452        }
453        if answer.free_text.as_deref().is_some_and(str::is_empty) {
454            return Err(SessionStoreError::Failed(format!(
455                "clarification free text cannot be empty for {}",
456                answer.question
457            )));
458        }
459        if answer.selected_options.is_empty() && answer.free_text.is_none() {
460            return Err(SessionStoreError::Failed(format!(
461                "clarification answer is empty for {}",
462                answer.question
463            )));
464        }
465        if !question.multi_select && answer.selected_options.len() > 1 {
466            return Err(SessionStoreError::Failed(format!(
467                "clarification question {} does not allow multiple selections",
468                answer.question
469            )));
470        }
471        let allowed = question
472            .options
473            .iter()
474            .map(|option| option.label.as_str())
475            .collect::<BTreeSet<_>>();
476        let mut selected = BTreeSet::new();
477        for label in &answer.selected_options {
478            if !selected.insert(label.as_str()) {
479                return Err(SessionStoreError::Failed(format!(
480                    "duplicate clarification selection {label} for {}",
481                    answer.question
482                )));
483            }
484            if !allowed.contains(label.as_str()) {
485                return Err(SessionStoreError::Failed(format!(
486                    "clarification selection {label} is not an option for {}",
487                    answer.question
488                )));
489            }
490        }
491    }
492
493    let ordered_answers = questions
494        .iter()
495        .map(|question| {
496            answer_by_question
497                .get(question.question.as_str())
498                .map(|answer| (*answer).clone())
499                .ok_or_else(|| {
500                    SessionStoreError::Failed(format!(
501                        "missing clarification answer for {}",
502                        question.question
503                    ))
504                })
505        })
506        .collect::<SessionStoreResult<Vec<_>>>()?;
507    Ok((questions, ordered_answers))
508}
509
510fn require_non_empty(label: &str, value: &str) -> SessionStoreResult<()> {
511    if value.is_empty() {
512        return Err(SessionStoreError::Failed(format!(
513            "{label} cannot be empty"
514        )));
515    }
516    Ok(())
517}