Skip to main content

turnframe_store/conformance/
interactions.rs

1//! Checks for [`InteractionStore`](crate::interaction::InteractionStore).
2
3use turnframe_core::ids::{CaseRevision, ConversationId, InteractionId, OptionId, TurnId};
4use turnframe_core::interaction::InteractionStatus;
5
6use super::ConformanceFailure;
7use super::fixtures::{
8    account, at, card, card_spec, case, case_key, ensure, ensure_eq, ensure_error, ensure_ok,
9    epoch, journal_entry, other_account, other_case, ttl, user_turn,
10};
11use crate::conversation::ConversationRecord;
12use crate::error::StoreError;
13use crate::interaction::{InvalidationReason, ResolutionOutcome};
14use crate::stores::Stores;
15
16/// A case holds at most one open blocking card (I5, spec §15.6).
17///
18/// Proves the slot is per case and per account, that non-blocking cards do not
19/// take it, and that a duplicate identifier is refused.
20pub async fn check_blocking_interaction_conflict(
21    stores: &Stores,
22) -> Result<(), ConformanceFailure> {
23    const CHECK: &str = "check_blocking_interaction_conflict";
24    let interactions = stores.interactions();
25    let account = account();
26    let conversation = ConversationId::new();
27    let turn = TurnId::new();
28
29    let first = card(
30        CHECK,
31        card_spec("first", case(1)),
32        InteractionId::new(),
33        account.clone(),
34        conversation,
35        turn,
36        epoch(),
37    )?;
38    ensure_ok(
39        CHECK,
40        "inserting the first blocking card",
41        interactions.insert(first.clone()).await,
42    )?;
43
44    let second = card(
45        CHECK,
46        card_spec("second", case(1)),
47        InteractionId::new(),
48        account.clone(),
49        conversation,
50        turn,
51        at(1),
52    )?;
53    ensure_error(
54        CHECK,
55        "a second blocking card on the same case",
56        interactions.insert(second).await,
57        &StoreError::Conflict,
58    )?;
59
60    let non_blocking = card(
61        CHECK,
62        card_spec("aside", case(1)).non_blocking(),
63        InteractionId::new(),
64        account.clone(),
65        conversation,
66        turn,
67        at(2),
68    )?;
69    ensure_ok(
70        CHECK,
71        "a non-blocking card on the same case does not take the slot",
72        interactions.insert(non_blocking).await,
73    )?;
74
75    let other = card(
76        CHECK,
77        card_spec("other-case", other_case(1)),
78        InteractionId::new(),
79        account.clone(),
80        conversation,
81        turn,
82        at(3),
83    )?;
84    ensure_ok(
85        CHECK,
86        "a blocking card on another case",
87        interactions.insert(other).await,
88    )?;
89
90    let elsewhere = card(
91        CHECK,
92        card_spec("other-tenant", case(1)),
93        InteractionId::new(),
94        other_account(),
95        conversation,
96        turn,
97        at(4),
98    )?;
99    ensure_ok(
100        CHECK,
101        "the slot is per account, so another tenant may hold its own",
102        interactions.insert(elsewhere).await,
103    )?;
104
105    ensure_error(
106        CHECK,
107        "re-inserting the same identifier",
108        interactions.insert(first.clone()).await,
109        &StoreError::Conflict,
110    )?;
111
112    let open = ensure_ok(
113        CHECK,
114        "listing the open cards of the case",
115        interactions.list_open_for_case(&account, &case_key()).await,
116    )?;
117    ensure_eq(CHECK, "open cards on the case", &open.len(), &2)?;
118    ensure_eq(CHECK, "the first open card", &open[0].id, &first.id)
119}
120
121/// Replacing the blocking card invalidates the occupant and mints a new
122/// identifier; a `Resolving` occupant is never replaced (spec §15.6).
123pub async fn check_blocking_interaction_replace(stores: &Stores) -> Result<(), ConformanceFailure> {
124    const CHECK: &str = "check_blocking_interaction_replace";
125    let interactions = stores.interactions();
126    let account = account();
127    let conversation = ConversationId::new();
128    let turn = TurnId::new();
129
130    let first = card(
131        CHECK,
132        card_spec("first", case(1)),
133        InteractionId::new(),
134        account.clone(),
135        conversation,
136        turn,
137        epoch(),
138    )?;
139    ensure_ok(
140        CHECK,
141        "inserting the occupant",
142        interactions.insert(first.clone()).await,
143    )?;
144
145    let replacement = card(
146        CHECK,
147        card_spec("replacement", case(1)),
148        InteractionId::new(),
149        account.clone(),
150        conversation,
151        turn,
152        at(1),
153    )?;
154    let invalidated = ensure_ok(
155        CHECK,
156        "replacing the occupant",
157        interactions
158            .insert_replacing_blocking(replacement.clone())
159            .await,
160    )?;
161    ensure_eq(
162        CHECK,
163        "invalidated occupants",
164        &invalidated,
165        &vec![first.id],
166    )?;
167    ensure(
168        CHECK,
169        replacement.id != first.id,
170        "the replacement must carry a new identifier",
171    )?;
172
173    let old = ensure_ok(
174        CHECK,
175        "reading the replaced card",
176        interactions.get(&account, &first.id).await,
177    )?;
178    ensure_eq(
179        CHECK,
180        "status of the replaced card",
181        &old.status(),
182        &InteractionStatus::Invalidated,
183    )?;
184    ensure_eq(
185        CHECK,
186        "reason recorded on the replaced card",
187        &old.invalidation.map(|record| record.reason),
188        &Some(InvalidationReason::Superseded { by: replacement.id }),
189    )?;
190
191    let current = ensure_ok(
192        CHECK,
193        "reading the replacement",
194        interactions.get(&account, &replacement.id).await,
195    )?;
196    ensure_eq(
197        CHECK,
198        "status of the replacement",
199        &current.status(),
200        &InteractionStatus::Active,
201    )?;
202
203    // A card whose commands are executing must not be swept away underneath them.
204    ensure_ok(
205        CHECK,
206        "starting resolution of the replacement",
207        interactions
208            .begin_resolution(
209                &account,
210                &replacement.id,
211                InteractionStatus::Active,
212                OptionId::from("ack"),
213                turn,
214            )
215            .await,
216    )?;
217    let third = card(
218        CHECK,
219        card_spec("third", case(1)),
220        InteractionId::new(),
221        account.clone(),
222        conversation,
223        turn,
224        at(2),
225    )?;
226    ensure_error(
227        CHECK,
228        "replacing a card that is already resolving",
229        interactions.insert_replacing_blocking(third).await,
230        &StoreError::Conflict,
231    )
232}
233
234/// Another tenant's records are `NotFound`, never a different error
235/// (spec §25.4).
236pub async fn check_cross_tenant_isolation(stores: &Stores) -> Result<(), ConformanceFailure> {
237    const CHECK: &str = "check_cross_tenant_isolation";
238    let account = account();
239    let intruder = other_account();
240    let conversation = ConversationId::new();
241    let turn = TurnId::new();
242
243    ensure_ok(
244        CHECK,
245        "creating the conversation",
246        stores
247            .conversations()
248            .create_conversation(ConversationRecord::new(
249                conversation,
250                account.clone(),
251                epoch(),
252            ))
253            .await,
254    )?;
255    ensure_ok(
256        CHECK,
257        "appending the user turn",
258        stores
259            .conversations()
260            .append_user_turn(user_turn(&account, conversation, turn, epoch()))
261            .await,
262    )?;
263    let interaction = card(
264        CHECK,
265        card_spec("private", case(1)),
266        InteractionId::new(),
267        account.clone(),
268        conversation,
269        turn,
270        epoch(),
271    )?;
272    ensure_ok(
273        CHECK,
274        "inserting the card",
275        stores.interactions().insert(interaction.clone()).await,
276    )?;
277    let command = turnframe_core::ids::CommandId::new();
278    ensure_ok(
279        CHECK,
280        "admitting the command",
281        stores
282            .journal()
283            .begin(journal_entry(&account, turn, command, "isolation", epoch()))
284            .await,
285    )?;
286    ensure_ok(
287        CHECK,
288        "writing the replay record",
289        stores
290            .replay()
291            .put(turnframe_core::replay::ReplayRecord::received(
292                turn,
293                conversation,
294                account.clone(),
295                epoch(),
296            ))
297            .await,
298    )?;
299
300    ensure_error(
301        CHECK,
302        "loading another tenant's conversation",
303        stores
304            .conversations()
305            .load_conversation(&intruder, &conversation)
306            .await,
307        &StoreError::NotFound,
308    )?;
309    ensure_error(
310        CHECK,
311        "loading another tenant's turn",
312        stores.conversations().load_turn(&intruder, &turn).await,
313        &StoreError::NotFound,
314    )?;
315    ensure_error(
316        CHECK,
317        "reading another tenant's phase marker",
318        stores.conversations().turn_phase(&intruder, &turn).await,
319        &StoreError::NotFound,
320    )?;
321    ensure_error(
322        CHECK,
323        "reading another tenant's card",
324        stores.interactions().get(&intruder, &interaction.id).await,
325        &StoreError::NotFound,
326    )?;
327    ensure_error(
328        CHECK,
329        "resolving another tenant's card",
330        stores
331            .interactions()
332            .begin_resolution(
333                &intruder,
334                &interaction.id,
335                InteractionStatus::Active,
336                OptionId::from("ack"),
337                turn,
338            )
339            .await,
340        &StoreError::NotFound,
341    )?;
342    ensure_error(
343        CHECK,
344        "settling another tenant's card",
345        stores
346            .interactions()
347            .finish_resolution(
348                &intruder,
349                &interaction.id,
350                ResolutionOutcome::Failed {
351                    code: "nope".to_owned(),
352                },
353            )
354            .await,
355        &StoreError::NotFound,
356    )?;
357    ensure_error(
358        CHECK,
359        "reading another tenant's journal entry",
360        stores.journal().get(&intruder, &command).await,
361        &StoreError::NotFound,
362    )?;
363    ensure_error(
364        CHECK,
365        "reading another tenant's replay record",
366        stores.replay().get(&intruder, &turn).await,
367        &StoreError::NotFound,
368    )?;
369
370    let listed = ensure_ok(
371        CHECK,
372        "listing another tenant's open cards",
373        stores
374            .interactions()
375            .list_open_for_conversation(&intruder, &conversation)
376            .await,
377    )?;
378    ensure(
379        CHECK,
380        listed.is_empty(),
381        "another tenant must see no cards of this conversation",
382    )?;
383    let entries = ensure_ok(
384        CHECK,
385        "listing another tenant's journal entries",
386        stores.journal().for_turn(&intruder, &turn).await,
387    )?;
388    ensure(
389        CHECK,
390        entries.is_empty(),
391        "another tenant must see no journal entries of this turn",
392    )?;
393
394    // An identifier that never existed answers exactly the same way.
395    ensure_error(
396        CHECK,
397        "reading an identifier that never existed",
398        stores
399            .interactions()
400            .get(&account, &InteractionId::new())
401            .await,
402        &StoreError::NotFound,
403    )
404}
405
406/// Resolution starts only from the status the caller expected.
407pub async fn check_begin_resolution_cas(stores: &Stores) -> Result<(), ConformanceFailure> {
408    const CHECK: &str = "check_begin_resolution_cas";
409    let interactions = stores.interactions();
410    let account = account();
411    let conversation = ConversationId::new();
412    let turn = TurnId::new();
413    let interaction = card(
414        CHECK,
415        card_spec("cas", case(1)),
416        InteractionId::new(),
417        account.clone(),
418        conversation,
419        turn,
420        epoch(),
421    )?;
422    ensure_ok(
423        CHECK,
424        "inserting the card",
425        interactions.insert(interaction.clone()).await,
426    )?;
427
428    ensure_error(
429        CHECK,
430        "expecting Resolving on an Active card",
431        interactions
432            .begin_resolution(
433                &account,
434                &interaction.id,
435                InteractionStatus::Resolving,
436                OptionId::from("ack"),
437                turn,
438            )
439            .await,
440        &StoreError::Conflict,
441    )?;
442    ensure_error(
443        CHECK,
444        "resolving an identifier that does not exist",
445        interactions
446            .begin_resolution(
447                &account,
448                &InteractionId::new(),
449                InteractionStatus::Active,
450                OptionId::from("ack"),
451                turn,
452            )
453            .await,
454        &StoreError::NotFound,
455    )?;
456
457    let record = ensure_ok(
458        CHECK,
459        "starting resolution from Active",
460        interactions
461            .begin_resolution(
462                &account,
463                &interaction.id,
464                InteractionStatus::Active,
465                OptionId::from("ack"),
466                turn,
467            )
468            .await,
469    )?;
470    ensure_eq(
471        CHECK,
472        "status after begin_resolution",
473        &record.status(),
474        &InteractionStatus::Resolving,
475    )?;
476    ensure_eq(
477        CHECK,
478        "option recorded on the card",
479        &record.interaction.resolved_option_id,
480        &Some(OptionId::from("ack")),
481    )?;
482    ensure_eq(
483        CHECK,
484        "turn recorded on the card",
485        &record.resolved_by_turn,
486        &Some(turn),
487    )?;
488
489    // The second click loses the race: it must see it, not silently re-resolve.
490    ensure_error(
491        CHECK,
492        "a second begin_resolution expecting Active",
493        interactions
494            .begin_resolution(
495                &account,
496                &interaction.id,
497                InteractionStatus::Active,
498                OptionId::from("ack"),
499                turn,
500            )
501            .await,
502        &StoreError::Conflict,
503    )?;
504    ensure_error(
505        CHECK,
506        "beginning resolution from Resolving",
507        interactions
508            .begin_resolution(
509                &account,
510                &interaction.id,
511                InteractionStatus::Resolving,
512                OptionId::from("ack"),
513                turn,
514            )
515            .await,
516        &StoreError::Conflict,
517    )
518}
519
520/// Settling the same way twice is accepted; settling differently is a conflict.
521pub async fn check_finish_resolution_idempotent(stores: &Stores) -> Result<(), ConformanceFailure> {
522    const CHECK: &str = "check_finish_resolution_idempotent";
523    let interactions = stores.interactions();
524    let account = account();
525    let conversation = ConversationId::new();
526    let turn = TurnId::new();
527    let events = vec![turnframe_core::ids::EventId::new()];
528
529    let resolved = card(
530        CHECK,
531        card_spec("resolved", case(1)),
532        InteractionId::new(),
533        account.clone(),
534        conversation,
535        turn,
536        epoch(),
537    )?;
538    ensure_ok(
539        CHECK,
540        "inserting the card",
541        interactions.insert(resolved.clone()).await,
542    )?;
543    ensure_ok(
544        CHECK,
545        "starting resolution",
546        interactions
547            .begin_resolution(
548                &account,
549                &resolved.id,
550                InteractionStatus::Active,
551                OptionId::from("ack"),
552                turn,
553            )
554            .await,
555    )?;
556    let settled = ensure_ok(
557        CHECK,
558        "settling the card as Resolved",
559        interactions
560            .finish_resolution(
561                &account,
562                &resolved.id,
563                ResolutionOutcome::Resolved {
564                    event_ids: events.clone(),
565                },
566            )
567            .await,
568    )?;
569    ensure_eq(
570        CHECK,
571        "status after Resolved",
572        &settled.status(),
573        &InteractionStatus::Resolved,
574    )?;
575    ensure_eq(
576        CHECK,
577        "events backing the resolution",
578        &settled.resolution_event_ids,
579        &events,
580    )?;
581
582    let repeat = ensure_ok(
583        CHECK,
584        "settling the same way again",
585        interactions
586            .finish_resolution(
587                &account,
588                &resolved.id,
589                ResolutionOutcome::Resolved {
590                    event_ids: events.clone(),
591                },
592            )
593            .await,
594    )?;
595    ensure_eq(
596        CHECK,
597        "the repeated finish must change nothing",
598        &repeat,
599        &settled,
600    )?;
601    ensure_error(
602        CHECK,
603        "settling as Resolved with different events",
604        interactions
605            .finish_resolution(
606                &account,
607                &resolved.id,
608                ResolutionOutcome::Resolved {
609                    event_ids: vec![turnframe_core::ids::EventId::new()],
610                },
611            )
612            .await,
613        &StoreError::Conflict,
614    )?;
615    ensure_error(
616        CHECK,
617        "settling a resolved card as Failed",
618        interactions
619            .finish_resolution(
620                &account,
621                &resolved.id,
622                ResolutionOutcome::Failed {
623                    code: "late".to_owned(),
624                },
625            )
626            .await,
627        &StoreError::Conflict,
628    )?;
629
630    // RestoreActive puts the card back in the user's hands, clearing the answer.
631    let restored = card(
632        CHECK,
633        card_spec("restored", other_case(1)),
634        InteractionId::new(),
635        account.clone(),
636        conversation,
637        turn,
638        at(1),
639    )?;
640    ensure_ok(
641        CHECK,
642        "inserting the card to restore",
643        interactions.insert(restored.clone()).await,
644    )?;
645    ensure_ok(
646        CHECK,
647        "starting resolution of the card to restore",
648        interactions
649            .begin_resolution(
650                &account,
651                &restored.id,
652                InteractionStatus::Active,
653                OptionId::from("ack"),
654                turn,
655            )
656            .await,
657    )?;
658    let back = ensure_ok(
659        CHECK,
660        "restoring the card",
661        interactions
662            .finish_resolution(&account, &restored.id, ResolutionOutcome::RestoreActive)
663            .await,
664    )?;
665    ensure_eq(
666        CHECK,
667        "status after RestoreActive",
668        &back.status(),
669        &InteractionStatus::Active,
670    )?;
671    ensure_eq(
672        CHECK,
673        "the chosen option is cleared so the card is answerable again",
674        &back.interaction.resolved_option_id,
675        &None,
676    )?;
677    ensure_ok(
678        CHECK,
679        "restoring an already restored card",
680        interactions
681            .finish_resolution(&account, &restored.id, ResolutionOutcome::RestoreActive)
682            .await,
683    )
684    .map(|_| ())
685}
686
687/// A revision change invalidates bound cards and spares independent ones
688/// (spec §15.5).
689pub async fn check_revision_invalidation_respects_independence(
690    stores: &Stores,
691) -> Result<(), ConformanceFailure> {
692    const CHECK: &str = "check_revision_invalidation_respects_independence";
693    let interactions = stores.interactions();
694    let account = account();
695    let conversation = ConversationId::new();
696    let turn = TurnId::new();
697
698    let bound = card(
699        CHECK,
700        card_spec("bound", case(1)),
701        InteractionId::new(),
702        account.clone(),
703        conversation,
704        turn,
705        epoch(),
706    )?;
707    let independent = card(
708        CHECK,
709        card_spec("independent", case(1))
710            .non_blocking()
711            .revision_independent(),
712        InteractionId::new(),
713        account.clone(),
714        conversation,
715        turn,
716        at(1),
717    )?;
718    let current = card(
719        CHECK,
720        card_spec("current", case(2)).non_blocking(),
721        InteractionId::new(),
722        account.clone(),
723        conversation,
724        turn,
725        at(2),
726    )?;
727    let elsewhere = card(
728        CHECK,
729        card_spec("elsewhere", other_case(1)),
730        InteractionId::new(),
731        account.clone(),
732        conversation,
733        turn,
734        at(3),
735    )?;
736    for (what, interaction) in [
737        ("bound", &bound),
738        ("independent", &independent),
739        ("current", &current),
740        ("elsewhere", &elsewhere),
741    ] {
742        ensure_ok(
743            CHECK,
744            &format!("inserting the {what} card"),
745            interactions.insert(interaction.clone()).await,
746        )?;
747    }
748
749    let invalidated = ensure_ok(
750        CHECK,
751        "invalidating the case at revision 2",
752        interactions
753            .invalidate_for_case(
754                &account,
755                &case_key(),
756                turnframe_core::ids::CaseRevision(2),
757                InvalidationReason::RevisionChanged,
758            )
759            .await,
760    )?;
761    ensure_eq(
762        CHECK,
763        "only the card bound to the old revision is invalidated",
764        &invalidated,
765        &vec![bound.id],
766    )?;
767
768    let stale = ensure_ok(
769        CHECK,
770        "reading the invalidated card",
771        interactions.get(&account, &bound.id).await,
772    )?;
773    ensure_eq(
774        CHECK,
775        "status of the invalidated card",
776        &stale.status(),
777        &InteractionStatus::Invalidated,
778    )?;
779    ensure_eq(
780        CHECK,
781        "reason recorded on the invalidated card",
782        &stale.invalidation.as_ref().map(|record| &record.reason),
783        &Some(&InvalidationReason::RevisionChanged),
784    )?;
785    ensure_eq(
786        CHECK,
787        "the revision that made the card stale",
788        &stale.invalidation.and_then(|record| record.new_revision),
789        &Some(turnframe_core::ids::CaseRevision(2)),
790    )?;
791
792    for (what, id) in [
793        ("revision-independent", independent.id),
794        ("already current", current.id),
795        ("on another case", elsewhere.id),
796    ] {
797        let survivor = ensure_ok(
798            CHECK,
799            &format!("reading the {what} card"),
800            interactions.get(&account, &id).await,
801        )?;
802        ensure_eq(
803            CHECK,
804            &format!("status of the {what} card"),
805            &survivor.status(),
806            &InteractionStatus::Active,
807        )?;
808    }
809    Ok(())
810}
811
812/// A card the user answered is remembered until the case moves.
813///
814/// A `ConfirmCommand` must offer a way to decline, and declining ends the card
815/// without effect: nothing is written and the case does not move. So the
816/// projection declares the same requirement, the same card goes back up, and
817/// the user who pressed "not now" is asked again. The runtime does not re-raise
818/// a requirement whose card was answered at the revision the case is still on,
819/// and this is the query that lets it know.
820pub async fn check_answered_blocking_card_is_remembered(
821    stores: &Stores,
822) -> Result<(), ConformanceFailure> {
823    const CHECK: &str = "check_answered_blocking_card_is_remembered";
824    let interactions = stores.interactions();
825    let account = account();
826    let conversation = ConversationId::new();
827    let turn = TurnId::new();
828    let key = case(1).key();
829
830    ensure_eq(
831        CHECK,
832        "nothing has been answered before anything exists",
833        &ensure_ok(
834            CHECK,
835            "asking about a case with no cards",
836            interactions
837                .blocking_answered_at(&account, &key, CaseRevision(1))
838                .await,
839        )?,
840        &false,
841    )?;
842
843    let interaction = card(
844        CHECK,
845        card_spec("send_confirmation", case(1)),
846        InteractionId::new(),
847        account.clone(),
848        conversation,
849        turn,
850        epoch(),
851    )?;
852    ensure_ok(
853        CHECK,
854        "inserting the card",
855        interactions.insert(interaction.clone()).await,
856    )?;
857    ensure_eq(
858        CHECK,
859        "a card on screen has not been answered",
860        &ensure_ok(
861            CHECK,
862            "asking about an active card",
863            interactions
864                .blocking_answered_at(&account, &key, CaseRevision(1))
865                .await,
866        )?,
867        &false,
868    )?;
869
870    ensure_ok(
871        CHECK,
872        "beginning the resolution",
873        interactions
874            .begin_resolution(
875                &account,
876                &interaction.id,
877                InteractionStatus::Active,
878                OptionId::from("not_now"),
879                turn,
880            )
881            .await,
882    )?;
883    ensure_ok(
884        CHECK,
885        "settling it with nothing committed, which is what declining is",
886        interactions
887            .finish_resolution(
888                &account,
889                &interaction.id,
890                ResolutionOutcome::Resolved {
891                    event_ids: Vec::new(),
892                },
893            )
894            .await,
895    )?;
896
897    ensure_eq(
898        CHECK,
899        "the answer is remembered at the revision it was given at",
900        &ensure_ok(
901            CHECK,
902            "asking after the answer",
903            interactions
904                .blocking_answered_at(&account, &key, CaseRevision(1))
905                .await,
906        )?,
907        &true,
908    )?;
909    ensure_eq(
910        CHECK,
911        "and the question is open again once the case has moved",
912        &ensure_ok(
913            CHECK,
914            "asking at the next revision",
915            interactions
916                .blocking_answered_at(&account, &key, CaseRevision(2))
917                .await,
918        )?,
919        &false,
920    )?;
921    Ok(())
922}
923
924/// Expiry moves `Active` cards whose deadline has passed, and only those.
925pub async fn check_interaction_expiry(stores: &Stores) -> Result<(), ConformanceFailure> {
926    const CHECK: &str = "check_interaction_expiry";
927    let interactions = stores.interactions();
928    let account = account();
929    let conversation = ConversationId::new();
930    let turn = TurnId::new();
931
932    let expiring = card(
933        CHECK,
934        card_spec("expiring", case(1)).expires_in(ttl(60)),
935        InteractionId::new(),
936        account.clone(),
937        conversation,
938        turn,
939        epoch(),
940    )?;
941    let permanent = card(
942        CHECK,
943        card_spec("permanent", case(1)).non_blocking(),
944        InteractionId::new(),
945        account.clone(),
946        conversation,
947        turn,
948        epoch(),
949    )?;
950    ensure_ok(
951        CHECK,
952        "inserting the expiring card",
953        interactions.insert(expiring.clone()).await,
954    )?;
955    ensure_ok(
956        CHECK,
957        "inserting the card without a deadline",
958        interactions.insert(permanent.clone()).await,
959    )?;
960
961    let early = ensure_ok(
962        CHECK,
963        "sweeping before the deadline",
964        interactions.expire_due(at(30)).await,
965    )?;
966    ensure(
967        CHECK,
968        early.is_empty(),
969        "a card must not expire before its deadline",
970    )?;
971
972    let due = ensure_ok(
973        CHECK,
974        "sweeping at the deadline",
975        interactions.expire_due(at(60)).await,
976    )?;
977    ensure_eq(
978        CHECK,
979        "cards expired by the sweep",
980        &due,
981        &vec![expiring.id],
982    )?;
983    let expired = ensure_ok(
984        CHECK,
985        "reading the expired card",
986        interactions.get(&account, &expiring.id).await,
987    )?;
988    ensure_eq(
989        CHECK,
990        "status after expiry",
991        &expired.status(),
992        &InteractionStatus::Expired,
993    )?;
994    let kept = ensure_ok(
995        CHECK,
996        "reading the card without a deadline",
997        interactions.get(&account, &permanent.id).await,
998    )?;
999    ensure_eq(
1000        CHECK,
1001        "a card without a deadline is untouched",
1002        &kept.status(),
1003        &InteractionStatus::Active,
1004    )?;
1005
1006    let again = ensure_ok(
1007        CHECK,
1008        "sweeping a second time",
1009        interactions.expire_due(at(600)).await,
1010    )?;
1011    ensure(
1012        CHECK,
1013        again.is_empty(),
1014        "a card must expire once, not on every sweep",
1015    )
1016}
1017
1018/// An operator can withdraw a card for a reason the revision does not describe
1019/// (spec §15.5).
1020///
1021/// Revision-driven invalidation cannot express this: it fires only for a card
1022/// bound to a revision the case has left, so a decision that has nothing to do
1023/// with the revision — a workflow rolled back to a version that cannot compile
1024/// the option the card offers, an account suspended, a card withdrawn — would
1025/// have no way to run, and the honest step would be to leave the user holding
1026/// an option nothing will honour.
1027///
1028/// A card mid-resolution is deliberately spared: a command it authorized is in
1029/// flight, and taking the card away underneath it would settle nothing while
1030/// making the outcome unattributable.
1031pub async fn check_administrative_invalidation_ignores_the_revision(
1032    stores: &Stores,
1033) -> Result<(), ConformanceFailure> {
1034    const CHECK: &str = "check_administrative_invalidation_ignores_the_revision";
1035    let interactions = stores.interactions();
1036    let account = account();
1037    let case_ref = case(4);
1038    let conversation = ConversationId::new();
1039
1040    // Two cards on the same case at its current revision: the blocking one the
1041    // case is waiting on, and a non-blocking one that declares itself
1042    // independent of the revision. Neither is reachable by the revision-driven
1043    // path, and only one may block, which is the rule that makes the second
1044    // non-blocking rather than a second blocking card.
1045    let plain_id = InteractionId::new();
1046    let independent_id = InteractionId::new();
1047    let plain = card(
1048        CHECK,
1049        card_spec("administrative.plain", case_ref.clone()),
1050        plain_id,
1051        account.clone(),
1052        conversation,
1053        TurnId::new(),
1054        epoch(),
1055    )?;
1056    let independent = card(
1057        CHECK,
1058        card_spec("administrative.independent", case_ref.clone())
1059            .non_blocking()
1060            .revision_independent(),
1061        independent_id,
1062        account.clone(),
1063        conversation,
1064        TurnId::new(),
1065        at(1),
1066    )?;
1067    ensure_ok(
1068        CHECK,
1069        "writing a card at the current revision",
1070        interactions.insert(plain).await,
1071    )?;
1072    ensure_ok(
1073        CHECK,
1074        "writing a revision-independent card",
1075        interactions.insert(independent).await,
1076    )?;
1077
1078    let by_revision = ensure_ok(
1079        CHECK,
1080        "sweeping by revision at the revision the cards are bound to",
1081        interactions
1082            .invalidate_for_case(
1083                &account,
1084                &case_ref.key(),
1085                case_ref.expected_revision,
1086                InvalidationReason::RevisionChanged,
1087            )
1088            .await,
1089    )?;
1090    ensure(
1091        CHECK,
1092        by_revision.is_empty(),
1093        "the revision-driven sweep finds nothing while the case has not moved",
1094    )?;
1095
1096    let withdrawn = ensure_ok(
1097        CHECK,
1098        "withdrawing the case's cards administratively",
1099        interactions
1100            .invalidate_case_cards(
1101                &account,
1102                &case_ref.key(),
1103                InvalidationReason::Administrative {
1104                    code: String::from("conformance.withdrawn"),
1105                },
1106            )
1107            .await,
1108    )?;
1109    ensure(
1110        CHECK,
1111        withdrawn.contains(&plain_id) && withdrawn.contains(&independent_id),
1112        "both cards are withdrawn, the revision-independent one included",
1113    )?;
1114
1115    for id in [plain_id, independent_id] {
1116        let record = ensure_ok(
1117            CHECK,
1118            "reading a withdrawn card",
1119            interactions.get(&account, &id).await,
1120        )?;
1121        ensure_eq(
1122            CHECK,
1123            "status after an administrative withdrawal",
1124            &record.interaction.status,
1125            &InteractionStatus::Invalidated,
1126        )?;
1127    }
1128
1129    Ok(())
1130}