1use 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#[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 Boolean,
37 SingleSelect,
39 MultiSelect,
41 Freeform,
43 ReviewChanges,
45 ConfirmCommand,
47 SelectTarget,
49 ResolveValidationError,
51 Reauthenticate,
53 ExternalSignature,
55}
56
57impl InteractionKind {
58 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 #[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#[derive(
95 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
96)]
97#[serde(rename_all = "snake_case")]
98pub enum InteractionStatus {
99 Active,
101 Resolving,
103 Resolved,
105 Declined,
107 Dismissed,
109 Invalidated,
111 Expired,
113 Failed,
115}
116
117impl InteractionStatus {
118 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 #[must_use]
132 pub fn is_terminal(self) -> bool {
133 !matches!(self, Self::Active | Self::Resolving)
134 }
135
136 #[must_use]
139 pub fn is_open(self) -> bool {
140 matches!(self, Self::Active | Self::Resolving)
141 }
142
143 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
161#[serde(tag = "kind", rename_all = "snake_case")]
162pub enum FreeformPolicy {
163 #[default]
165 Forbidden,
166 Optional {
168 max_len: usize,
170 },
171 Required {
173 max_len: usize,
175 },
176}
177
178impl FreeformPolicy {
179 #[must_use]
181 pub fn allows(self) -> bool {
182 !matches!(self, Self::Forbidden)
183 }
184
185 #[must_use]
187 pub fn requires(self) -> bool {
188 matches!(self, Self::Required { .. })
189 }
190
191 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
203#[serde(tag = "kind", rename_all = "snake_case")]
204pub enum TextResolutionPolicy {
205 #[default]
207 Never,
208 ModelInterpretedLowRisk,
211}
212
213impl TextResolutionPolicy {
214 #[must_use]
216 pub fn allows_model_interpretation(&self) -> bool {
217 matches!(self, Self::ModelInterpretedLowRisk)
218 }
219
220 #[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#[derive(
240 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
241)]
242#[serde(rename_all = "snake_case")]
243pub enum ActionClass {
244 ConfirmsCommands,
246 AppliesOperation,
248 NoCommands,
251}
252
253impl ActionClass {
254 #[must_use]
256 pub fn authorizes_commands(self) -> bool {
257 matches!(self, Self::ConfirmsCommands | Self::AppliesOperation)
258 }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(tag = "kind", rename_all = "snake_case")]
267#[non_exhaustive]
268pub enum StoredInteractionAction {
269 ConfirmCommands {
271 command_refs: Vec<CommandRef>,
273 },
274 DeclineCommands,
276 SelectTarget {
278 case_ref: CaseRef,
280 },
281 ResolveClarification {
283 answer_key: String,
285 },
286 ApplyOperation {
288 operation: OperationKey,
290 arguments: serde_json::Value,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
308 freeform_argument: Option<String>,
309 },
310 DeclineAndRecord {
343 operation: OperationKey,
346 },
347 Dismiss,
349 Custom {
351 key: String,
353 payload: serde_json::Value,
355 },
356}
357
358impl StoredInteractionAction {
359 #[must_use]
365 pub fn action_class(&self) -> ActionClass {
366 match self {
367 Self::ConfirmCommands { .. } => ActionClass::ConfirmsCommands,
368 Self::ApplyOperation { .. } => ActionClass::AppliesOperation,
369 Self::DeclineCommands
373 | Self::DeclineAndRecord { .. }
374 | Self::SelectTarget { .. }
375 | Self::ResolveClarification { .. }
376 | Self::Dismiss
377 | Self::Custom { .. } => ActionClass::NoCommands,
378 }
379 }
380
381 #[must_use]
383 pub fn declines(&self) -> bool {
384 matches!(
385 self,
386 Self::DeclineCommands | Self::DeclineAndRecord { .. } | Self::Dismiss
387 )
388 }
389
390 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
402#[serde(rename_all = "snake_case")]
403pub enum OptionStyle {
404 Primary,
406 #[default]
408 Secondary,
409 Danger,
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct InteractionOption {
416 pub id: OptionId,
418 pub label: LocalizedText,
420 pub action: StoredInteractionAction,
422 #[serde(default)]
424 pub freeform_policy: FreeformPolicy,
425 #[serde(default)]
427 pub style: OptionStyle,
428}
429
430impl InteractionOption {
431 #[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 #[must_use]
449 pub fn with_freeform(mut self, policy: FreeformPolicy) -> Self {
450 self.freeform_policy = policy;
451 self
452 }
453
454 #[must_use]
456 pub fn with_style(mut self, style: OptionStyle) -> Self {
457 self.style = style;
458 self
459 }
460
461 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
484pub enum FieldValue {
485 #[default]
487 Absent,
488 Present(serde_json::Value),
490}
491
492impl FieldValue {
493 #[must_use]
495 pub fn present(value: impl Into<serde_json::Value>) -> Self {
496 Self::Present(value.into())
497 }
498
499 #[must_use]
501 pub fn cleared() -> Self {
502 Self::Present(serde_json::Value::Null)
503 }
504
505 #[must_use]
507 pub fn is_absent(&self) -> bool {
508 matches!(self, Self::Absent)
509 }
510
511 #[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 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 serde_json::Value::deserialize(deserializer).map(Self::Present)
543 }
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct ReviewDiffEntry {
549 pub field: String,
551 pub label: LocalizedText,
553 #[serde(default, skip_serializing_if = "FieldValue::is_absent")]
555 pub before: FieldValue,
556 #[serde(default, skip_serializing_if = "FieldValue::is_absent")]
558 pub after: FieldValue,
559}
560
561impl ReviewDiffEntry {
562 #[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 #[must_use]
575 pub fn with_before(mut self, before: FieldValue) -> Self {
576 self.before = before;
577 self
578 }
579
580 #[must_use]
582 pub fn with_after(mut self, after: FieldValue) -> Self {
583 self.after = after;
584 self
585 }
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct InteractionPayload {
591 pub title: LocalizedText,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub body: Option<LocalizedText>,
596 #[serde(default)]
598 pub options: Vec<InteractionOption>,
599 #[serde(default)]
601 pub review_entries: Vec<ReviewDiffEntry>,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub freeform_prompt: Option<LocalizedText>,
605 #[serde(default)]
607 pub metadata: serde_json::Value,
608}
609
610impl InteractionPayload {
611 #[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 #[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 #[must_use]
633 pub fn with_option(mut self, option: InteractionOption) -> Self {
634 self.options.push(option);
635 self
636 }
637
638 #[must_use]
640 pub fn with_review_entry(mut self, entry: ReviewDiffEntry) -> Self {
641 self.review_entries.push(entry);
642 self
643 }
644
645 #[must_use]
647 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
648 self.metadata = metadata;
649 self
650 }
651
652 pub fn hash(&self) -> Result<Digest, HashError> {
656 canonical_digest(self)
657 }
658
659 #[must_use]
661 pub fn option(&self, id: &OptionId) -> Option<&InteractionOption> {
662 self.options.iter().find(|o| &o.id == id)
663 }
664
665 #[must_use]
667 pub fn option_ids(&self) -> Vec<OptionId> {
668 self.options.iter().map(|o| o.id.clone()).collect()
669 }
670
671 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
786pub struct InteractionSpec {
787 pub key: String,
790 pub case_ref: CaseRef,
792 pub kind: InteractionKind,
794 pub blocking: bool,
796 pub payload: InteractionPayload,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub expires_in: Option<Duration>,
801 #[serde(default)]
803 pub text_resolution: TextResolutionPolicy,
804 #[serde(default = "RiskClass::conservative")]
810 pub confirms_risk: RiskClass,
811 pub binds_to_revision: bool,
813}
814
815impl InteractionSpec {
816 #[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 #[must_use]
840 pub fn with_confirms_risk(mut self, risk: RiskClass) -> Self {
841 self.confirms_risk = risk;
842 self
843 }
844
845 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 #[must_use]
869 pub fn non_blocking(mut self) -> Self {
870 self.blocking = false;
871 self
872 }
873
874 #[must_use]
876 pub fn with_text_resolution(mut self, policy: TextResolutionPolicy) -> Self {
877 self.text_resolution = policy;
878 self
879 }
880
881 #[must_use]
883 pub fn expires_in(mut self, ttl: Duration) -> Self {
884 self.expires_in = Some(ttl);
885 self
886 }
887
888 #[must_use]
890 pub fn revision_independent(mut self) -> Self {
891 self.binds_to_revision = false;
892 self
893 }
894}
895
896#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898pub struct Interaction {
899 pub id: InteractionId,
901 pub account_id: AccountId,
903 pub conversation_id: ConversationId,
905 pub case_ref: CaseRef,
907 pub created_by_turn: TurnId,
909 pub kind: InteractionKind,
911 pub blocking: bool,
913 pub payload: InteractionPayload,
915 pub payload_hash: Digest,
917 pub status: InteractionStatus,
919 pub revision_independent: bool,
921 pub text_resolution: TextResolutionPolicy,
923 #[serde(default = "RiskClass::conservative")]
926 pub confirms_risk: RiskClass,
927 pub created_at: DateTime<Utc>,
929 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub expires_at: Option<DateTime<Utc>>,
932 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub resolved_at: Option<DateTime<Utc>>,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub resolved_option_id: Option<OptionId>,
938}
939
940impl Interaction {
941 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 #[must_use]
990 pub fn bound_revision(&self) -> Option<CaseRevision> {
991 (!self.revision_independent).then_some(self.case_ref.expected_revision)
992 }
993
994 #[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 pub fn verify_payload_hash(&self) -> Result<bool, HashError> {
1002 Ok(self.payload.hash()? == self.payload_hash)
1003 }
1004
1005 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1057pub struct InteractionOptionView {
1058 pub id: OptionId,
1060 pub label: LocalizedText,
1062 pub freeform_policy: FreeformPolicy,
1064 pub style: OptionStyle,
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1070pub struct InteractionView {
1071 pub id: InteractionId,
1073 pub kind: InteractionKind,
1075 pub status: InteractionStatus,
1077 pub blocking: bool,
1079 pub case_ref: CaseRef,
1081 pub title: LocalizedText,
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub body: Option<LocalizedText>,
1086 pub options: Vec<InteractionOptionView>,
1088 #[serde(default)]
1090 pub review_entries: Vec<ReviewDiffEntry>,
1091 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub freeform_prompt: Option<LocalizedText>,
1094 #[serde(default)]
1096 pub metadata: serde_json::Value,
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub expires_at: Option<DateTime<Utc>>,
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1104#[serde(tag = "kind", rename_all = "snake_case")]
1105#[non_exhaustive]
1106pub enum InteractionRejection {
1107 #[error("interaction not found")]
1110 NotFound,
1111 #[error("interaction is not active ({status:?})")]
1113 NotActive {
1114 status: InteractionStatus,
1116 },
1117 #[error("interaction already resolved")]
1120 AlreadyResolved {
1121 option_id: Option<OptionId>,
1123 },
1124 #[error("interaction expired")]
1126 Expired,
1127 #[error("interaction stale: bound {bound_revision}, current {current_revision}")]
1129 Stale {
1130 bound_revision: CaseRevision,
1132 current_revision: CaseRevision,
1134 },
1135 #[error("unknown option")]
1137 UnknownOption,
1138 #[error("freeform input not allowed")]
1140 FreeformNotAllowed,
1141 #[error("freeform input required")]
1143 FreeformRequired,
1144 #[error("freeform input exceeds {max_len} characters")]
1146 FreeformTooLong {
1147 max_len: usize,
1149 },
1150 #[error("interaction payload does not match its stored hash")]
1153 PayloadCorrupt,
1154 #[error("interaction cannot be resolved through the {channel:?} channel")]
1158 ChannelNotAllowed {
1159 channel: ResolutionChannel,
1161 },
1162}
1163
1164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1166pub struct AcceptedResponse {
1167 pub interaction_id: InteractionId,
1169 pub case_ref: CaseRef,
1171 pub kind: InteractionKind,
1173 pub option_id: OptionId,
1175 pub action: StoredInteractionAction,
1177 pub channel: ResolutionChannel,
1179 pub freeform_input: Option<String>,
1181 pub payload_hash: Digest,
1183}
1184
1185impl AcceptedResponse {
1186 #[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
1210pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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}