1use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17use crate::case::{CaseKey, CaseRef};
18use crate::hash::{Digest, HashError, canonical_digest, derive_uuid};
19use crate::ids::{AccountId, BatchId, CommandId, InteractionId, TurnId};
20use crate::interaction::{ActionClass, InteractionKind};
21use crate::turn::ActorContext;
22use crate::understanding::ActId;
23
24#[derive(
30 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
31)]
32#[serde(rename_all = "snake_case")]
33pub enum ResolutionChannel {
34 Click,
36 ModelInterpreted,
39}
40
41impl ResolutionChannel {
42 pub const ALL: [Self; 2] = [Self::Click, Self::ModelInterpreted];
44
45 #[must_use]
48 pub fn is_deterministic(self) -> bool {
49 matches!(self, Self::Click)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59#[non_exhaustive]
60pub enum CommandOrigin {
61 DirectSafeUserAct {
63 evidence_digest: Digest,
65 },
66 ConfirmedInteraction {
73 interaction_id: InteractionId,
75 payload_hash: Digest,
77 interaction_kind: InteractionKind,
79 action_class: ActionClass,
81 channel: ResolutionChannel,
83 },
84 InternalPolicy {
87 policy_key: String,
89 },
90 ExternalCallback {
92 callback_id: String,
94 signature_verified: bool,
96 },
97}
98
99impl CommandOrigin {
100 #[must_use]
107 pub fn is_trusted(&self) -> bool {
108 match self {
109 Self::DirectSafeUserAct { .. } => false,
110 Self::ConfirmedInteraction {
111 action_class,
112 channel,
113 ..
114 } => action_class.authorizes_commands() && channel.is_deterministic(),
115 Self::InternalPolicy { .. } => true,
116 Self::ExternalCallback {
117 signature_verified, ..
118 } => *signature_verified,
119 }
120 }
121
122 #[must_use]
125 fn confirming_kind(&self) -> Option<InteractionKind> {
126 match self {
127 Self::ConfirmedInteraction {
128 interaction_kind,
129 action_class,
130 channel,
131 ..
132 } if action_class.authorizes_commands() && channel.is_deterministic() => {
133 Some(*interaction_kind)
134 }
135 _ => None,
136 }
137 }
138
139 #[must_use]
141 fn is_verified_callback(&self) -> bool {
142 matches!(
143 self,
144 Self::ExternalCallback {
145 signature_verified: true,
146 ..
147 }
148 )
149 }
150
151 #[must_use]
168 pub fn satisfies_confirmation(&self, confirmation: ConfirmationPolicy) -> bool {
169 use ConfirmationPolicy as Policy;
170 use InteractionKind as Kind;
171 match confirmation {
172 Policy::None => true,
173 Policy::ReviewCard | Policy::ExplicitClick => matches!(
174 self.confirming_kind(),
175 Some(Kind::ConfirmCommand | Kind::ReviewChanges)
176 ),
177 Policy::Reauthentication => {
178 self.confirming_kind() == Some(Kind::Reauthenticate) || self.is_verified_callback()
179 }
180 Policy::QualifiedSignature => {
181 self.confirming_kind() == Some(Kind::ExternalSignature)
182 || self.is_verified_callback()
183 }
184 Policy::HumanProfessionalReview => {
185 matches!(self, Self::InternalPolicy { .. }) || self.is_verified_callback()
186 }
187 }
188 }
189}
190
191#[derive(
196 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
197)]
198#[serde(rename_all = "snake_case")]
199pub enum RiskClass {
200 ReadOnly,
202 ReversibleLowRisk,
204 SensitiveDataChange,
206 Destructive,
208 Irreversible,
210 ExternalRegulated,
212}
213
214impl RiskClass {
215 #[must_use]
220 pub const fn conservative() -> Self {
221 Self::Irreversible
222 }
223
224 #[must_use]
227 pub fn needs_trusted_origin(self) -> bool {
228 self > Self::ReversibleLowRisk
229 }
230}
231
232#[derive(
237 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
238)]
239#[serde(rename_all = "snake_case")]
240#[non_exhaustive]
241pub enum ConfirmationPolicy {
242 None,
244 ReviewCard,
246 ExplicitClick,
248 Reauthentication,
250 QualifiedSignature,
252 HumanProfessionalReview,
255}
256
257impl ConfirmationPolicy {
258 #[must_use]
266 pub fn interaction_kind(self) -> Option<InteractionKind> {
267 match self {
268 Self::None | Self::HumanProfessionalReview => None,
269 Self::ReviewCard => Some(InteractionKind::ReviewChanges),
270 Self::ExplicitClick => Some(InteractionKind::ConfirmCommand),
271 Self::Reauthentication => Some(InteractionKind::Reauthenticate),
272 Self::QualifiedSignature => Some(InteractionKind::ExternalSignature),
273 }
274 }
275
276 #[must_use]
278 pub fn is_server_side_only(self) -> bool {
279 matches!(self, Self::HumanProfessionalReview)
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
288#[serde(tag = "kind", rename_all = "snake_case")]
289#[non_exhaustive]
290pub enum AtomicityScope {
291 PerCommand,
293 PerCase,
295 ExplicitGroup {
297 group: String,
299 },
300 ExternalSaga {
302 saga: String,
304 },
305}
306
307impl AtomicityScope {
308 #[must_use]
310 pub fn discriminant(&self) -> &'static str {
311 match self {
312 Self::PerCommand => "per_command",
313 Self::PerCase => "per_case",
314 Self::ExplicitGroup { .. } => "explicit_group",
315 Self::ExternalSaga { .. } => "external_saga",
316 }
317 }
318
319 #[must_use]
321 pub fn group_name(&self) -> Option<&str> {
322 match self {
323 Self::PerCommand | Self::PerCase => None,
324 Self::ExplicitGroup { group } => Some(group),
325 Self::ExternalSaga { saga } => Some(saga),
326 }
327 }
328}
329
330#[derive(
332 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
333)]
334#[serde(rename_all = "snake_case")]
335pub enum ClaimMode {
336 ServerReceiptOnly,
338 EventReferencedParaphrase,
340 FreeExplanation,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
346pub struct CommandPolicy {
347 pub risk: RiskClass,
349 pub confirmation: ConfirmationPolicy,
351 pub atomicity: AtomicityScope,
353 pub claim_mode: ClaimMode,
355}
356
357impl CommandPolicy {
358 #[must_use]
361 pub fn conservative() -> Self {
362 Self {
363 risk: RiskClass::Irreversible,
364 confirmation: ConfirmationPolicy::ExplicitClick,
365 atomicity: AtomicityScope::PerCase,
366 claim_mode: ClaimMode::ServerReceiptOnly,
367 }
368 }
369
370 #[must_use]
372 pub fn read_only() -> Self {
373 Self {
374 risk: RiskClass::ReadOnly,
375 confirmation: ConfirmationPolicy::None,
376 atomicity: AtomicityScope::PerCommand,
377 claim_mode: ClaimMode::FreeExplanation,
378 }
379 }
380
381 #[must_use]
384 pub fn low_risk() -> Self {
385 Self {
386 risk: RiskClass::ReversibleLowRisk,
387 confirmation: ConfirmationPolicy::None,
388 atomicity: AtomicityScope::PerCase,
389 claim_mode: ClaimMode::EventReferencedParaphrase,
390 }
391 }
392
393 #[must_use]
397 pub fn requires_trusted_origin(&self) -> bool {
398 self.risk > RiskClass::ReversibleLowRisk || self.confirmation != ConfirmationPolicy::None
399 }
400}
401
402impl Default for CommandPolicy {
403 fn default() -> Self {
404 Self::conservative()
405 }
406}
407
408#[must_use]
443pub fn origin_satisfies(origin: &CommandOrigin, policy: &CommandPolicy) -> bool {
444 if policy.risk.needs_trusted_origin() && !origin.is_trusted() {
445 return false;
446 }
447 origin.satisfies_confirmation(policy.confirmation)
448}
449
450const COMMAND_ID_DOMAIN: &str = "turnframe.command_id.v2";
452
453const BATCH_ID_DOMAIN: &str = "turnframe.batch_id.v1";
455
456impl CommandId {
457 #[must_use]
465 pub fn derive(turn_id: &TurnId, act: ActId, position: usize) -> Self {
466 Self(derive_uuid(
467 COMMAND_ID_DOMAIN,
468 &[
469 &turn_id.to_string(),
470 &act.to_string(),
471 &position.to_string(),
472 ],
473 ))
474 }
475}
476
477impl BatchId {
478 #[must_use]
483 pub fn derive(turn_id: &TurnId, case_key: &CaseKey, scope: &AtomicityScope) -> Self {
484 Self(derive_uuid(
485 BATCH_ID_DOMAIN,
486 &[
487 &turn_id.to_string(),
488 case_key.workflow.as_str(),
489 case_key.case_id.as_str(),
490 scope.discriminant(),
491 scope.group_name().unwrap_or(""),
492 ],
493 ))
494 }
495}
496
497#[derive(
499 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
500)]
501#[serde(transparent)]
502pub struct IdempotencyKey(pub String);
503
504const IDEMPOTENCY_DOMAIN: &str = "turnframe.idempotency.v1";
507
508#[derive(Serialize)]
509struct IdempotencyInput<'a> {
510 domain: &'static str,
511 account: &'a AccountId,
512 turn_id: &'a TurnId,
513 case_ref: &'a CaseRef,
514 origin: &'a CommandOrigin,
515 command: &'a serde_json::Value,
516}
517
518impl IdempotencyKey {
519 pub fn derive(
556 account: &AccountId,
557 turn_id: &TurnId,
558 case_ref: &CaseRef,
559 origin: &CommandOrigin,
560 command: &serde_json::Value,
561 ) -> Result<Self, HashError> {
562 let input = IdempotencyInput {
563 domain: IDEMPOTENCY_DOMAIN,
564 account,
565 turn_id,
566 case_ref,
567 origin,
568 command,
569 };
570 canonical_digest(&input).map(|digest| Self(digest.0))
571 }
572
573 #[must_use]
575 pub fn new(value: impl Into<String>) -> Self {
576 Self(value.into())
577 }
578
579 #[must_use]
581 pub fn as_str(&self) -> &str {
582 &self.0
583 }
584}
585
586impl std::fmt::Display for IdempotencyKey {
587 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588 f.write_str(&self.0)
589 }
590}
591
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct CommandEnvelope<C> {
595 pub command_id: CommandId,
597 pub turn_id: TurnId,
599 pub actor: ActorContext,
601 pub case_ref: CaseRef,
603 pub idempotency_key: IdempotencyKey,
605 pub origin: CommandOrigin,
607 pub command: C,
609}
610
611impl<C> CommandEnvelope<C> {
612 #[must_use]
614 pub fn account_id(&self) -> &AccountId {
615 &self.actor.account_id
616 }
617
618 pub fn try_map_command<D, E>(
620 self,
621 f: impl FnOnce(C) -> Result<D, E>,
622 ) -> Result<CommandEnvelope<D>, E> {
623 Ok(CommandEnvelope {
624 command_id: self.command_id,
625 turn_id: self.turn_id,
626 actor: self.actor,
627 case_ref: self.case_ref,
628 idempotency_key: self.idempotency_key,
629 origin: self.origin,
630 command: f(self.command)?,
631 })
632 }
633}
634
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
637pub struct CommandBatch<C> {
638 pub batch_id: BatchId,
640 pub scope: AtomicityScope,
642 pub envelopes: Vec<CommandEnvelope<C>>,
644}
645
646impl<C> CommandBatch<C> {
647 #[must_use]
649 pub fn len(&self) -> usize {
650 self.envelopes.len()
651 }
652
653 #[must_use]
655 pub fn is_empty(&self) -> bool {
656 self.envelopes.is_empty()
657 }
658
659 #[must_use]
662 pub fn is_single_case(&self) -> bool {
663 match self.envelopes.split_first() {
664 None => true,
665 Some((first, rest)) => rest.iter().all(|e| e.case_ref.same_case(&first.case_ref)),
666 }
667 }
668
669 pub fn try_map<D, E>(self, mut f: impl FnMut(C) -> Result<D, E>) -> Result<CommandBatch<D>, E> {
671 let mut envelopes = Vec::with_capacity(self.envelopes.len());
672 for envelope in self.envelopes {
673 envelopes.push(envelope.try_map_command(&mut f)?);
674 }
675 Ok(CommandBatch {
676 batch_id: self.batch_id,
677 scope: self.scope,
678 envelopes,
679 })
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use crate::ids::CaseRevision;
687 use crate::understanding::UnitId;
688
689 fn card(kind: InteractionKind, action_class: ActionClass) -> CommandOrigin {
690 CommandOrigin::ConfirmedInteraction {
691 interaction_id: InteractionId::nil(),
692 payload_hash: Digest::of_bytes(b"p"),
693 interaction_kind: kind,
694 action_class,
695 channel: ResolutionChannel::Click,
696 }
697 }
698
699 fn confirmed() -> CommandOrigin {
700 card(
701 InteractionKind::ConfirmCommand,
702 ActionClass::ConfirmsCommands,
703 )
704 }
705
706 fn direct() -> CommandOrigin {
707 CommandOrigin::DirectSafeUserAct {
708 evidence_digest: Digest::of_bytes(b"e"),
709 }
710 }
711
712 fn internal() -> CommandOrigin {
713 CommandOrigin::InternalPolicy {
714 policy_key: "auto".into(),
715 }
716 }
717
718 fn callback(signature_verified: bool) -> CommandOrigin {
719 CommandOrigin::ExternalCallback {
720 callback_id: "c".into(),
721 signature_verified,
722 }
723 }
724
725 fn with_confirmation(confirmation: ConfirmationPolicy) -> CommandPolicy {
726 CommandPolicy {
727 confirmation,
728 ..CommandPolicy::conservative()
729 }
730 }
731
732 #[test]
733 fn conservative_is_default_and_requires_trust() {
734 assert_eq!(CommandPolicy::default(), CommandPolicy::conservative());
735 assert!(CommandPolicy::conservative().requires_trusted_origin());
736 assert!(!CommandPolicy::low_risk().requires_trusted_origin());
737 assert!(!CommandPolicy::read_only().requires_trusted_origin());
738 }
739
740 #[test]
741 fn origin_satisfies_rules() {
742 assert!(origin_satisfies(&direct(), &CommandPolicy::low_risk()));
743 assert!(!origin_satisfies(&direct(), &CommandPolicy::conservative()));
744 assert!(origin_satisfies(
745 &confirmed(),
746 &CommandPolicy::conservative()
747 ));
748 let mut low_with_review = CommandPolicy::low_risk();
749 low_with_review.confirmation = ConfirmationPolicy::ReviewCard;
750 assert!(!origin_satisfies(&direct(), &low_with_review));
751 assert!(!origin_satisfies(
752 &callback(false),
753 &CommandPolicy::conservative()
754 ));
755 assert!(!origin_satisfies(
758 &callback(true),
759 &CommandPolicy::conservative()
760 ));
761 assert!(origin_satisfies(
762 &callback(true),
763 &with_confirmation(ConfirmationPolicy::QualifiedSignature)
764 ));
765 assert!(!origin_satisfies(
766 &internal(),
767 &CommandPolicy::conservative()
768 ));
769 assert!(origin_satisfies(
770 &internal(),
771 &with_confirmation(ConfirmationPolicy::HumanProfessionalReview)
772 ));
773 }
774
775 #[test]
776 fn answering_a_selection_card_confirms_nothing() {
777 let selection = card(InteractionKind::SelectTarget, ActionClass::NoCommands);
780 assert!(!selection.is_trusted());
781 assert!(!origin_satisfies(
782 &selection,
783 &CommandPolicy::conservative()
784 ));
785 assert!(origin_satisfies(&selection, &CommandPolicy::low_risk()));
786 let dismissed = card(InteractionKind::ConfirmCommand, ActionClass::NoCommands);
787 assert!(!origin_satisfies(
788 &dismissed,
789 &CommandPolicy::conservative()
790 ));
791 }
792
793 #[test]
794 fn each_confirmation_policy_accepts_only_its_own_authority() {
795 use ConfirmationPolicy as P;
796 use InteractionKind as K;
797 let cases: &[(P, &[K])] = &[
798 (P::ReviewCard, &[K::ConfirmCommand, K::ReviewChanges]),
799 (P::ExplicitClick, &[K::ConfirmCommand, K::ReviewChanges]),
800 (P::Reauthentication, &[K::Reauthenticate]),
801 (P::QualifiedSignature, &[K::ExternalSignature]),
802 (P::HumanProfessionalReview, &[]),
803 ];
804 let every_kind = [
805 K::Boolean,
806 K::SingleSelect,
807 K::MultiSelect,
808 K::Freeform,
809 K::ReviewChanges,
810 K::ConfirmCommand,
811 K::SelectTarget,
812 K::ResolveValidationError,
813 K::Reauthenticate,
814 K::ExternalSignature,
815 ];
816 for (confirmation, accepted) in cases {
817 let policy = with_confirmation(*confirmation);
818 for kind in every_kind {
819 let origin = card(kind, ActionClass::ConfirmsCommands);
820 assert_eq!(
821 origin_satisfies(&origin, &policy),
822 accepted.contains(&kind),
823 "{confirmation:?} vs {kind:?}"
824 );
825 }
826 }
827 }
828
829 #[test]
830 fn human_professional_review_is_not_the_users_own_click() {
831 let policy = with_confirmation(ConfirmationPolicy::HumanProfessionalReview);
832 assert!(!origin_satisfies(&confirmed(), &policy));
833 assert_eq!(
834 ConfirmationPolicy::HumanProfessionalReview.interaction_kind(),
835 None
836 );
837 assert!(ConfirmationPolicy::HumanProfessionalReview.is_server_side_only());
838 assert!(origin_satisfies(&internal(), &policy));
839 assert!(origin_satisfies(&callback(true), &policy));
840 assert!(!origin_satisfies(&callback(false), &policy));
841 }
842
843 #[test]
844 fn model_interpreted_answers_never_authorize_above_low_risk() {
845 let interpreted = CommandOrigin::ConfirmedInteraction {
846 interaction_id: InteractionId::nil(),
847 payload_hash: Digest::of_bytes(b"p"),
848 interaction_kind: InteractionKind::ConfirmCommand,
849 action_class: ActionClass::ConfirmsCommands,
850 channel: ResolutionChannel::ModelInterpreted,
851 };
852 assert!(!interpreted.is_trusted());
853 assert!(!origin_satisfies(
854 &interpreted,
855 &CommandPolicy::conservative()
856 ));
857 assert!(origin_satisfies(&interpreted, &CommandPolicy::low_risk()));
858 let mut low_but_confirmed = CommandPolicy::low_risk();
859 low_but_confirmed.confirmation = ConfirmationPolicy::ExplicitClick;
860 assert!(!origin_satisfies(&interpreted, &low_but_confirmed));
861 }
862
863 #[test]
864 fn derived_ids_are_deterministic_and_positional() {
865 let turn = TurnId::nil();
866 let (first, second) = (ActId::new(UnitId(1), 1), ActId::new(UnitId(2), 1));
867 let a = CommandId::derive(&turn, first, 0);
868 assert_eq!(a, CommandId::derive(&turn, first, 0));
869 assert_ne!(a, CommandId::derive(&turn, first, 1));
870 assert_ne!(a, CommandId::derive(&turn, second, 0));
871 assert_ne!(a, CommandId::derive(&TurnId::new(), first, 0));
872 let key = CaseKey::new("trip", "i1");
873 let b = BatchId::derive(&turn, &key, &AtomicityScope::PerCase);
874 assert_eq!(b, BatchId::derive(&turn, &key, &AtomicityScope::PerCase));
875 assert_ne!(b, BatchId::derive(&turn, &key, &AtomicityScope::PerCommand));
876 assert_ne!(
877 b,
878 BatchId::derive(&turn, &CaseKey::new("trip", "i2"), &AtomicityScope::PerCase)
879 );
880 assert_ne!(
881 BatchId::derive(
882 &turn,
883 &key,
884 &AtomicityScope::ExplicitGroup { group: "a".into() }
885 ),
886 BatchId::derive(
887 &turn,
888 &key,
889 &AtomicityScope::ExplicitGroup { group: "b".into() }
890 )
891 );
892 }
893
894 #[test]
895 fn risk_ordering_matches_declaration() {
896 assert!(RiskClass::ReadOnly < RiskClass::ReversibleLowRisk);
897 assert!(RiskClass::Irreversible < RiskClass::ExternalRegulated);
898 }
899
900 #[test]
901 fn idempotency_key_is_deterministic() {
902 let account = AccountId::from("acct");
903 let turn = TurnId::nil();
904 let case_ref = CaseRef::new("trip", "i1", CaseRevision(1));
905 let cmd = serde_json::json!({"set_subject": {"value": "x"}});
906 let a = IdempotencyKey::derive(&account, &turn, &case_ref, &direct(), &cmd).unwrap();
907 let b = IdempotencyKey::derive(&account, &turn, &case_ref, &direct(), &cmd).unwrap();
908 assert_eq!(a, b);
909 let other_rev = case_ref.with_revision(CaseRevision(2));
910 let c = IdempotencyKey::derive(&account, &turn, &other_rev, &direct(), &cmd).unwrap();
911 assert_ne!(a, c);
912 }
913}