Skip to main content

turnframe_core/
interaction.rs

1//! Persistent interactions: cards, confirmations, selections (spec §15).
2//!
3//! An interaction is a durable, server-owned record. The client only ever
4//! sends back an interaction id, an option id and the case revision it saw
5//! (I7); the meaning of the click is the [`StoredInteractionAction`] persisted
6//! with the option. [`validate_response`] is the pure gate every response
7//! passes through before anything executes.
8
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15use crate::case::CaseRef;
16use crate::command::{CommandOrigin, ResolutionChannel, RiskClass};
17use crate::error::{InteractionError, InteractionSpecError};
18use crate::hash::{Digest, HashError, canonical_digest};
19use crate::ids::{
20    AccountId, CaseRevision, ConversationId, InteractionId, OperationKey, OptionId, TurnId,
21};
22use crate::locale::LocalizedText;
23use crate::reduce::CommandRef;
24use crate::turn::{ActorContext, InteractionResponse};
25
26/// The shape of an interaction (spec §15.2).
27///
28/// New card shapes are expected, so downstream matches need a wildcard arm.
29#[derive(
30    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
31)]
32#[serde(rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum InteractionKind {
35    /// Yes/no.
36    Boolean,
37    /// Pick one option.
38    SingleSelect,
39    /// Pick several options.
40    MultiSelect,
41    /// Free text.
42    Freeform,
43    /// Review a diff before applying.
44    ReviewChanges,
45    /// Confirm compiled commands.
46    ConfirmCommand,
47    /// Choose which case an act targets.
48    SelectTarget,
49    /// Resolve a validation error.
50    ResolveValidationError,
51    /// Re-authenticate.
52    Reauthenticate,
53    /// Sign externally.
54    ExternalSignature,
55}
56
57impl InteractionKind {
58    /// Every kind, in declaration order.
59    pub const ALL: [Self; 10] = [
60        Self::Boolean,
61        Self::SingleSelect,
62        Self::MultiSelect,
63        Self::Freeform,
64        Self::ReviewChanges,
65        Self::ConfirmCommand,
66        Self::SelectTarget,
67        Self::ResolveValidationError,
68        Self::Reauthenticate,
69        Self::ExternalSignature,
70    ];
71
72    /// Returns `true` for cards whose answer authorizes commands.
73    ///
74    /// Such a card is never resolvable from typed text: the authorization must
75    /// be the user's own deterministic answer (spec §15.7, §13.2 rule 8).
76    #[must_use]
77    pub fn authorizes_commands(self) -> bool {
78        matches!(
79            self,
80            Self::ConfirmCommand
81                | Self::ReviewChanges
82                | Self::Reauthenticate
83                | Self::ExternalSignature
84        )
85    }
86}
87
88/// Lifecycle status of an interaction (spec §15.4).
89///
90/// Deliberately exhaustive: the set is a closed state machine with an
91/// [`ALL`](Self::ALL) table and a
92/// [`can_transition`](Self::can_transition) rule for every pair, so code that
93/// handles statuses must be forced by the compiler to consider all of them.
94#[derive(
95    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
96)]
97#[serde(rename_all = "snake_case")]
98pub enum InteractionStatus {
99    /// Displayed and answerable.
100    Active,
101    /// An answer was accepted and its commands are executing.
102    Resolving,
103    /// The associated commands committed.
104    Resolved,
105    /// The user declined.
106    Declined,
107    /// The user dismissed it without answering.
108    Dismissed,
109    /// The case moved on; the card is no longer valid.
110    Invalidated,
111    /// The deadline passed.
112    Expired,
113    /// The associated commands failed.
114    Failed,
115}
116
117impl InteractionStatus {
118    /// Every status, in declaration order.
119    pub const ALL: [Self; 8] = [
120        Self::Active,
121        Self::Resolving,
122        Self::Resolved,
123        Self::Declined,
124        Self::Dismissed,
125        Self::Invalidated,
126        Self::Expired,
127        Self::Failed,
128    ];
129
130    /// Returns `true` for statuses with no outgoing transition.
131    #[must_use]
132    pub fn is_terminal(self) -> bool {
133        !matches!(self, Self::Active | Self::Resolving)
134    }
135
136    /// Returns `true` while the interaction still occupies the "one blocking
137    /// interaction per case" slot (I5).
138    #[must_use]
139    pub fn is_open(self) -> bool {
140        matches!(self, Self::Active | Self::Resolving)
141    }
142
143    /// The interaction state machine (spec §15.5).
144    ///
145    /// * `Active` may move to any other status.
146    /// * `Resolving` may move to `Resolved`, `Failed`, or back to `Active` when
147    ///   policy restores the card after a failed command.
148    /// * Every other status is terminal.
149    #[must_use]
150    pub fn can_transition(from: Self, to: Self) -> bool {
151        match from {
152            Self::Active => to != Self::Active,
153            Self::Resolving => matches!(to, Self::Resolved | Self::Failed | Self::Active),
154            _ => false,
155        }
156    }
157}
158
159/// Whether an option accepts free text (spec §15.5).
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
161#[serde(tag = "kind", rename_all = "snake_case")]
162pub enum FreeformPolicy {
163    /// No free text accepted.
164    #[default]
165    Forbidden,
166    /// Free text accepted up to `max_len` characters.
167    Optional {
168        /// Maximum length in characters.
169        max_len: usize,
170    },
171    /// Free text required, up to `max_len` characters.
172    Required {
173        /// Maximum length in characters.
174        max_len: usize,
175    },
176}
177
178impl FreeformPolicy {
179    /// Returns `true` when free text may be supplied.
180    #[must_use]
181    pub fn allows(self) -> bool {
182        !matches!(self, Self::Forbidden)
183    }
184
185    /// Returns `true` when free text must be supplied.
186    #[must_use]
187    pub fn requires(self) -> bool {
188        matches!(self, Self::Required { .. })
189    }
190
191    /// Maximum accepted length, when free text is allowed.
192    #[must_use]
193    pub fn max_len(self) -> Option<usize> {
194        match self {
195            Self::Forbidden => None,
196            Self::Optional { max_len } | Self::Required { max_len } => Some(max_len),
197        }
198    }
199}
200
201/// Whether and how typed text may resolve an interaction (spec §15.7).
202#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
203#[serde(tag = "kind", rename_all = "snake_case")]
204pub enum TextResolutionPolicy {
205    /// Only the structured CTA resolves it (default for high-risk confirmations).
206    #[default]
207    Never,
208    /// Understanding may read typed text as one of its options, for a low-risk card;
209    /// the option id is still checked against the stored options.
210    ModelInterpretedLowRisk,
211}
212
213impl TextResolutionPolicy {
214    /// Returns `true` when typed text may be read as an option.
215    #[must_use]
216    pub fn allows_model_interpretation(&self) -> bool {
217        matches!(self, Self::ModelInterpretedLowRisk)
218    }
219
220    /// Returns `true` when the policy admits the channel an answer arrived on.
221    ///
222    /// [`ResolutionChannel::Click`] is always admitted: the structured CTA
223    /// resolves every card.
224    #[must_use]
225    pub fn admits(&self, channel: ResolutionChannel) -> bool {
226        match channel {
227            ResolutionChannel::Click => true,
228            ResolutionChannel::ModelInterpreted => self.allows_model_interpretation(),
229        }
230    }
231}
232
233/// What a stored option authorizes when it is chosen (spec §15.3).
234///
235/// This is the half of [`StoredInteractionAction`] that policy cares about: an
236/// origin records the class, not the payload, so
237/// [`origin_satisfies`](crate::command::origin_satisfies) can tell a
238/// confirmation from a clarification without loading the card again.
239#[derive(
240    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
241)]
242#[serde(rename_all = "snake_case")]
243pub enum ActionClass {
244    /// Executes commands that were compiled and journaled with the card.
245    ConfirmsCommands,
246    /// Compiles and executes an operation named by the option.
247    AppliesOperation,
248    /// Answers the card without authorizing any command: a selection, a
249    /// clarification, a decline, a dismissal, an application-defined action.
250    NoCommands,
251}
252
253impl ActionClass {
254    /// Returns `true` when choosing the option makes commands run.
255    #[must_use]
256    pub fn authorizes_commands(self) -> bool {
257        matches!(self, Self::ConfirmsCommands | Self::AppliesOperation)
258    }
259}
260
261/// The server-side meaning of an option (spec §15.3). Never supplied by the client.
262///
263/// Applications add meanings through [`Self::Custom`] and the library adds
264/// variants, so downstream matches need a wildcard arm.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(tag = "kind", rename_all = "snake_case")]
267#[non_exhaustive]
268pub enum StoredInteractionAction {
269    /// Execute commands that were compiled and journaled when the card was made.
270    ConfirmCommands {
271        /// The commands.
272        command_refs: Vec<CommandRef>,
273    },
274    /// Drop the pending commands.
275    DeclineCommands,
276    /// Bind an ambiguous act to this case.
277    SelectTarget {
278        /// The chosen case.
279        case_ref: CaseRef,
280    },
281    /// Answer a clarification with a stable key.
282    ResolveClarification {
283        /// Application-defined answer key.
284        answer_key: String,
285    },
286    /// Compile and execute an operation when clicked (the option is the origin).
287    ApplyOperation {
288        /// Operation to compile through the workflow.
289        operation: OperationKey,
290        /// Arguments for the operation.
291        arguments: serde_json::Value,
292        /// Where the option's free text goes in those arguments, as a JSON
293        /// pointer (RFC 6901), for a card that asks for a VALUE rather than a
294        /// choice.
295        ///
296        /// `None` is every card that only offers options, which is all of them
297        /// until one asks a question whose answer is a word.
298        ///
299        /// # Why a card at all, when the user could just type
300        ///
301        /// Because a question in prose is not a state: the next message arrives as
302        /// free text, and what to do with it is a guess among every operation that
303        /// accepts a name. Asked on a card, the question is the state: the answer
304        /// arrives bound to the case and to the operation, and nothing is chosen.
305        ///
306        /// A JSON pointer into the operation's arguments.
307        #[serde(default, skip_serializing_if = "Option::is_none")]
308        freeform_argument: Option<String>,
309    },
310    /// Drop the pending commands, and record that the card was answered.
311    ///
312    /// # The trace a "no" could not leave
313    ///
314    /// A confirmation must carry an option that declines, and both declining
315    /// actions end the card without effect. So the only shape a "no" could take
316    /// wrote nothing — and a workflow whose next sentence depends on whether the
317    /// user has already answered could not tell that they had.
318    ///
319    /// A send confirmation offered "Send" and "Edit". The user pressed Edit;
320    /// nothing was written, so the projection was unchanged, so the phase's own
321    /// instruction to the writing stage was served again, and the reply was word
322    /// for word the sentence the user had just answered. The writer was obeying.
323    ///
324    /// The runtime already remembers the answer well enough not to raise the
325    /// card again, and that memory decides whether the **card** is rendered. It
326    /// does not reach what the workflow tells the stage that speaks, which is
327    /// where the repetition lives.
328    ///
329    /// # What it is not
330    ///
331    /// It does not run what it declined. The journaled commands are dropped
332    /// exactly as [`Self::DeclineCommands`] drops them, [`Self::declines`] is
333    /// true of it, and it satisfies the requirement that a confirmation offer a
334    /// way to say no. What it adds is one operation, named by the server that
335    /// wrote the card, compiled through `compile_act` and validated like any
336    /// other — so a workflow that records nothing is unaffected, and one that
337    /// records something cannot smuggle a confirmation through this door,
338    /// because no pending command executes on it.
339    ///
340    /// The operation is the domain's bookkeeping, so its `command_policy` had
341    /// better not ask for a confirmation of its own.
342    DeclineAndRecord {
343        /// Operation to compile through the workflow, on the case the card
344        /// belongs to.
345        operation: OperationKey,
346    },
347    /// Close the card without effect.
348    Dismiss,
349    /// Application-defined action.
350    Custom {
351        /// Application key.
352        key: String,
353        /// Application payload.
354        payload: serde_json::Value,
355    },
356}
357
358impl StoredInteractionAction {
359    /// What choosing this option authorizes.
360    ///
361    /// [`Self::Custom`] is deliberately [`ActionClass::NoCommands`]: an
362    /// application-defined action cannot be a confirmation, because the library
363    /// cannot know what it does.
364    #[must_use]
365    pub fn action_class(&self) -> ActionClass {
366        match self {
367            Self::ConfirmCommands { .. } => ActionClass::ConfirmsCommands,
368            Self::ApplyOperation { .. } => ActionClass::AppliesOperation,
369            // It compiles an operation, and it is still not a confirmation:
370            // what it authorizes is the record of a refusal, on the path where
371            // nothing pending runs.
372            Self::DeclineCommands
373            | Self::DeclineAndRecord { .. }
374            | Self::SelectTarget { .. }
375            | Self::ResolveClarification { .. }
376            | Self::Dismiss
377            | Self::Custom { .. } => ActionClass::NoCommands,
378        }
379    }
380
381    /// Returns `true` when choosing this option ends the card without effect.
382    #[must_use]
383    pub fn declines(&self) -> bool {
384        matches!(
385            self,
386            Self::DeclineCommands | Self::DeclineAndRecord { .. } | Self::Dismiss
387        )
388    }
389
390    /// The operation a decline records, when it records one.
391    #[must_use]
392    pub fn records(&self) -> Option<&OperationKey> {
393        match self {
394            Self::DeclineAndRecord { operation } => Some(operation),
395            _ => None,
396        }
397    }
398}
399
400/// Visual emphasis of an option, for clients.
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
402#[serde(rename_all = "snake_case")]
403pub enum OptionStyle {
404    /// The suggested action.
405    Primary,
406    /// A neutral action.
407    #[default]
408    Secondary,
409    /// A destructive or declining action.
410    Danger,
411}
412
413/// A stored option (spec §15.3).
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct InteractionOption {
416    /// Identifier echoed by the client.
417    pub id: OptionId,
418    /// Label copy.
419    pub label: LocalizedText,
420    /// Server-side meaning.
421    pub action: StoredInteractionAction,
422    /// Whether free text may accompany the click.
423    #[serde(default)]
424    pub freeform_policy: FreeformPolicy,
425    /// Visual emphasis.
426    #[serde(default)]
427    pub style: OptionStyle,
428}
429
430impl InteractionOption {
431    /// Builds an option that forbids free text with secondary style.
432    #[must_use]
433    pub fn new(
434        id: impl Into<OptionId>,
435        label: impl Into<LocalizedText>,
436        action: StoredInteractionAction,
437    ) -> Self {
438        Self {
439            id: id.into(),
440            label: label.into(),
441            action,
442            freeform_policy: FreeformPolicy::Forbidden,
443            style: OptionStyle::Secondary,
444        }
445    }
446
447    /// Sets the free-form policy.
448    #[must_use]
449    pub fn with_freeform(mut self, policy: FreeformPolicy) -> Self {
450        self.freeform_policy = policy;
451        self
452    }
453
454    /// Sets the style.
455    #[must_use]
456    pub fn with_style(mut self, style: OptionStyle) -> Self {
457        self.style = style;
458        self
459    }
460
461    /// The client-facing projection (no action).
462    #[must_use]
463    pub fn view(&self) -> InteractionOptionView {
464        InteractionOptionView {
465            id: self.id.clone(),
466            label: self.label.clone(),
467            freeform_policy: self.freeform_policy,
468            style: self.style,
469        }
470    }
471}
472
473/// One side of a field change: absent, or an explicit JSON value.
474///
475/// `Option<serde_json::Value>` cannot express this: with
476/// `skip_serializing_if = "Option::is_none"` an explicit `Some(Value::Null)` —
477/// "this field is being cleared" — is written as a missing key and reads back
478/// as `None`, so a review card that clears a field fails
479/// [`Interaction::verify_payload_hash`] after a round trip through any JSON
480/// store and its confirmation can never match. `FieldValue` keeps the two
481/// apart: [`Self::Absent`] omits the key, [`Self::Present`] writes the value,
482/// `null` included.
483#[derive(Debug, Clone, Default, PartialEq, Eq)]
484pub enum FieldValue {
485    /// The field is not part of this side of the change.
486    #[default]
487    Absent,
488    /// An explicit value. [`serde_json::Value::Null`] means "cleared".
489    Present(serde_json::Value),
490}
491
492impl FieldValue {
493    /// A present value.
494    #[must_use]
495    pub fn present(value: impl Into<serde_json::Value>) -> Self {
496        Self::Present(value.into())
497    }
498
499    /// An explicit JSON `null`: the field is being cleared.
500    #[must_use]
501    pub fn cleared() -> Self {
502        Self::Present(serde_json::Value::Null)
503    }
504
505    /// Returns `true` when the field is not part of this side.
506    #[must_use]
507    pub fn is_absent(&self) -> bool {
508        matches!(self, Self::Absent)
509    }
510
511    /// The value, when present.
512    #[must_use]
513    pub fn value(&self) -> Option<&serde_json::Value> {
514        match self {
515            Self::Absent => None,
516            Self::Present(value) => Some(value),
517        }
518    }
519}
520
521impl From<serde_json::Value> for FieldValue {
522    fn from(value: serde_json::Value) -> Self {
523        Self::Present(value)
524    }
525}
526
527impl Serialize for FieldValue {
528    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
529        // `Absent` is skipped by the container; serializing it standalone still
530        // has to produce something, and `null` is the closest reading.
531        match self {
532            Self::Absent => serializer.serialize_unit(),
533            Self::Present(value) => value.serialize(serializer),
534        }
535    }
536}
537
538impl<'de> Deserialize<'de> for FieldValue {
539    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
540        // Only called when the key is present, so an explicit `null` arrives
541        // here and stays `Present(Null)`; a missing key uses `Default`.
542        serde_json::Value::deserialize(deserializer).map(Self::Present)
543    }
544}
545
546/// One line of a review card: a field before and after the proposed change.
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct ReviewDiffEntry {
549    /// Stable field path.
550    pub field: String,
551    /// Label copy.
552    pub label: LocalizedText,
553    /// Current value.
554    #[serde(default, skip_serializing_if = "FieldValue::is_absent")]
555    pub before: FieldValue,
556    /// Proposed value.
557    #[serde(default, skip_serializing_if = "FieldValue::is_absent")]
558    pub after: FieldValue,
559}
560
561impl ReviewDiffEntry {
562    /// Builds an entry with both sides absent.
563    #[must_use]
564    pub fn new(field: impl Into<String>, label: impl Into<LocalizedText>) -> Self {
565        Self {
566            field: field.into(),
567            label: label.into(),
568            before: FieldValue::Absent,
569            after: FieldValue::Absent,
570        }
571    }
572
573    /// Sets the current value.
574    #[must_use]
575    pub fn with_before(mut self, before: FieldValue) -> Self {
576        self.before = before;
577        self
578    }
579
580    /// Sets the proposed value.
581    #[must_use]
582    pub fn with_after(mut self, after: FieldValue) -> Self {
583        self.after = after;
584        self
585    }
586}
587
588/// Immutable content of an interaction (spec §15.1).
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct InteractionPayload {
591    /// Title copy.
592    pub title: LocalizedText,
593    /// Body copy.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub body: Option<LocalizedText>,
596    /// Stored options.
597    #[serde(default)]
598    pub options: Vec<InteractionOption>,
599    /// Diff entries for review cards.
600    #[serde(default)]
601    pub review_entries: Vec<ReviewDiffEntry>,
602    /// Prompt for free-form interactions.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub freeform_prompt: Option<LocalizedText>,
605    /// Application metadata (rendering hints, preview hash...).
606    #[serde(default)]
607    pub metadata: serde_json::Value,
608}
609
610impl InteractionPayload {
611    /// Builds a payload with only a title.
612    #[must_use]
613    pub fn new(title: impl Into<LocalizedText>) -> Self {
614        Self {
615            title: title.into(),
616            body: None,
617            options: Vec::new(),
618            review_entries: Vec::new(),
619            freeform_prompt: None,
620            metadata: serde_json::Value::Null,
621        }
622    }
623
624    /// Sets the body.
625    #[must_use]
626    pub fn with_body(mut self, body: impl Into<LocalizedText>) -> Self {
627        self.body = Some(body.into());
628        self
629    }
630
631    /// Appends an option.
632    #[must_use]
633    pub fn with_option(mut self, option: InteractionOption) -> Self {
634        self.options.push(option);
635        self
636    }
637
638    /// Appends a review entry.
639    #[must_use]
640    pub fn with_review_entry(mut self, entry: ReviewDiffEntry) -> Self {
641        self.review_entries.push(entry);
642        self
643    }
644
645    /// Sets metadata.
646    #[must_use]
647    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
648        self.metadata = metadata;
649        self
650    }
651
652    /// BLAKE3 over the canonical JSON of the payload. Stored on the interaction
653    /// and embedded in [`CommandOrigin::ConfirmedInteraction`] so a command can
654    /// prove which content the user confirmed.
655    pub fn hash(&self) -> Result<Digest, HashError> {
656        canonical_digest(self)
657    }
658
659    /// Finds a stored option.
660    #[must_use]
661    pub fn option(&self, id: &OptionId) -> Option<&InteractionOption> {
662        self.options.iter().find(|o| &o.id == id)
663    }
664
665    /// Identifiers of all options.
666    #[must_use]
667    pub fn option_ids(&self) -> Vec<OptionId> {
668        self.options.iter().map(|o| o.id.clone()).collect()
669    }
670
671    /// Sets the free-form prompt.
672    #[must_use]
673    pub fn with_freeform_prompt(mut self, prompt: impl Into<LocalizedText>) -> Self {
674        self.freeform_prompt = Some(prompt.into());
675        self
676    }
677
678    /// Checks that a card of this `kind` can actually be answered (I6).
679    ///
680    /// A user-owned phase whose card carries no usable option is a dead end:
681    /// the case blocks on an answer nobody can give. The rules are:
682    ///
683    /// * option ids are unique, whatever the kind;
684    /// * `Boolean` has exactly two options;
685    /// * `SingleSelect`, `SelectTarget` and `ResolveValidationError` have at
686    ///   least one option;
687    /// * `ConfirmCommand` and `ReviewChanges` have at least one option that
688    ///   authorizes commands **and** one that declines, so refusing is always
689    ///   possible; `ReviewChanges` also has at least one diff entry;
690    /// * `Reauthenticate` and `ExternalSignature` have at least one option that
691    ///   authorizes commands, since their whole purpose is to carry that
692    ///   authority;
693    /// * `Freeform` has a prompt and an option that requires free text;
694    /// * `MultiSelect` is refused outright: the input protocol carries one
695    ///   option id ([`InteractionResponse::option_id`]), so a persisted
696    ///   multi-select card could only ever be answered as a single select.
697    pub fn validate_for(&self, kind: InteractionKind) -> Result<(), InteractionSpecError> {
698        let mut seen = std::collections::BTreeSet::new();
699        for option in &self.options {
700            if !seen.insert(&option.id) {
701                return Err(InteractionSpecError::DuplicateOptionId {
702                    option_id: option.id.clone(),
703                });
704            }
705        }
706        let authorizing = self
707            .options
708            .iter()
709            .filter(|o| o.action.action_class().authorizes_commands())
710            .count();
711        let declining = self.options.iter().filter(|o| o.action.declines()).count();
712        let required_freeform = self
713            .options
714            .iter()
715            .filter(|o| o.freeform_policy.requires())
716            .count();
717        let require_options = |expected: usize| {
718            if self.options.len() < expected {
719                Err(InteractionSpecError::NotEnoughOptions {
720                    interaction_kind: kind,
721                    required: expected,
722                    found: self.options.len(),
723                })
724            } else {
725                Ok(())
726            }
727        };
728        match kind {
729            InteractionKind::MultiSelect => {
730                return Err(InteractionSpecError::UnsupportedKind {
731                    interaction_kind: kind,
732                });
733            }
734            InteractionKind::Boolean => {
735                if self.options.len() != 2 {
736                    return Err(InteractionSpecError::NotEnoughOptions {
737                        interaction_kind: kind,
738                        required: 2,
739                        found: self.options.len(),
740                    });
741                }
742            }
743            InteractionKind::SingleSelect
744            | InteractionKind::SelectTarget
745            | InteractionKind::ResolveValidationError => require_options(1)?,
746            InteractionKind::ConfirmCommand | InteractionKind::ReviewChanges => {
747                require_options(2)?;
748                if authorizing == 0 {
749                    return Err(InteractionSpecError::MissingAuthorizingOption {
750                        interaction_kind: kind,
751                    });
752                }
753                if declining == 0 {
754                    return Err(InteractionSpecError::MissingDeclineOption {
755                        interaction_kind: kind,
756                    });
757                }
758                if kind == InteractionKind::ReviewChanges && self.review_entries.is_empty() {
759                    return Err(InteractionSpecError::MissingReviewEntries);
760                }
761            }
762            InteractionKind::Reauthenticate | InteractionKind::ExternalSignature => {
763                require_options(1)?;
764                if authorizing == 0 {
765                    return Err(InteractionSpecError::MissingAuthorizingOption {
766                        interaction_kind: kind,
767                    });
768                }
769            }
770            InteractionKind::Freeform => {
771                require_options(1)?;
772                if self.freeform_prompt.is_none() {
773                    return Err(InteractionSpecError::MissingFreeformPrompt);
774                }
775                if required_freeform == 0 {
776                    return Err(InteractionSpecError::MissingFreeformOption);
777                }
778            }
779        }
780        Ok(())
781    }
782}
783
784/// What a reducer or workflow asks the engine to create (spec §13.3, I6).
785#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
786pub struct InteractionSpec {
787    /// Stable key within a turn/plan (e.g. `"select_target:acts[1]"`). Two
788    /// specs with the same key describe the same interaction.
789    pub key: String,
790    /// Case the interaction belongs to; its revision is the bound revision.
791    pub case_ref: CaseRef,
792    /// Shape.
793    pub kind: InteractionKind,
794    /// Whether it owns unqualified answers for the case (I5).
795    pub blocking: bool,
796    /// Content.
797    pub payload: InteractionPayload,
798    /// Time to live, if any.
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub expires_in: Option<Duration>,
801    /// Whether typed text may resolve it.
802    #[serde(default)]
803    pub text_resolution: TextResolutionPolicy,
804    /// Highest risk class of the commands an answer to this card authorizes.
805    ///
806    /// The default is [`RiskClass::Irreversible`], so a card that forgets to
807    /// declare it is treated as consequential and can never be resolved from
808    /// typed text (spec §13.2 rule 8, §15.7).
809    #[serde(default = "RiskClass::conservative")]
810    pub confirms_risk: RiskClass,
811    /// `true` when the interaction is invalidated by a case revision change.
812    pub binds_to_revision: bool,
813}
814
815impl InteractionSpec {
816    /// Builds a blocking, revision-bound spec with `Never` text resolution and
817    /// the conservative [`RiskClass::Irreversible`] confirmation risk.
818    #[must_use]
819    pub fn new(
820        key: impl Into<String>,
821        case_ref: CaseRef,
822        kind: InteractionKind,
823        payload: InteractionPayload,
824    ) -> Self {
825        Self {
826            key: key.into(),
827            case_ref,
828            kind,
829            blocking: true,
830            payload,
831            expires_in: None,
832            text_resolution: TextResolutionPolicy::Never,
833            confirms_risk: RiskClass::conservative(),
834            binds_to_revision: true,
835        }
836    }
837
838    /// Declares the highest risk class an answer authorizes.
839    #[must_use]
840    pub fn with_confirms_risk(mut self, risk: RiskClass) -> Self {
841        self.confirms_risk = risk;
842        self
843    }
844
845    /// Checks that the card can be answered and that its text-resolution
846    /// policy is allowed for what it confirms.
847    ///
848    /// Beyond [`InteractionPayload::validate_for`], any policy other than
849    /// [`TextResolutionPolicy::Never`] is refused when the card authorizes
850    /// commands above [`RiskClass::ReversibleLowRisk`] or is one of the
851    /// authorizing kinds ([`InteractionKind::authorizes_commands`]): a
852    /// confirmation inferred from prose is not a confirmation.
853    pub fn validate(&self) -> Result<(), InteractionSpecError> {
854        self.payload.validate_for(self.kind)?;
855        if self.text_resolution != TextResolutionPolicy::Never
856            && (self.confirms_risk > RiskClass::ReversibleLowRisk
857                || self.kind.authorizes_commands())
858        {
859            return Err(InteractionSpecError::TextResolutionNotAllowed {
860                interaction_kind: self.kind,
861                confirms_risk: self.confirms_risk,
862            });
863        }
864        Ok(())
865    }
866
867    /// Marks the spec non-blocking.
868    #[must_use]
869    pub fn non_blocking(mut self) -> Self {
870        self.blocking = false;
871        self
872    }
873
874    /// Sets the text resolution policy.
875    #[must_use]
876    pub fn with_text_resolution(mut self, policy: TextResolutionPolicy) -> Self {
877        self.text_resolution = policy;
878        self
879    }
880
881    /// Sets the time to live.
882    #[must_use]
883    pub fn expires_in(mut self, ttl: Duration) -> Self {
884        self.expires_in = Some(ttl);
885        self
886    }
887
888    /// Makes the interaction survive revision changes.
889    #[must_use]
890    pub fn revision_independent(mut self) -> Self {
891        self.binds_to_revision = false;
892        self
893    }
894}
895
896/// A persisted interaction (spec §15.1).
897#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898pub struct Interaction {
899    /// Identifier.
900    pub id: InteractionId,
901    /// Owning tenant.
902    pub account_id: AccountId,
903    /// Owning conversation.
904    pub conversation_id: ConversationId,
905    /// Case and the revision the card was rendered against (the bound revision).
906    pub case_ref: CaseRef,
907    /// Turn that created it.
908    pub created_by_turn: TurnId,
909    /// Shape.
910    pub kind: InteractionKind,
911    /// Whether it owns unqualified answers for the case.
912    pub blocking: bool,
913    /// Immutable content.
914    pub payload: InteractionPayload,
915    /// Hash of `payload` at creation.
916    pub payload_hash: Digest,
917    /// Lifecycle status.
918    pub status: InteractionStatus,
919    /// `true` when a revision change does not invalidate it.
920    pub revision_independent: bool,
921    /// Whether typed text may resolve it.
922    pub text_resolution: TextResolutionPolicy,
923    /// Highest risk class of the commands an answer authorizes. Conservative
924    /// ([`RiskClass::Irreversible`]) when a stored record predates the field.
925    #[serde(default = "RiskClass::conservative")]
926    pub confirms_risk: RiskClass,
927    /// Creation time.
928    pub created_at: DateTime<Utc>,
929    /// Expiry, if any.
930    #[serde(default, skip_serializing_if = "Option::is_none")]
931    pub expires_at: Option<DateTime<Utc>>,
932    /// Resolution time, if resolved.
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub resolved_at: Option<DateTime<Utc>>,
935    /// Option chosen, if resolved.
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub resolved_option_id: Option<OptionId>,
938}
939
940impl Interaction {
941    /// Materializes a spec into a new `Active` interaction, computing the
942    /// payload hash and the expiry.
943    ///
944    /// Fails closed: a card that cannot be answered
945    /// ([`InteractionSpec::validate`]) or whose time to live cannot be applied
946    /// is never persisted. Silently dropping an out-of-range TTL would produce
947    /// a card that never expires, which is a fail-open on a safety timeout.
948    pub fn from_spec(
949        spec: InteractionSpec,
950        id: InteractionId,
951        account_id: AccountId,
952        conversation_id: ConversationId,
953        created_by_turn: TurnId,
954        now: DateTime<Utc>,
955    ) -> Result<Self, InteractionError> {
956        spec.validate()?;
957        let payload_hash = spec.payload.hash().map_err(|_| InteractionError::Hash)?;
958        let expires_at = spec
959            .expires_in
960            .map(|ttl| {
961                chrono::Duration::from_std(ttl)
962                    .ok()
963                    .and_then(|ttl| now.checked_add_signed(ttl))
964                    .ok_or(InteractionError::InvalidTtl)
965            })
966            .transpose()?;
967        Ok(Self {
968            id,
969            account_id,
970            conversation_id,
971            case_ref: spec.case_ref,
972            created_by_turn,
973            kind: spec.kind,
974            blocking: spec.blocking,
975            payload: spec.payload,
976            payload_hash,
977            status: InteractionStatus::Active,
978            revision_independent: !spec.binds_to_revision,
979            text_resolution: spec.text_resolution,
980            confirms_risk: spec.confirms_risk,
981            created_at: now,
982            expires_at,
983            resolved_at: None,
984            resolved_option_id: None,
985        })
986    }
987
988    /// The revision the card is bound to, or `None` when revision independent.
989    #[must_use]
990    pub fn bound_revision(&self) -> Option<CaseRevision> {
991        (!self.revision_independent).then_some(self.case_ref.expected_revision)
992    }
993
994    /// Returns `true` when `now` is past the expiry.
995    #[must_use]
996    pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
997        self.expires_at.is_some_and(|at| at <= now)
998    }
999
1000    /// Recomputes the payload hash and compares it with the stored one.
1001    pub fn verify_payload_hash(&self) -> Result<bool, HashError> {
1002        Ok(self.payload.hash()? == self.payload_hash)
1003    }
1004
1005    /// Applies a status transition, refusing illegal ones.
1006    pub fn transition(&mut self, to: InteractionStatus) -> Result<(), InteractionError> {
1007        if !InteractionStatus::can_transition(self.status, to) {
1008            return Err(InteractionError::InvalidTransition {
1009                interaction_id: self.id,
1010                from: self.status,
1011                to,
1012            });
1013        }
1014        self.status = to;
1015        Ok(())
1016    }
1017
1018    /// Records the chosen option and moves to `Resolving`.
1019    pub fn begin_resolution(
1020        &mut self,
1021        option_id: OptionId,
1022        now: DateTime<Utc>,
1023    ) -> Result<(), InteractionError> {
1024        self.transition(InteractionStatus::Resolving)?;
1025        self.resolved_option_id = Some(option_id);
1026        self.resolved_at = Some(now);
1027        Ok(())
1028    }
1029
1030    /// The client-facing projection without server actions.
1031    #[must_use]
1032    pub fn view(&self) -> InteractionView {
1033        InteractionView {
1034            id: self.id,
1035            kind: self.kind,
1036            status: self.status,
1037            blocking: self.blocking,
1038            case_ref: self.case_ref.clone(),
1039            title: self.payload.title.clone(),
1040            body: self.payload.body.clone(),
1041            options: self
1042                .payload
1043                .options
1044                .iter()
1045                .map(InteractionOption::view)
1046                .collect(),
1047            review_entries: self.payload.review_entries.clone(),
1048            freeform_prompt: self.payload.freeform_prompt.clone(),
1049            metadata: self.payload.metadata.clone(),
1050            expires_at: self.expires_at,
1051        }
1052    }
1053}
1054
1055/// Client-facing option: no server action.
1056#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1057pub struct InteractionOptionView {
1058    /// Identifier to echo back.
1059    pub id: OptionId,
1060    /// Label copy.
1061    pub label: LocalizedText,
1062    /// Whether free text may accompany the click.
1063    pub freeform_policy: FreeformPolicy,
1064    /// Visual emphasis.
1065    pub style: OptionStyle,
1066}
1067
1068/// Client-facing projection of an interaction (spec §18.1).
1069#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1070pub struct InteractionView {
1071    /// Identifier.
1072    pub id: InteractionId,
1073    /// Shape.
1074    pub kind: InteractionKind,
1075    /// Status.
1076    pub status: InteractionStatus,
1077    /// Whether it blocks the case.
1078    pub blocking: bool,
1079    /// Case and bound revision the client must echo back.
1080    pub case_ref: CaseRef,
1081    /// Title copy.
1082    pub title: LocalizedText,
1083    /// Body copy.
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub body: Option<LocalizedText>,
1086    /// Options without actions.
1087    pub options: Vec<InteractionOptionView>,
1088    /// Diff entries.
1089    #[serde(default)]
1090    pub review_entries: Vec<ReviewDiffEntry>,
1091    /// Free-form prompt.
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub freeform_prompt: Option<LocalizedText>,
1094    /// Application metadata.
1095    #[serde(default)]
1096    pub metadata: serde_json::Value,
1097    /// Expiry.
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub expires_at: Option<DateTime<Utc>>,
1100}
1101
1102/// Why a response was not accepted (spec §15.5).
1103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1104#[serde(tag = "kind", rename_all = "snake_case")]
1105#[non_exhaustive]
1106pub enum InteractionRejection {
1107    /// Wrong id, account or conversation. Deliberately indistinguishable so a
1108    /// guessed id reveals nothing about other tenants (spec §25.4).
1109    #[error("interaction not found")]
1110    NotFound,
1111    /// The interaction is not `Active`.
1112    #[error("interaction is not active ({status:?})")]
1113    NotActive {
1114        /// Current status.
1115        status: InteractionStatus,
1116    },
1117    /// The interaction was already resolved; the original option is returned so
1118    /// the caller can replay the original result (I14).
1119    #[error("interaction already resolved")]
1120    AlreadyResolved {
1121        /// The option chosen originally.
1122        option_id: Option<OptionId>,
1123    },
1124    /// The interaction expired.
1125    #[error("interaction expired")]
1126    Expired,
1127    /// The case moved past the bound revision, or the client echoed another one.
1128    #[error("interaction stale: bound {bound_revision}, current {current_revision}")]
1129    Stale {
1130        /// Revision the card was rendered against.
1131        bound_revision: CaseRevision,
1132        /// Revision now.
1133        current_revision: CaseRevision,
1134    },
1135    /// The option id is not stored on the interaction.
1136    #[error("unknown option")]
1137    UnknownOption,
1138    /// Free text was supplied but the option forbids it.
1139    #[error("freeform input not allowed")]
1140    FreeformNotAllowed,
1141    /// The option requires free text and none was supplied.
1142    #[error("freeform input required")]
1143    FreeformRequired,
1144    /// Free text exceeds the option's limit.
1145    #[error("freeform input exceeds {max_len} characters")]
1146    FreeformTooLong {
1147        /// The limit.
1148        max_len: usize,
1149    },
1150    /// The stored payload no longer hashes to the stored `payload_hash`, so the
1151    /// option's meaning is not the one the user saw.
1152    #[error("interaction payload does not match its stored hash")]
1153    PayloadCorrupt,
1154    /// The answer arrived on a channel the card's
1155    /// [`TextResolutionPolicy`] does not admit (e.g. an interpreted "yes" on a
1156    /// card that only the CTA resolves).
1157    #[error("interaction cannot be resolved through the {channel:?} channel")]
1158    ChannelNotAllowed {
1159        /// The channel the answer arrived on.
1160        channel: ResolutionChannel,
1161    },
1162}
1163
1164/// A response that passed every §15.5 rule.
1165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1166pub struct AcceptedResponse {
1167    /// The interaction.
1168    pub interaction_id: InteractionId,
1169    /// Case and bound revision.
1170    pub case_ref: CaseRef,
1171    /// Shape of the card that was answered.
1172    pub kind: InteractionKind,
1173    /// The chosen option.
1174    pub option_id: OptionId,
1175    /// Server-side meaning of the option.
1176    pub action: StoredInteractionAction,
1177    /// How the answer reached the server.
1178    pub channel: ResolutionChannel,
1179    /// Free text, when permitted and supplied.
1180    pub freeform_input: Option<String>,
1181    /// Hash of the payload the user saw.
1182    pub payload_hash: Digest,
1183}
1184
1185impl AcceptedResponse {
1186    /// The command origin this response authorizes (I12), or `None` when it
1187    /// authorizes nothing.
1188    ///
1189    /// Only an option that actually confirms or applies commands mints an
1190    /// origin. Picking a case on a `SelectTarget` card, answering a
1191    /// clarification or dismissing a card resolves the interaction without
1192    /// authorizing anything: the runtime must re-run policy for the original
1193    /// act and raise a fresh confirmation when one is required, instead of
1194    /// treating the click as consent.
1195    #[must_use]
1196    pub fn origin(&self) -> Option<CommandOrigin> {
1197        let action_class = self.action.action_class();
1198        action_class
1199            .authorizes_commands()
1200            .then(|| CommandOrigin::ConfirmedInteraction {
1201                interaction_id: self.interaction_id,
1202                payload_hash: self.payload_hash.clone(),
1203                interaction_kind: self.kind,
1204                action_class,
1205                channel: self.channel,
1206            })
1207    }
1208}
1209
1210/// Pure validation of a client response against the stored interaction
1211/// (spec §15.5).
1212///
1213/// Checks, in order: identity (id, account, conversation → `NotFound`),
1214/// status (`AlreadyResolved` / `NotActive`), expiry, revision binding
1215/// (`Stale`), payload integrity (`PayloadCorrupt`), the resolution channel
1216/// against the stored [`TextResolutionPolicy`] (`ChannelNotAllowed`), option
1217/// existence (`UnknownOption`), free-form rules.
1218///
1219/// `channel` says how the answer arrived: [`ResolutionChannel::Click`] for a
1220/// structured client response, the text channels when the runtime resolved
1221/// typed text to an option. It is carried into the accepted response and from
1222/// there into the [`CommandOrigin`], because an inferred "yes" and a click are
1223/// not the same authority (I12, I20).
1224pub fn validate_response(
1225    interaction: &Interaction,
1226    response: &InteractionResponse,
1227    channel: ResolutionChannel,
1228    actor: &ActorContext,
1229    conversation_id: &ConversationId,
1230    current_revision: CaseRevision,
1231    now: DateTime<Utc>,
1232) -> Result<AcceptedResponse, InteractionRejection> {
1233    if response.interaction_id != interaction.id
1234        || actor.account_id != interaction.account_id
1235        || *conversation_id != interaction.conversation_id
1236    {
1237        return Err(InteractionRejection::NotFound);
1238    }
1239    match interaction.status {
1240        InteractionStatus::Active => {}
1241        InteractionStatus::Resolved => {
1242            return Err(InteractionRejection::AlreadyResolved {
1243                option_id: interaction.resolved_option_id.clone(),
1244            });
1245        }
1246        status => return Err(InteractionRejection::NotActive { status }),
1247    }
1248    if interaction.is_expired(now) {
1249        return Err(InteractionRejection::Expired);
1250    }
1251    if let Some(bound) = interaction.bound_revision()
1252        && (response.expected_case_revision != bound || current_revision != bound)
1253    {
1254        return Err(InteractionRejection::Stale {
1255            bound_revision: bound,
1256            current_revision,
1257        });
1258    }
1259    // The stored payload is what the user saw and what the origin's hash will
1260    // claim; if the two disagree, the option's meaning is not the one that was
1261    // shown, so nothing may execute (I19, §15.5).
1262    if !matches!(interaction.verify_payload_hash(), Ok(true)) {
1263        return Err(InteractionRejection::PayloadCorrupt);
1264    }
1265    if !interaction.text_resolution.admits(channel) {
1266        return Err(InteractionRejection::ChannelNotAllowed { channel });
1267    }
1268    let option = interaction
1269        .payload
1270        .option(&response.option_id)
1271        .ok_or(InteractionRejection::UnknownOption)?;
1272    let freeform_input = match (&response.freeform_input, option.freeform_policy) {
1273        (Some(_), FreeformPolicy::Forbidden) => {
1274            return Err(InteractionRejection::FreeformNotAllowed);
1275        }
1276        (None, FreeformPolicy::Required { .. }) => {
1277            return Err(InteractionRejection::FreeformRequired);
1278        }
1279        (
1280            Some(text),
1281            FreeformPolicy::Optional { max_len } | FreeformPolicy::Required { max_len },
1282        ) => {
1283            if text.chars().count() > max_len {
1284                return Err(InteractionRejection::FreeformTooLong { max_len });
1285            }
1286            // Blank text is no text: required free text must carry content.
1287            if text.trim().is_empty() {
1288                if option.freeform_policy.requires() {
1289                    return Err(InteractionRejection::FreeformRequired);
1290                }
1291                None
1292            } else {
1293                Some(text.clone())
1294            }
1295        }
1296        (None, _) => None,
1297    };
1298    Ok(AcceptedResponse {
1299        interaction_id: interaction.id,
1300        case_ref: interaction.case_ref.clone(),
1301        kind: interaction.kind,
1302        option_id: option.id.clone(),
1303        action: option.action.clone(),
1304        channel,
1305        freeform_input,
1306        payload_hash: interaction.payload_hash.clone(),
1307    })
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313    use crate::command::ConfirmationPolicy;
1314
1315    fn now() -> DateTime<Utc> {
1316        DateTime::from_timestamp(1_700_000_000, 0).unwrap()
1317    }
1318
1319    fn confirm_option() -> InteractionOption {
1320        InteractionOption::new(
1321            "confirm",
1322            "Confirm",
1323            StoredInteractionAction::ApplyOperation {
1324                operation: OperationKey::from("trip.rebook"),
1325                arguments: serde_json::Value::Null,
1326                freeform_argument: None,
1327            },
1328        )
1329    }
1330
1331    fn decline_option() -> InteractionOption {
1332        InteractionOption::new("no", "Cancel", StoredInteractionAction::DeclineCommands)
1333    }
1334
1335    fn payload() -> InteractionPayload {
1336        InteractionPayload::new("Rebook this flight?")
1337            .with_option(confirm_option())
1338            .with_option(decline_option())
1339            .with_option(
1340                InteractionOption::new("note", "Add a note", StoredInteractionAction::Dismiss)
1341                    .with_freeform(FreeformPolicy::Optional { max_len: 5 }),
1342            )
1343            .with_option(
1344                InteractionOption::new("why", "Explain", StoredInteractionAction::Dismiss)
1345                    .with_freeform(FreeformPolicy::Required { max_len: 100 }),
1346            )
1347    }
1348
1349    fn spec() -> InteractionSpec {
1350        InteractionSpec::new(
1351            "send",
1352            CaseRef::new("trip", "i1", CaseRevision(12)),
1353            InteractionKind::ConfirmCommand,
1354            payload(),
1355        )
1356    }
1357
1358    fn interaction() -> Interaction {
1359        Interaction::from_spec(
1360            spec(),
1361            InteractionId::nil(),
1362            AccountId::from("acct"),
1363            ConversationId::nil(),
1364            TurnId::nil(),
1365            now(),
1366        )
1367        .unwrap()
1368    }
1369
1370    fn response(option: &str) -> InteractionResponse {
1371        InteractionResponse {
1372            interaction_id: InteractionId::nil(),
1373            option_id: OptionId::from(option),
1374            expected_case_revision: CaseRevision(12),
1375            freeform_input: None,
1376        }
1377    }
1378
1379    fn actor() -> ActorContext {
1380        ActorContext::new("acct", "u1")
1381    }
1382
1383    fn validate(
1384        i: &Interaction,
1385        r: &InteractionResponse,
1386    ) -> Result<AcceptedResponse, InteractionRejection> {
1387        validate_response(
1388            i,
1389            r,
1390            ResolutionChannel::Click,
1391            &actor(),
1392            &ConversationId::nil(),
1393            CaseRevision(12),
1394            now(),
1395        )
1396    }
1397
1398    #[test]
1399    fn happy_path_yields_confirmed_origin() {
1400        let i = interaction();
1401        let accepted = validate(&i, &response("confirm")).unwrap();
1402        assert_eq!(accepted.option_id, OptionId::from("confirm"));
1403        assert_eq!(
1404            accepted.origin(),
1405            Some(CommandOrigin::ConfirmedInteraction {
1406                interaction_id: i.id,
1407                payload_hash: i.payload_hash.clone(),
1408                interaction_kind: InteractionKind::ConfirmCommand,
1409                action_class: ActionClass::AppliesOperation,
1410                channel: ResolutionChannel::Click,
1411            })
1412        );
1413        assert!(crate::command::origin_satisfies(
1414            &accepted.origin().unwrap(),
1415            &crate::command::CommandPolicy::conservative()
1416        ));
1417    }
1418
1419    #[test]
1420    fn an_option_that_authorizes_nothing_mints_no_origin() {
1421        let i = interaction();
1422        // Declining and dismissing resolve the card without authorizing a
1423        // command; the runtime must not treat either as consent.
1424        for option in ["no", "note"] {
1425            let accepted = validate(&i, &response(option)).unwrap();
1426            assert_eq!(accepted.origin(), None, "{option}");
1427        }
1428        let selection = Interaction::from_spec(
1429            InteractionSpec::new(
1430                "which",
1431                CaseRef::new("trip", "i1", CaseRevision(12)),
1432                InteractionKind::SelectTarget,
1433                InteractionPayload::new("Which trip?").with_option(InteractionOption::new(
1434                    "a",
1435                    "Trip A",
1436                    StoredInteractionAction::SelectTarget {
1437                        case_ref: CaseRef::new("trip", "a", CaseRevision(1)),
1438                    },
1439                )),
1440            ),
1441            InteractionId::nil(),
1442            AccountId::from("acct"),
1443            ConversationId::nil(),
1444            TurnId::nil(),
1445            now(),
1446        )
1447        .unwrap();
1448        let accepted = validate(&selection, &response("a")).unwrap();
1449        assert_eq!(accepted.origin(), None);
1450    }
1451
1452    #[test]
1453    fn wrong_account_and_conversation_are_indistinguishable() {
1454        let i = interaction();
1455        let other_actor = ActorContext::new("other", "u1");
1456        let err_account = validate_response(
1457            &i,
1458            &response("confirm"),
1459            ResolutionChannel::Click,
1460            &other_actor,
1461            &ConversationId::nil(),
1462            CaseRevision(12),
1463            now(),
1464        )
1465        .unwrap_err();
1466        let err_conv = validate_response(
1467            &i,
1468            &response("confirm"),
1469            ResolutionChannel::Click,
1470            &actor(),
1471            &ConversationId::new(),
1472            CaseRevision(12),
1473            now(),
1474        )
1475        .unwrap_err();
1476        let mut r = response("confirm");
1477        r.interaction_id = InteractionId::new();
1478        let err_id = validate(&i, &r).unwrap_err();
1479        assert_eq!(err_account, InteractionRejection::NotFound);
1480        assert_eq!(err_conv, InteractionRejection::NotFound);
1481        assert_eq!(err_id, InteractionRejection::NotFound);
1482    }
1483
1484    #[test]
1485    fn identity_is_checked_before_status_and_expiry() {
1486        // A foreign tenant must not learn that the card exists, whatever state
1487        // it is in.
1488        let mut resolved = interaction();
1489        resolved
1490            .begin_resolution(OptionId::from("confirm"), now())
1491            .unwrap();
1492        resolved.transition(InteractionStatus::Resolved).unwrap();
1493        let mut expired = interaction();
1494        expired.expires_at = Some(now() - chrono::Duration::seconds(1));
1495        for card in [&resolved, &expired] {
1496            let err = validate_response(
1497                card,
1498                &response("confirm"),
1499                ResolutionChannel::Click,
1500                &ActorContext::new("other", "u1"),
1501                &ConversationId::nil(),
1502                CaseRevision(12),
1503                now(),
1504            )
1505            .unwrap_err();
1506            assert_eq!(err, InteractionRejection::NotFound);
1507        }
1508    }
1509
1510    #[test]
1511    fn unknown_option_rejected() {
1512        assert_eq!(
1513            validate(&interaction(), &response("nope")).unwrap_err(),
1514            InteractionRejection::UnknownOption
1515        );
1516    }
1517
1518    #[test]
1519    fn freeform_rules() {
1520        let i = interaction();
1521        let mut r = response("confirm");
1522        r.freeform_input = Some("x".into());
1523        assert_eq!(
1524            validate(&i, &r).unwrap_err(),
1525            InteractionRejection::FreeformNotAllowed
1526        );
1527        let mut r = response("note");
1528        r.freeform_input = Some("toolong".into());
1529        assert_eq!(
1530            validate(&i, &r).unwrap_err(),
1531            InteractionRejection::FreeformTooLong { max_len: 5 }
1532        );
1533        r.freeform_input = Some("exact".into());
1534        assert_eq!(
1535            validate(&i, &r).unwrap().freeform_input.as_deref(),
1536            Some("exact"),
1537            "a string of exactly max_len characters is accepted"
1538        );
1539        r.freeform_input = Some("ok".into());
1540        assert_eq!(
1541            validate(&i, &r).unwrap().freeform_input.as_deref(),
1542            Some("ok")
1543        );
1544        assert!(validate(&i, &response("note")).is_ok());
1545        assert_eq!(
1546            validate(&i, &response("why")).unwrap_err(),
1547            InteractionRejection::FreeformRequired
1548        );
1549    }
1550
1551    #[test]
1552    fn blank_text_does_not_satisfy_a_required_freeform() {
1553        let i = interaction();
1554        for blank in ["", "   ", "\n\t "] {
1555            let mut r = response("why");
1556            r.freeform_input = Some(blank.into());
1557            assert_eq!(
1558                validate(&i, &r).unwrap_err(),
1559                InteractionRejection::FreeformRequired,
1560                "{blank:?}"
1561            );
1562        }
1563        // Blank optional text is simply no text.
1564        let mut r = response("note");
1565        r.freeform_input = Some("  ".into());
1566        assert_eq!(validate(&i, &r).unwrap().freeform_input, None);
1567    }
1568
1569    #[test]
1570    fn a_tampered_payload_is_refused() {
1571        let mut i = interaction();
1572        i.payload.options[0].action = StoredInteractionAction::ApplyOperation {
1573            operation: OperationKey::from("trip.withdraw"),
1574            arguments: serde_json::Value::Null,
1575            freeform_argument: None,
1576        };
1577        assert!(!i.verify_payload_hash().unwrap());
1578        assert_eq!(
1579            validate(&i, &response("confirm")).unwrap_err(),
1580            InteractionRejection::PayloadCorrupt
1581        );
1582    }
1583
1584    #[test]
1585    fn a_card_only_answers_on_the_channels_it_admits() {
1586        let i = interaction();
1587        let channel = ResolutionChannel::ModelInterpreted;
1588        let err = validate_response(
1589            &i,
1590            &response("confirm"),
1591            channel,
1592            &actor(),
1593            &ConversationId::nil(),
1594            CaseRevision(12),
1595            now(),
1596        )
1597        .unwrap_err();
1598        assert_eq!(err, InteractionRejection::ChannelNotAllowed { channel });
1599        let mut aliased = Interaction::from_spec(
1600            InteractionSpec::new(
1601                "pick",
1602                CaseRef::new("trip", "i1", CaseRevision(12)),
1603                InteractionKind::SingleSelect,
1604                InteractionPayload::new("Which?").with_option(InteractionOption::new(
1605                    "a",
1606                    "A",
1607                    StoredInteractionAction::ResolveClarification {
1608                        answer_key: "a".into(),
1609                    },
1610                )),
1611            )
1612            .with_confirms_risk(RiskClass::ReversibleLowRisk)
1613            .with_text_resolution(TextResolutionPolicy::Never),
1614            InteractionId::nil(),
1615            AccountId::from("acct"),
1616            ConversationId::nil(),
1617            TurnId::nil(),
1618            now(),
1619        )
1620        .unwrap();
1621        aliased.text_resolution = TextResolutionPolicy::ModelInterpretedLowRisk;
1622        let accepted = validate_response(
1623            &aliased,
1624            &response("a"),
1625            ResolutionChannel::ModelInterpreted,
1626            &actor(),
1627            &ConversationId::nil(),
1628            CaseRevision(12),
1629            now(),
1630        )
1631        .unwrap();
1632        assert_eq!(accepted.channel, ResolutionChannel::ModelInterpreted);
1633        assert_eq!(
1634            accepted.origin(),
1635            None,
1636            "a clarification authorizes nothing"
1637        );
1638    }
1639
1640    #[test]
1641    fn stale_revision_rejected_both_ways() {
1642        let i = interaction();
1643        let mut r = response("confirm");
1644        r.expected_case_revision = CaseRevision(11);
1645        assert_eq!(
1646            validate(&i, &r).unwrap_err(),
1647            InteractionRejection::Stale {
1648                bound_revision: CaseRevision(12),
1649                current_revision: CaseRevision(12)
1650            }
1651        );
1652        let err = validate_response(
1653            &i,
1654            &response("confirm"),
1655            ResolutionChannel::Click,
1656            &actor(),
1657            &ConversationId::nil(),
1658            CaseRevision(13),
1659            now(),
1660        )
1661        .unwrap_err();
1662        assert_eq!(
1663            err,
1664            InteractionRejection::Stale {
1665                bound_revision: CaseRevision(12),
1666                current_revision: CaseRevision(13)
1667            }
1668        );
1669        let mut independent = interaction();
1670        independent.revision_independent = true;
1671        assert!(
1672            validate_response(
1673                &independent,
1674                &response("confirm"),
1675                ResolutionChannel::Click,
1676                &actor(),
1677                &ConversationId::nil(),
1678                CaseRevision(99),
1679                now(),
1680            )
1681            .is_ok()
1682        );
1683    }
1684
1685    #[test]
1686    fn already_resolved_returns_original_option() {
1687        let mut i = interaction();
1688        i.begin_resolution(OptionId::from("confirm"), now())
1689            .unwrap();
1690        i.transition(InteractionStatus::Resolved).unwrap();
1691        assert_eq!(
1692            validate(&i, &response("confirm")).unwrap_err(),
1693            InteractionRejection::AlreadyResolved {
1694                option_id: Some(OptionId::from("confirm"))
1695            }
1696        );
1697    }
1698
1699    #[test]
1700    fn expired_and_not_active() {
1701        let mut i = interaction();
1702        i.expires_at = Some(now() - chrono::Duration::seconds(1));
1703        assert_eq!(
1704            validate(&i, &response("confirm")).unwrap_err(),
1705            InteractionRejection::Expired
1706        );
1707        let mut i = interaction();
1708        i.status = InteractionStatus::Invalidated;
1709        assert_eq!(
1710            validate(&i, &response("confirm")).unwrap_err(),
1711            InteractionRejection::NotActive {
1712                status: InteractionStatus::Invalidated
1713            }
1714        );
1715        let mut i = interaction();
1716        i.status = InteractionStatus::Resolving;
1717        assert!(matches!(
1718            validate(&i, &response("confirm")).unwrap_err(),
1719            InteractionRejection::NotActive { .. }
1720        ));
1721    }
1722
1723    #[test]
1724    fn state_machine_table() {
1725        use InteractionStatus as S;
1726        let allowed: &[(S, S)] = &[
1727            (S::Active, S::Resolving),
1728            (S::Active, S::Resolved),
1729            (S::Active, S::Declined),
1730            (S::Active, S::Dismissed),
1731            (S::Active, S::Invalidated),
1732            (S::Active, S::Expired),
1733            (S::Active, S::Failed),
1734            (S::Resolving, S::Resolved),
1735            (S::Resolving, S::Failed),
1736            (S::Resolving, S::Active),
1737        ];
1738        for from in S::ALL {
1739            for to in S::ALL {
1740                let expected = allowed.contains(&(from, to));
1741                assert_eq!(S::can_transition(from, to), expected, "{from:?} -> {to:?}");
1742            }
1743        }
1744        for terminal in [
1745            S::Resolved,
1746            S::Declined,
1747            S::Dismissed,
1748            S::Invalidated,
1749            S::Expired,
1750            S::Failed,
1751        ] {
1752            assert!(terminal.is_terminal());
1753            assert!(!terminal.is_open());
1754        }
1755    }
1756
1757    #[test]
1758    fn transition_refuses_what_the_table_forbids() {
1759        let mut i = interaction();
1760        i.transition(InteractionStatus::Resolving).unwrap();
1761        i.transition(InteractionStatus::Resolved).unwrap();
1762        let err = i.transition(InteractionStatus::Active).unwrap_err();
1763        assert!(matches!(
1764            err,
1765            InteractionError::InvalidTransition {
1766                from: InteractionStatus::Resolved,
1767                to: InteractionStatus::Active,
1768                ..
1769            }
1770        ));
1771        // `begin_resolution` refuses too, instead of recording the option.
1772        let mut done = interaction();
1773        done.status = InteractionStatus::Expired;
1774        assert!(
1775            done.begin_resolution(OptionId::from("confirm"), now())
1776                .is_err()
1777        );
1778        assert_eq!(done.resolved_option_id, None);
1779    }
1780
1781    #[test]
1782    fn view_hides_actions() {
1783        let json = serde_json::to_value(interaction().view()).unwrap();
1784        assert!(json["options"][0].get("action").is_none());
1785        assert_eq!(json["options"][0]["id"], "confirm");
1786    }
1787
1788    #[test]
1789    fn a_card_must_be_answerable_for_its_kind() {
1790        let title_only = InteractionPayload::new("Anything?");
1791        for kind in InteractionKind::ALL {
1792            assert!(
1793                title_only.validate_for(kind).is_err(),
1794                "{kind:?} accepted a card with no options"
1795            );
1796        }
1797        let boolean = InteractionPayload::new("Ready?")
1798            .with_option(InteractionOption::new(
1799                "yes",
1800                "Yes",
1801                StoredInteractionAction::ConfirmCommands {
1802                    command_refs: vec![],
1803                },
1804            ))
1805            .with_option(decline_option());
1806        assert_eq!(boolean.validate_for(InteractionKind::Boolean), Ok(()));
1807        let three = boolean.clone().with_option(InteractionOption::new(
1808            "maybe",
1809            "Maybe",
1810            StoredInteractionAction::Dismiss,
1811        ));
1812        assert!(matches!(
1813            three.validate_for(InteractionKind::Boolean),
1814            Err(InteractionSpecError::NotEnoughOptions { .. })
1815        ));
1816        // A confirmation the user cannot refuse is not a confirmation.
1817        let no_decline = InteractionPayload::new("Send?")
1818            .with_option(confirm_option())
1819            .with_option(InteractionOption::new(
1820                "later",
1821                "Later",
1822                StoredInteractionAction::ResolveClarification {
1823                    answer_key: "later".into(),
1824                },
1825            ));
1826        assert!(matches!(
1827            no_decline.validate_for(InteractionKind::ConfirmCommand),
1828            Err(InteractionSpecError::MissingDeclineOption { .. })
1829        ));
1830        let no_authority = InteractionPayload::new("Send?")
1831            .with_option(decline_option())
1832            .with_option(InteractionOption::new(
1833                "dismiss",
1834                "Close",
1835                StoredInteractionAction::Dismiss,
1836            ));
1837        assert!(matches!(
1838            no_authority.validate_for(InteractionKind::ConfirmCommand),
1839            Err(InteractionSpecError::MissingAuthorizingOption { .. })
1840        ));
1841        // A review card with nothing to review.
1842        assert!(matches!(
1843            boolean.validate_for(InteractionKind::ReviewChanges),
1844            Err(InteractionSpecError::MissingReviewEntries)
1845        ));
1846        let review = boolean.clone().with_review_entry(
1847            ReviewDiffEntry::new("total", "Total")
1848                .with_after(FieldValue::present(serde_json::Value::from(10))),
1849        );
1850        assert_eq!(review.validate_for(InteractionKind::ReviewChanges), Ok(()));
1851        // Free-form cards need somewhere to type.
1852        let freeform_option =
1853            InteractionOption::new("text", "Send", StoredInteractionAction::Dismiss)
1854                .with_freeform(FreeformPolicy::Required { max_len: 200 });
1855        let no_prompt = InteractionPayload::new("Note").with_option(freeform_option.clone());
1856        assert!(matches!(
1857            no_prompt.validate_for(InteractionKind::Freeform),
1858            Err(InteractionSpecError::MissingFreeformPrompt)
1859        ));
1860        let no_field = InteractionPayload::new("Note")
1861            .with_freeform_prompt("Write the note")
1862            .with_option(decline_option());
1863        assert!(matches!(
1864            no_field.validate_for(InteractionKind::Freeform),
1865            Err(InteractionSpecError::MissingFreeformOption)
1866        ));
1867        let good = InteractionPayload::new("Note")
1868            .with_freeform_prompt("Write the note")
1869            .with_option(freeform_option);
1870        assert_eq!(good.validate_for(InteractionKind::Freeform), Ok(()));
1871        // Duplicate ids make the answer ambiguous.
1872        let duplicated = InteractionPayload::new("Pick")
1873            .with_option(InteractionOption::new(
1874                "a",
1875                "A",
1876                StoredInteractionAction::Dismiss,
1877            ))
1878            .with_option(InteractionOption::new(
1879                "a",
1880                "A again",
1881                StoredInteractionAction::Dismiss,
1882            ));
1883        assert!(matches!(
1884            duplicated.validate_for(InteractionKind::SingleSelect),
1885            Err(InteractionSpecError::DuplicateOptionId { .. })
1886        ));
1887    }
1888
1889    #[test]
1890    fn multi_select_cannot_be_persisted() {
1891        // The input protocol carries one option id, so a stored multi-select
1892        // card could only ever be answered as a single select.
1893        let payload = InteractionPayload::new("Pick some")
1894            .with_option(InteractionOption::new(
1895                "a",
1896                "A",
1897                StoredInteractionAction::Dismiss,
1898            ))
1899            .with_option(InteractionOption::new(
1900                "b",
1901                "B",
1902                StoredInteractionAction::Dismiss,
1903            ));
1904        assert!(matches!(
1905            payload.validate_for(InteractionKind::MultiSelect),
1906            Err(InteractionSpecError::UnsupportedKind {
1907                interaction_kind: InteractionKind::MultiSelect
1908            })
1909        ));
1910        let spec = InteractionSpec::new(
1911            "pick",
1912            CaseRef::new("trip", "i1", CaseRevision(1)),
1913            InteractionKind::MultiSelect,
1914            payload,
1915        );
1916        assert!(matches!(
1917            Interaction::from_spec(
1918                spec,
1919                InteractionId::nil(),
1920                AccountId::from("acct"),
1921                ConversationId::nil(),
1922                TurnId::nil(),
1923                now()
1924            ),
1925            Err(InteractionError::InvalidSpec(_))
1926        ));
1927    }
1928
1929    #[test]
1930    fn a_confirming_card_may_never_be_resolved_from_text() {
1931        let base = spec();
1932        let policy = TextResolutionPolicy::ModelInterpretedLowRisk;
1933        let risky = base.clone().with_text_resolution(policy.clone());
1934        assert!(
1935            matches!(
1936                risky.validate(),
1937                Err(InteractionSpecError::TextResolutionNotAllowed { .. })
1938            ),
1939            "a ConfirmCommand card is authorizing whatever its risk"
1940        );
1941        // Even a low-risk kind is refused while it confirms something
1942        // consequential.
1943        let mut low_kind = base.clone().with_text_resolution(policy);
1944        low_kind.kind = InteractionKind::SingleSelect;
1945        low_kind.payload = InteractionPayload::new("Which?").with_option(InteractionOption::new(
1946            "a",
1947            "A",
1948            StoredInteractionAction::Dismiss,
1949        ));
1950        assert!(matches!(
1951            low_kind.validate(),
1952            Err(InteractionSpecError::TextResolutionNotAllowed { .. })
1953        ));
1954        assert_eq!(
1955            low_kind
1956                .with_confirms_risk(RiskClass::ReversibleLowRisk)
1957                .validate(),
1958            Ok(())
1959        );
1960        assert_eq!(base.validate(), Ok(()));
1961        assert_eq!(
1962            spec().confirms_risk,
1963            RiskClass::Irreversible,
1964            "a card that declares nothing is treated as consequential"
1965        );
1966    }
1967
1968    #[test]
1969    fn an_unusable_time_to_live_is_an_error_not_an_immortal_card() {
1970        let ttl = spec().expires_in(Duration::from_secs(60));
1971        let card = Interaction::from_spec(
1972            ttl,
1973            InteractionId::nil(),
1974            AccountId::from("acct"),
1975            ConversationId::nil(),
1976            TurnId::nil(),
1977            now(),
1978        )
1979        .unwrap();
1980        assert_eq!(card.expires_at, Some(now() + chrono::Duration::seconds(60)));
1981        assert!(!card.is_expired(now()));
1982        assert!(card.is_expired(now() + chrono::Duration::seconds(61)));
1983        let absurd = spec().expires_in(Duration::from_secs(u64::MAX));
1984        assert!(matches!(
1985            Interaction::from_spec(
1986                absurd,
1987                InteractionId::nil(),
1988                AccountId::from("acct"),
1989                ConversationId::nil(),
1990                TurnId::nil(),
1991                now()
1992            ),
1993            Err(InteractionError::InvalidTtl)
1994        ));
1995    }
1996
1997    #[test]
1998    fn an_explicit_null_survives_the_round_trip() {
1999        let entry = ReviewDiffEntry::new("traveler.email", "Email")
2000            .with_before(FieldValue::present(serde_json::Value::from("a@b.it")))
2001            .with_after(FieldValue::cleared());
2002        let json = serde_json::to_value(&entry).unwrap();
2003        assert_eq!(json["after"], serde_json::Value::Null);
2004        let back: ReviewDiffEntry = serde_json::from_value(json).unwrap();
2005        assert_eq!(back, entry);
2006        assert_eq!(back.after, FieldValue::Present(serde_json::Value::Null));
2007
2008        let absent = ReviewDiffEntry::new("traveler.email", "Email");
2009        let json = serde_json::to_value(&absent).unwrap();
2010        assert!(json.get("after").is_none(), "an absent side omits the key");
2011        assert_eq!(
2012            serde_json::from_value::<ReviewDiffEntry>(json).unwrap(),
2013            absent
2014        );
2015
2016        // The whole point: a card that clears a field still verifies after a
2017        // round trip through a JSON store, so its confirmation can match.
2018        let payload = InteractionPayload::new("Clear the email?")
2019            .with_option(confirm_option())
2020            .with_option(decline_option())
2021            .with_review_entry(entry);
2022        let card = Interaction::from_spec(
2023            InteractionSpec::new(
2024                "clear",
2025                CaseRef::new("trip", "i1", CaseRevision(1)),
2026                InteractionKind::ReviewChanges,
2027                payload,
2028            ),
2029            InteractionId::nil(),
2030            AccountId::from("acct"),
2031            ConversationId::nil(),
2032            TurnId::nil(),
2033            now(),
2034        )
2035        .unwrap();
2036        assert!(card.verify_payload_hash().unwrap());
2037        let reloaded: Interaction =
2038            serde_json::from_str(&serde_json::to_string(&card).unwrap()).unwrap();
2039        assert_eq!(reloaded, card);
2040        assert!(reloaded.verify_payload_hash().unwrap());
2041    }
2042
2043    #[test]
2044    fn action_classes_follow_the_stored_action() {
2045        assert_eq!(
2046            StoredInteractionAction::ConfirmCommands {
2047                command_refs: vec![]
2048            }
2049            .action_class(),
2050            ActionClass::ConfirmsCommands
2051        );
2052        assert_eq!(
2053            StoredInteractionAction::ApplyOperation {
2054                operation: OperationKey::from("x"),
2055                arguments: serde_json::Value::Null,
2056                freeform_argument: None,
2057            }
2058            .action_class(),
2059            ActionClass::AppliesOperation
2060        );
2061        for action in [
2062            StoredInteractionAction::DeclineCommands,
2063            StoredInteractionAction::Dismiss,
2064            StoredInteractionAction::SelectTarget {
2065                case_ref: CaseRef::new("w", "c", CaseRevision(1)),
2066            },
2067            StoredInteractionAction::ResolveClarification {
2068                answer_key: "k".into(),
2069            },
2070            StoredInteractionAction::Custom {
2071                key: "k".into(),
2072                payload: serde_json::Value::Null,
2073            },
2074        ] {
2075            assert_eq!(action.action_class(), ActionClass::NoCommands);
2076            assert!(!action.action_class().authorizes_commands());
2077        }
2078        assert!(InteractionKind::ConfirmCommand.authorizes_commands());
2079        assert!(InteractionKind::ExternalSignature.authorizes_commands());
2080        assert!(!InteractionKind::SelectTarget.authorizes_commands());
2081        assert_eq!(
2082            ConfirmationPolicy::ExplicitClick.interaction_kind(),
2083            Some(InteractionKind::ConfirmCommand)
2084        );
2085    }
2086}