Skip to main content

turnframe_runtime/
interactions.rs

1//! The persistent interaction engine (spec §15, §23 steps C, L and P).
2//!
3//! A card is a durable server-owned record, not a model tool call. This module
4//! is where the four rules that make that true are enforced against a real
5//! store:
6//!
7//! 1. **Persistence precedes the sentence.** [`InteractionEngine::persist`]
8//!    writes every specification the reducer asked for *before* any block of
9//!    the response may refer to a card (§15.5). What it could not write is
10//!    reported in [`PersistedInteractions::failed`], and the caller must then
11//!    say nothing about a card the user cannot see.
12//! 2. **One blocking card per case.** A blocking specification goes in through
13//!    [`InteractionWriter::insert_replacing_blocking`](turnframe_store::interaction::InteractionWriter::insert_replacing_blocking), so the previous occupant
14//!    is invalidated and the replacement gets a **new** identifier (§15.6). A
15//!    `Resolving` occupant is never replaced: its commands are running.
16//! 3. **Resolution is compare-and-set.** [`InteractionEngine::accept`]
17//!    validates the client's answer through core's [`validate_response`] and
18//!    then moves `Active → Resolving` through the store's own compare-and-set.
19//!    A second click loses that race and comes back as
20//!    [`ResponseAdmission::AlreadyAnswered`], carrying the record instead of
21//!    authorizing a second execution (§15.5, I14).
22//! 4. **Resolved means committed.** A card becomes `Resolved` only once the
23//!    command its answer authorized has committed; otherwise it is `Failed` or
24//!    restored to `Active` according to policy. There is no path here that
25//!    marks a card resolved on the strength of an intention.
26//!
27//! # The channel is part of the authorization
28//!
29//! [`validate_response`] takes a [`ResolutionChannel`], and so does
30//! [`InteractionEngine::accept`]: a structured click, a typed word that matched
31//! a stored alias exactly, and a "yes" the interpreter inferred are three
32//! different authorities, and the stored
33//! [`TextResolutionPolicy`](turnframe_core::interaction::TextResolutionPolicy)
34//! decides which of them the card admits (§15.7). The channel travels into the
35//! [`CommandOrigin`] the answer mints, so a replay can say which one happened
36//! (I20).
37
38use std::fmt;
39use std::sync::Arc;
40
41use chrono::{DateTime, Utc};
42use turnframe_core::case::{CaseKey, CaseRef};
43use turnframe_core::command::{CommandOrigin, ResolutionChannel};
44use turnframe_core::error::InteractionError;
45use turnframe_core::hash::derive_uuid;
46use turnframe_core::ids::{
47    AccountId, CaseRevision, ConversationId, EventId, InteractionId, TurnId,
48};
49use turnframe_core::interaction::{
50    AcceptedResponse, Interaction, InteractionRejection, InteractionSpec, InteractionStatus,
51    validate_response,
52};
53use turnframe_core::observe::{NoopObserver, Observer, Signal, SignalLabels};
54use turnframe_core::reduce::ActiveInteractionSummary;
55use turnframe_core::turn::{ActorContext, InteractionResponse};
56use turnframe_store::error::StoreError;
57use turnframe_store::interaction::{InteractionRecord, InteractionStore, ResolutionOutcome};
58
59use crate::config::InteractionConfig;
60
61/// Domain separation of the derived interaction identifiers.
62const INTERACTION_ID_DOMAIN: &str = "turnframe.interaction_id.v1";
63
64/// Derives the identifier of the card `key` of `turn`.
65///
66/// Deterministic on purpose: a turn replayed after a crash re-creates the same
67/// card under the same identifier instead of a second one the client would show
68/// twice (I20, spec §23.1).
69#[must_use]
70pub fn derive_interaction_id(turn_id: &TurnId, key: &str) -> InteractionId {
71    InteractionId::from(derive_uuid(
72        INTERACTION_ID_DOMAIN,
73        &[&turn_id.to_string(), key],
74    ))
75}
76
77/// What [`InteractionEngine::persist`] managed to write.
78///
79/// `failed` is the whole point of the type. A caller that ignores it and
80/// narrates "I have prepared the change below" is exactly the defect §15.5
81/// forbids, so the outcome makes the failure impossible to overlook while still
82/// handing back the cards that *were* written.
83#[derive(Debug, Clone, Default)]
84#[non_exhaustive]
85pub struct PersistedInteractions {
86    /// The cards now visible to the client, in specification order.
87    pub created: Vec<Interaction>,
88    /// Cards a replacing insert invalidated, in application order.
89    pub invalidated: Vec<InteractionId>,
90    /// The first failure, when one specification could not be written.
91    pub failed: Option<InteractionError>,
92}
93
94impl PersistedInteractions {
95    /// Returns `true` when every specification was written.
96    #[must_use]
97    pub fn is_complete(&self) -> bool {
98        self.failed.is_none()
99    }
100
101    /// The cards, as the client-facing views a response block carries.
102    #[must_use]
103    pub fn views(&self) -> Vec<turnframe_core::interaction::InteractionView> {
104        self.created.iter().map(Interaction::view).collect()
105    }
106}
107
108/// A client answer that passed every §15.5 rule and holds the card in
109/// `Resolving`.
110#[derive(Debug, Clone)]
111#[non_exhaustive]
112pub struct AcceptedInteraction {
113    /// The validated answer.
114    pub response: AcceptedResponse,
115    /// The stored record, now in `Resolving`.
116    pub record: InteractionRecord,
117}
118
119impl AcceptedInteraction {
120    /// The command origin the answer authorizes, or `None` when it authorizes
121    /// nothing (a selection, a clarification, a dismissal).
122    #[must_use]
123    pub fn origin(&self) -> Option<CommandOrigin> {
124        self.response.origin()
125    }
126
127    /// The case the card belongs to, at the revision it was bound to.
128    #[must_use]
129    pub fn case_ref(&self) -> &CaseRef {
130        &self.response.case_ref
131    }
132
133    /// The interaction identifier.
134    #[must_use]
135    pub fn interaction_id(&self) -> InteractionId {
136        self.response.interaction_id
137    }
138}
139
140/// What a client answer is judged against (spec §15.5).
141///
142/// It exists so the six things that decide whether a click is valid — who is
143/// clicking, in which conversation and turn, how the answer arrived, what
144/// revision the case is really at, and when "now" is — travel together and
145/// none of them can be forgotten at a call site.
146#[derive(Debug, Clone, Copy)]
147pub struct ResponseContext<'a> {
148    /// The authenticated actor.
149    pub actor: &'a ActorContext,
150    /// The conversation the card belongs to.
151    pub conversation: &'a ConversationId,
152    /// The turn that is answering.
153    pub turn_id: TurnId,
154    /// How the answer arrived (spec §15.7).
155    pub channel: ResolutionChannel,
156    /// The revision the case is at, read fresh.
157    pub current_revision: CaseRevision,
158    /// The instant to judge expiry against.
159    pub now: DateTime<Utc>,
160}
161
162impl<'a> ResponseContext<'a> {
163    /// A structured click.
164    #[must_use]
165    pub fn click(
166        actor: &'a ActorContext,
167        conversation: &'a ConversationId,
168        turn_id: TurnId,
169        current_revision: CaseRevision,
170        now: DateTime<Utc>,
171    ) -> Self {
172        Self {
173            actor,
174            conversation,
175            turn_id,
176            channel: ResolutionChannel::Click,
177            current_revision,
178            now,
179        }
180    }
181
182    /// Returns a copy that arrived on another channel.
183    #[must_use]
184    pub const fn through(mut self, channel: ResolutionChannel) -> Self {
185        self.channel = channel;
186        self
187    }
188}
189
190/// What happened to a client answer.
191#[derive(Debug, Clone)]
192#[non_exhaustive]
193pub enum ResponseAdmission {
194    /// The answer was accepted and the card is now `Resolving`.
195    Accepted(Box<AcceptedInteraction>),
196    /// The card had already been answered — a second click, or a race the
197    /// client lost. It covers both a resolution that is still running
198    /// (`Resolving`) and one that finished (`Resolved`): in either case the
199    /// record comes back so the turn can repeat the original *result* without
200    /// repeating the original *effect* (§15.5, I14).
201    AlreadyAnswered(Box<InteractionRecord>),
202}
203
204impl ResponseAdmission {
205    /// The accepted answer, when there was one.
206    #[must_use]
207    pub fn accepted(&self) -> Option<&AcceptedInteraction> {
208        match self {
209            Self::Accepted(accepted) => Some(accepted),
210            Self::AlreadyAnswered(_) => None,
211        }
212    }
213
214    /// Returns `true` when nothing new may execute for this answer.
215    #[must_use]
216    pub fn is_replay(&self) -> bool {
217        matches!(self, Self::AlreadyAnswered(_))
218    }
219}
220
221/// Reads, writes and settles persisted interactions (spec §15).
222#[derive(Clone)]
223pub struct InteractionEngine {
224    store: Arc<dyn InteractionStore>,
225    config: InteractionConfig,
226    observer: Arc<dyn Observer>,
227}
228
229impl fmt::Debug for InteractionEngine {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.debug_struct("InteractionEngine")
232            .field("config", &self.config)
233            .finish_non_exhaustive()
234    }
235}
236
237impl InteractionEngine {
238    /// Builds an engine over `store`.
239    #[must_use]
240    pub fn new(store: Arc<dyn InteractionStore>, config: InteractionConfig) -> Self {
241        Self {
242            store,
243            config,
244            observer: Arc::new(NoopObserver),
245        }
246    }
247
248    /// Sends this stage's signals to `observer` (spec §26.2).
249    ///
250    /// [`Orchestrator`](crate::orchestrator::Orchestrator) sets it from its own
251    /// observer when it is built, so a card settled **out of band** — the
252    /// §15.7 free-text channel, an operator tool, a reconciliation job — is
253    /// counted on the same series as one a turn settled.
254    #[must_use]
255    pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self {
256        self.observer = observer;
257        self
258    }
259
260    /// The interaction configuration in force.
261    #[must_use]
262    pub const fn config(&self) -> &InteractionConfig {
263        &self.config
264    }
265
266    /// Materializes one specification and writes it (spec §15.5, §15.6).
267    ///
268    /// A blocking card replaces the case's current blocking occupant and
269    /// invalidates it; a non-blocking one is inserted next to whatever is
270    /// there. The identifier is derived from the turn and the specification
271    /// key, so a replay writes the same card rather than a duplicate.
272    ///
273    /// # Errors
274    ///
275    /// * [`InteractionError::InvalidSpec`] when the card could not be answered;
276    /// * [`InteractionError::NotPersisted`] when the store refused the write —
277    ///   after which the response must not mention the card.
278    pub async fn create(
279        &self,
280        spec: InteractionSpec,
281        account: &AccountId,
282        conversation: ConversationId,
283        turn_id: TurnId,
284        now: DateTime<Utc>,
285    ) -> Result<(Interaction, Vec<InteractionId>), InteractionError> {
286        let id = derive_interaction_id(&turn_id, &spec.key);
287        let mut spec = spec;
288        if spec.expires_in.is_none()
289            && let Some(ttl) = self.config.default_ttl
290        {
291            spec = spec.expires_in(ttl);
292        }
293        let interaction =
294            Interaction::from_spec(spec, id, account.clone(), conversation, turn_id, now)?;
295        let invalidated = if interaction.blocking {
296            self.store
297                .insert_replacing_blocking(interaction.clone())
298                .await
299                .map_err(persistence_failure)?
300        } else {
301            self.store
302                .insert(interaction.clone())
303                .await
304                .map_err(persistence_failure)?;
305            Vec::new()
306        };
307        Ok((interaction, invalidated))
308    }
309
310    /// Writes every specification, stopping at the first failure.
311    ///
312    /// Nothing here is best effort in the sense of "carry on and hope": the
313    /// first failure stops the loop and is reported, precisely so the caller
314    /// cannot describe cards that are not there.
315    pub async fn persist(
316        &self,
317        specs: &[InteractionSpec],
318        account: &AccountId,
319        conversation: ConversationId,
320        turn_id: TurnId,
321        now: DateTime<Utc>,
322    ) -> PersistedInteractions {
323        let mut created = Vec::with_capacity(specs.len());
324        let mut invalidated = Vec::new();
325        let mut failed = None;
326        for spec in specs {
327            match self
328                .create(spec.clone(), account, conversation, turn_id, now)
329                .await
330            {
331                Ok((interaction, gone)) => {
332                    created.push(interaction);
333                    invalidated.extend(gone);
334                }
335                Err(error) => {
336                    tracing::warn!(
337                        target: "turnframe.interactions",
338                        key = %spec.key,
339                        "interaction persistence failed; the response may not mention this card"
340                    );
341                    failed = Some(error);
342                    break;
343                }
344            }
345        }
346        PersistedInteractions {
347            created,
348            invalidated,
349            failed,
350        }
351    }
352
353    /// Validates a client answer and takes the card into `Resolving`
354    /// (spec §15.5, §23 step C).
355    ///
356    /// `current_revision` is the revision the case is actually at, read fresh:
357    /// a card bound to an older one is
358    /// [`Stale`](InteractionRejection::Stale) whatever the client echoed.
359    ///
360    /// # Errors
361    ///
362    /// [`InteractionError::Rejected`] carrying the §15.5 rejection. An
363    /// identifier of another tenant is [`InteractionRejection::NotFound`],
364    /// exactly like one that never existed (spec §25.4).
365    pub async fn accept(
366        &self,
367        context: ResponseContext<'_>,
368        response: &InteractionResponse,
369    ) -> Result<ResponseAdmission, InteractionError> {
370        let ResponseContext {
371            actor,
372            conversation,
373            turn_id,
374            channel,
375            current_revision,
376            now,
377        } = context;
378        let record = match self
379            .store
380            .get(&actor.account_id, &response.interaction_id)
381            .await
382        {
383            Ok(record) => record,
384            // Unknown and other-tenant identifiers are indistinguishable, and
385            // a store that is merely unavailable must not look like either.
386            Err(StoreError::NotFound) => {
387                return Err(InteractionError::Rejected(InteractionRejection::NotFound));
388            }
389            Err(_) => return Err(InteractionError::NotPersisted),
390        };
391        let accepted = match validate_response(
392            &record.interaction,
393            response,
394            channel,
395            actor,
396            conversation,
397            current_revision,
398            now,
399        ) {
400            Ok(accepted) => accepted,
401            // Both mean "somebody already answered this": one because the
402            // resolution finished, the other because it is still running. A
403            // second click authorizes nothing either way (I14).
404            Err(InteractionRejection::AlreadyResolved { .. })
405            | Err(InteractionRejection::NotActive {
406                status: InteractionStatus::Resolving,
407            }) => {
408                return Ok(ResponseAdmission::AlreadyAnswered(Box::new(record)));
409            }
410            Err(rejection) => return Err(InteractionError::Rejected(rejection)),
411        };
412        match self
413            .store
414            .begin_resolution(
415                &actor.account_id,
416                &response.interaction_id,
417                InteractionStatus::Active,
418                accepted.option_id.clone(),
419                turn_id,
420            )
421            .await
422        {
423            Ok(record) => Ok(ResponseAdmission::Accepted(Box::new(AcceptedInteraction {
424                response: accepted,
425                record,
426            }))),
427            // The compare-and-set lost: somebody else already began resolving
428            // this card. That is a double click, and the honest answer is the
429            // resolution that is already under way (I14).
430            Err(StoreError::Conflict) => {
431                let record = self
432                    .store
433                    .get(&actor.account_id, &response.interaction_id)
434                    .await
435                    .map_err(|_| InteractionError::NotPersisted)?;
436                Ok(ResponseAdmission::AlreadyAnswered(Box::new(record)))
437            }
438            Err(StoreError::NotFound) => {
439                Err(InteractionError::Rejected(InteractionRejection::NotFound))
440            }
441            Err(_) => Err(InteractionError::NotPersisted),
442        }
443    }
444
445    /// Settles a `Resolving` card.
446    ///
447    /// # Errors
448    ///
449    /// [`InteractionError::NotPersisted`] when the store refused, and
450    /// [`InteractionError::Rejected`] with
451    /// [`NotFound`](InteractionRejection::NotFound) when the card is not there.
452    pub async fn settle(
453        &self,
454        account: &AccountId,
455        id: &InteractionId,
456        outcome: ResolutionOutcome,
457    ) -> Result<InteractionRecord, InteractionError> {
458        let settled = self
459            .store
460            .finish_resolution(account, id, outcome)
461            .await
462            .map_err(|error| match error {
463                StoreError::NotFound => InteractionError::Rejected(InteractionRejection::NotFound),
464                _ => InteractionError::NotPersisted,
465            })?;
466        // Only once the store said so: a card that reached a status is one the
467        // store wrote, not one this process intended to write.
468        observe_settled(self.observer.as_ref(), &settled);
469        Ok(settled)
470    }
471
472    /// Marks a card `Resolved` — only legitimate once the command its answer
473    /// authorized has committed, which is why the events are required (§15.5).
474    ///
475    /// # Errors
476    ///
477    /// See [`Self::settle`].
478    pub async fn mark_resolved(
479        &self,
480        account: &AccountId,
481        id: &InteractionId,
482        event_ids: Vec<EventId>,
483    ) -> Result<InteractionRecord, InteractionError> {
484        self.settle(account, id, ResolutionOutcome::Resolved { event_ids })
485            .await
486    }
487
488    /// Marks a card `Failed` after the command it authorized did not commit.
489    ///
490    /// # Errors
491    ///
492    /// See [`Self::settle`].
493    pub async fn mark_failed(
494        &self,
495        account: &AccountId,
496        id: &InteractionId,
497        code: impl Into<String>,
498    ) -> Result<InteractionRecord, InteractionError> {
499        self.settle(account, id, ResolutionOutcome::Failed { code: code.into() })
500            .await
501    }
502
503    /// Puts a card back to `Active` so the user may answer again.
504    ///
505    /// # Errors
506    ///
507    /// See [`Self::settle`].
508    pub async fn restore(
509        &self,
510        account: &AccountId,
511        id: &InteractionId,
512    ) -> Result<InteractionRecord, InteractionError> {
513        self.settle(account, id, ResolutionOutcome::RestoreActive)
514            .await
515    }
516
517    /// The open cards of a conversation, oldest first.
518    ///
519    /// # Errors
520    ///
521    /// [`InteractionError::NotPersisted`] when the store could not answer;
522    /// silently pretending there are none would let a blocking card be
523    /// bypassed (I19).
524    pub async fn open_for_conversation(
525        &self,
526        account: &AccountId,
527        conversation: &ConversationId,
528    ) -> Result<Vec<Interaction>, InteractionError> {
529        self.store
530            .list_open_for_conversation(account, conversation)
531            .await
532            .map_err(|_| InteractionError::NotPersisted)
533    }
534
535    /// The open cards of one case, oldest first.
536    ///
537    /// # Errors
538    ///
539    /// See [`Self::open_for_conversation`].
540    pub async fn open_for_case(
541        &self,
542        account: &AccountId,
543        case_key: &CaseKey,
544    ) -> Result<Vec<Interaction>, InteractionError> {
545        self.store
546            .list_open_for_case(account, case_key)
547            .await
548            .map_err(|_| InteractionError::NotPersisted)
549    }
550
551    /// Whether the user has already answered a blocking card of this case at
552    /// this revision.
553    ///
554    /// What stops a declined card going straight back up. See
555    /// [`InteractionReader::blocking_answered_at`](turnframe_store::interaction::InteractionReader::blocking_answered_at).
556    ///
557    /// # Errors
558    ///
559    /// See [`Self::open_for_conversation`].
560    pub async fn blocking_answered_at(
561        &self,
562        account: &AccountId,
563        case_key: &CaseKey,
564        revision: turnframe_core::ids::CaseRevision,
565    ) -> Result<bool, InteractionError> {
566        self.store
567            .blocking_answered_at(account, case_key, revision)
568            .await
569            .map_err(|_| InteractionError::NotPersisted)
570    }
571
572    /// One stored card, whatever its status.
573    ///
574    /// # Errors
575    ///
576    /// See [`Self::open_for_conversation`]; an identifier of another tenant is
577    /// [`InteractionRejection::NotFound`].
578    pub async fn get(
579        &self,
580        account: &AccountId,
581        id: &InteractionId,
582    ) -> Result<InteractionRecord, InteractionError> {
583        self.store
584            .get(account, id)
585            .await
586            .map_err(|error| match error {
587                StoreError::NotFound => InteractionError::Rejected(InteractionRejection::NotFound),
588                _ => InteractionError::NotPersisted,
589            })
590    }
591}
592
593/// The dimensions every settlement signal carries: the workflow the card sits
594/// on and the shape of the card. Never the card identifier.
595fn settlement_labels(card: &Interaction) -> SignalLabels {
596    SignalLabels::workflow(card.case_ref.workflow.clone()).with_interaction(card.kind)
597}
598
599/// Reports a card that reached a terminal status, as the store left it
600/// (spec §15.5, §26.2).
601///
602/// Only the two terminal statuses are counted.
603/// [`RestoreActive`](ResolutionOutcome::RestoreActive) puts the card back in
604/// front of the user, which is neither an abandonment nor a resolution, and
605/// counting it as either would move the abandonment panel for a card the user
606/// is about to answer.
607pub(crate) fn observe_settled(observer: &dyn Observer, settled: &InteractionRecord) {
608    let card = &settled.interaction;
609    let labels = settlement_labels(card);
610    match card.status {
611        InteractionStatus::Resolved => {
612            observer.observe_labeled(&Signal::InteractionResolved, &labels);
613        }
614        InteractionStatus::Failed => {
615            let code = settled
616                .failure_code
617                .clone()
618                .unwrap_or_else(|| String::from("not_committed"));
619            observer.observe_labeled(&Signal::InteractionFailed, &labels.with_error_code(code));
620        }
621        _ => {}
622    }
623}
624
625/// The same, for a card settled inside a turn's commit bundle.
626///
627/// The bundle is the one write of the turn (spec §16.3), so there is no
628/// [`InteractionRecord`] to read back: the outcome the bundle carried *is* what
629/// the card became, and the caller emits this only once the bundle landed.
630pub(crate) fn observe_resolution(
631    observer: &dyn Observer,
632    card: &Interaction,
633    outcome: &ResolutionOutcome,
634) {
635    let labels = settlement_labels(card);
636    match outcome {
637        ResolutionOutcome::Resolved { .. } => {
638            observer.observe_labeled(&Signal::InteractionResolved, &labels);
639        }
640        ResolutionOutcome::Failed { code } => {
641            observer.observe_labeled(
642                &Signal::InteractionFailed,
643                &labels.with_error_code(code.clone()),
644            );
645        }
646        ResolutionOutcome::RestoreActive => {}
647    }
648}
649
650/// A store refusal while writing a card is never anything but "the card is not
651/// there": the caller's only correct reaction is to say nothing about it.
652fn persistence_failure(_error: StoreError) -> InteractionError {
653    InteractionError::NotPersisted
654}
655
656/// The summary the reducer needs about one stored card.
657#[must_use]
658pub fn summarize(interaction: &Interaction) -> ActiveInteractionSummary {
659    ActiveInteractionSummary {
660        interaction_id: interaction.id,
661        case_ref: interaction.case_ref.clone(),
662        kind: interaction.kind,
663        blocking: interaction.blocking,
664        option_ids: interaction.payload.option_ids(),
665        text_resolution: interaction.text_resolution.clone(),
666        confirms_risk: interaction.confirms_risk,
667        payload_hash: interaction.payload_hash.clone(),
668    }
669}
670
671/// The summaries of a set of stored cards, in the order given.
672#[must_use]
673pub fn summarize_all(interactions: &[Interaction]) -> Vec<ActiveInteractionSummary> {
674    interactions.iter().map(summarize).collect()
675}
676
677#[cfg(test)]
678mod tests {
679    use turnframe_core::case::CaseRef;
680    use turnframe_core::ids::{CaseRevision, OptionId};
681    use turnframe_core::interaction::{
682        InteractionKind, InteractionOption, InteractionPayload, StoredInteractionAction,
683    };
684    use turnframe_store::memory::MemoryStores;
685
686    use super::*;
687
688    fn engine() -> (InteractionEngine, Arc<MemoryStores>) {
689        let memory = Arc::new(MemoryStores::new());
690        let engine = InteractionEngine::new(
691            Arc::clone(&memory) as Arc<dyn InteractionStore>,
692            InteractionConfig::conservative(),
693        );
694        (engine, memory)
695    }
696
697    fn case() -> CaseRef {
698        CaseRef::new("trip", "trip-1", CaseRevision(3))
699    }
700
701    fn spec(key: &str) -> InteractionSpec {
702        InteractionSpec::new(
703            key,
704            case(),
705            InteractionKind::SingleSelect,
706            InteractionPayload::new("Which one?").with_option(InteractionOption::new(
707                OptionId::from("a"),
708                "The first",
709                StoredInteractionAction::ResolveClarification {
710                    answer_key: "a".to_owned(),
711                },
712            )),
713        )
714    }
715
716    fn now() -> DateTime<Utc> {
717        DateTime::from_timestamp(1_700_000_000, 0).expect("a valid fixed instant")
718    }
719
720    #[tokio::test]
721    async fn a_second_blocking_card_replaces_and_invalidates_the_first() {
722        let (engine, _memory) = engine();
723        let account = AccountId::from("aurora");
724        let conversation = ConversationId::nil();
725
726        let (first, gone) = engine
727            .create(
728                spec("first"),
729                &account,
730                conversation,
731                TurnId::from(uuid::Uuid::from_u128(1)),
732                now(),
733            )
734            .await
735            .expect("the slot was free");
736        assert!(gone.is_empty());
737
738        let (second, gone) = engine
739            .create(
740                spec("second"),
741                &account,
742                conversation,
743                TurnId::from(uuid::Uuid::from_u128(2)),
744                now(),
745            )
746            .await
747            .expect("the occupant is replaceable");
748        assert_eq!(
749            gone,
750            vec![first.id],
751            "the previous blocking card was invalidated (I5, §15.6)"
752        );
753        assert_ne!(second.id, first.id, "and the replacement is a new card");
754
755        let open = engine
756            .open_for_case(&account, &case().key())
757            .await
758            .expect("the store answers");
759        assert_eq!(open.len(), 1);
760        assert_eq!(open[0].id, second.id);
761    }
762
763    #[tokio::test]
764    async fn a_card_is_resolved_only_once_its_command_committed() {
765        let (engine, _memory) = engine();
766        let account = AccountId::from("aurora");
767        let conversation = ConversationId::nil();
768        let turn = TurnId::from(uuid::Uuid::from_u128(1));
769        let (card, _) = engine
770            .create(spec("first"), &account, conversation, turn, now())
771            .await
772            .expect("the card is written");
773
774        let actor = ActorContext::new(account.clone(), "u1");
775        let response = InteractionResponse {
776            interaction_id: card.id,
777            option_id: OptionId::from("a"),
778            expected_case_revision: CaseRevision(3),
779            freeform_input: None,
780        };
781        let admitted = engine
782            .accept(
783                ResponseContext::click(&actor, &conversation, turn, CaseRevision(3), now()),
784                &response,
785            )
786            .await
787            .expect("the answer is valid");
788        let accepted = admitted.accepted().expect("it was accepted");
789        assert_eq!(
790            accepted.record.status(),
791            InteractionStatus::Resolving,
792            "answering starts a resolution; it does not finish one"
793        );
794
795        // A second click loses the compare-and-set and never authorizes a
796        // second execution (I14).
797        let again = engine
798            .accept(
799                ResponseContext::click(
800                    &actor,
801                    &conversation,
802                    TurnId::from(uuid::Uuid::from_u128(2)),
803                    CaseRevision(3),
804                    now(),
805                ),
806                &response,
807            )
808            .await
809            .expect("the second click is answered, not accepted");
810        assert!(again.is_replay());
811
812        let settled = engine
813            .mark_resolved(&account, &card.id, Vec::new())
814            .await
815            .expect("the command committed");
816        assert_eq!(settled.status(), InteractionStatus::Resolved);
817    }
818
819    #[tokio::test]
820    async fn a_card_of_another_tenant_is_simply_not_there() {
821        let (engine, _memory) = engine();
822        let (card, _) = engine
823            .create(
824                spec("first"),
825                &AccountId::from("aurora"),
826                ConversationId::nil(),
827                TurnId::nil(),
828                now(),
829            )
830            .await
831            .expect("the card is written");
832
833        let stranger = engine.get(&AccountId::from("other"), &card.id).await;
834        let unknown = engine
835            .get(
836                &AccountId::from("other"),
837                &InteractionId::from(uuid::Uuid::from_u128(999)),
838            )
839            .await;
840        assert_eq!(
841            format!("{stranger:?}"),
842            format!("{unknown:?}"),
843            "another tenant's card and one that never existed must be one answer (§25.4)"
844        );
845    }
846
847    #[test]
848    fn interaction_ids_are_derived_and_stable() {
849        let turn = TurnId::nil();
850        assert_eq!(
851            derive_interaction_id(&turn, "confirm:acts[0]"),
852            derive_interaction_id(&turn, "confirm:acts[0]"),
853        );
854        assert_ne!(
855            derive_interaction_id(&turn, "confirm:acts[0]"),
856            derive_interaction_id(&turn, "confirm:acts[1]"),
857        );
858    }
859}