Skip to main content

turnframe_core/
command.rs

1//! Typed command envelopes, origins, risk and confirmation policy (spec §14).
2//!
3//! # Why there is no model-proposal origin
4//!
5//! [`CommandOrigin`] deliberately has no variant for "the model proposed it".
6//! A model output is a proposal (I9); it becomes a command only after evidence
7//! validation, target resolution and reduction. When that pipeline produces a
8//! low-risk command straight from the user's words, the origin is
9//! [`CommandOrigin::DirectSafeUserAct`] carrying the digest of the *validated*
10//! evidence, so the authority is the user's text, not the model. Anything more
11//! consequential needs a server-issued origin: a confirmed interaction, an
12//! internal policy, or a verified external callback (I12).
13
14use 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/// How a user's answer to an interaction reached the server (spec §15.7).
25///
26/// The channel is part of the authorization record: a "yes" the interpreter
27/// inferred from prose is not the same authority as a click, and I20 requires
28/// the replay to say which one happened.
29#[derive(
30    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
31)]
32#[serde(rename_all = "snake_case")]
33pub enum ResolutionChannel {
34    /// The client posted the stored option id (a click on the card).
35    Click,
36    /// Understanding read the option from typed text. This channel may never
37    /// authorize anything above [`RiskClass::ReversibleLowRisk`].
38    ModelInterpreted,
39}
40
41impl ResolutionChannel {
42    /// Every channel, ordered from strongest to weakest authority.
43    pub const ALL: [Self; 2] = [Self::Click, Self::ModelInterpreted];
44
45    /// Returns `true` for a channel the user drove deterministically: a click, never
46    /// a reading.
47    #[must_use]
48    pub fn is_deterministic(self) -> bool {
49        matches!(self, Self::Click)
50    }
51}
52
53/// Who or what authorized a command (spec §14.2).
54///
55/// The enum grows as new authorization sources appear, so downstream matches
56/// need a wildcard arm.
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59#[non_exhaustive]
60pub enum CommandOrigin {
61    /// A low-risk act grounded directly in validated user evidence.
62    DirectSafeUserAct {
63        /// Digest of the validated evidence set.
64        evidence_digest: Digest,
65    },
66    /// A stored option of a persisted interaction was chosen.
67    ///
68    /// The three qualifying fields answer *what* was confirmed: the shape of
69    /// the card, what its chosen option authorizes, and how the answer arrived.
70    /// Without them a click on a "which trip did you mean?" card would be
71    /// indistinguishable from a qualified signature (I12).
72    ConfirmedInteraction {
73        /// The interaction.
74        interaction_id: InteractionId,
75        /// Hash of the payload the user saw.
76        payload_hash: Digest,
77        /// Shape of the card that was answered.
78        interaction_kind: InteractionKind,
79        /// What the chosen option authorizes.
80        action_class: ActionClass,
81        /// How the answer reached the server.
82        channel: ResolutionChannel,
83    },
84    /// A server-side policy decided the command (e.g. automatic follow-up, or
85    /// a qualified professional's review recorded out of band).
86    InternalPolicy {
87        /// Key of the policy.
88        policy_key: String,
89    },
90    /// An external system called back.
91    ExternalCallback {
92        /// Callback identifier.
93        callback_id: String,
94        /// Whether the signature was verified. Unverified callbacks are untrusted.
95        signature_verified: bool,
96    },
97}
98
99impl CommandOrigin {
100    /// Returns `true` when the origin may authorize commands above
101    /// [`RiskClass::ReversibleLowRisk`] (I12).
102    ///
103    /// A confirmed interaction is trusted only when the chosen option actually
104    /// authorizes commands and the answer did not come from an inference: a
105    /// dismissed card and a model-interpreted "yes" are both untrusted.
106    #[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    /// The interaction kind behind a confirmed origin, when the option
123    /// authorizes commands.
124    #[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    /// Returns `true` for an external callback whose signature was verified.
140    #[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    /// Returns `true` when this origin is the *specific* authorization
152    /// [`ConfirmationPolicy`] demands (spec §14.3, §15.7).
153    ///
154    /// The mapping is deliberately narrow, because a confirmation is a
155    /// statement about one act by one authority:
156    ///
157    /// | Policy | Accepted origin |
158    /// | --- | --- |
159    /// | `None` | any |
160    /// | `ReviewCard`, `ExplicitClick` | a `ConfirmCommand` or `ReviewChanges` card whose chosen option authorizes commands |
161    /// | `Reauthentication` | a `Reauthenticate` card, or a verified external callback |
162    /// | `QualifiedSignature` | an `ExternalSignature` card, or a verified external callback |
163    /// | `HumanProfessionalReview` | an internal policy or a verified external callback — never the end user's own click |
164    ///
165    /// A [`ResolutionChannel::ModelInterpreted`] answer satisfies no policy but
166    /// [`ConfirmationPolicy::None`].
167    #[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/// Risk class of a command, ordered from harmless to regulated (spec §14.3).
192///
193/// Deliberately exhaustive: the ladder is a closed, ordered vocabulary and
194/// callers compare against it rather than match it open-endedly.
195#[derive(
196    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
197)]
198#[serde(rename_all = "snake_case")]
199pub enum RiskClass {
200    /// No mutation.
201    ReadOnly,
202    /// Easily reversible, low impact.
203    ReversibleLowRisk,
204    /// Changes sensitive data.
205    SensitiveDataChange,
206    /// Deletes or destroys data.
207    Destructive,
208    /// Cannot be undone.
209    Irreversible,
210    /// An external effect another party decides (an airline, a bank, a regulator).
211    ExternalRegulated,
212}
213
214impl RiskClass {
215    /// The class assumed when nothing declares one: [`Self::Irreversible`].
216    ///
217    /// Used as the serde default wherever an unannotated record would
218    /// otherwise be read as harmless.
219    #[must_use]
220    pub const fn conservative() -> Self {
221        Self::Irreversible
222    }
223
224    /// Returns `true` when the class needs a trusted origin on its own,
225    /// regardless of the confirmation policy (I12).
226    #[must_use]
227    pub fn needs_trusted_origin(self) -> bool {
228        self > Self::ReversibleLowRisk
229    }
230}
231
232/// Confirmation a command requires before execution (spec §14.3).
233///
234/// New confirmation kinds are expected, so downstream matches need a wildcard
235/// arm.
236#[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    /// No confirmation.
243    None,
244    /// The user must review a diff card.
245    ReviewCard,
246    /// The user must click an explicit confirmation CTA.
247    ExplicitClick,
248    /// The user must re-authenticate.
249    Reauthentication,
250    /// A qualified electronic signature is required.
251    QualifiedSignature,
252    /// A qualified human other than the end user must review. No card the user
253    /// can click satisfies it.
254    HumanProfessionalReview,
255}
256
257impl ConfirmationPolicy {
258    /// The interaction kind whose answer satisfies this confirmation, if the
259    /// end user can give it at all.
260    ///
261    /// [`Self::HumanProfessionalReview`] returns `None`: the review is somebody
262    /// else's, recorded through [`CommandOrigin::InternalPolicy`] or a verified
263    /// [`CommandOrigin::ExternalCallback`], so offering the user a confirmation
264    /// card would let them approve themselves.
265    #[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    /// Returns `true` when only the server can supply the confirmation.
277    #[must_use]
278    pub fn is_server_side_only(self) -> bool {
279        matches!(self, Self::HumanProfessionalReview)
280    }
281}
282
283/// How commands are grouped for all-or-nothing execution (spec §13.4).
284///
285/// New grouping strategies are expected, so downstream matches need a wildcard
286/// arm.
287#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
288#[serde(tag = "kind", rename_all = "snake_case")]
289#[non_exhaustive]
290pub enum AtomicityScope {
291    /// Each command commits alone.
292    PerCommand,
293    /// All commands on the same case commit together (the default for mutations).
294    PerCase,
295    /// A named group commits together.
296    ExplicitGroup {
297        /// Group name.
298        group: String,
299    },
300    /// The command starts an external saga tracked through the outbox.
301    ExternalSaga {
302        /// Saga name.
303        saga: String,
304    },
305}
306
307impl AtomicityScope {
308    /// Stable snake-case name of the variant, as it appears in JSON.
309    #[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    /// The group or saga name, for the named scopes.
320    #[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/// How the assistant may talk about the outcome of a command (spec §17.2).
331#[derive(
332    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
333)]
334#[serde(rename_all = "snake_case")]
335pub enum ClaimMode {
336    /// Only the server-rendered receipt may state the outcome.
337    ServerReceiptOnly,
338    /// The narrator may paraphrase, citing event ids.
339    EventReferencedParaphrase,
340    /// The narrator may explain freely (read-only or trivial outcomes).
341    FreeExplanation,
342}
343
344/// The policy attached to a command (spec §14.3).
345#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
346pub struct CommandPolicy {
347    /// Risk class.
348    pub risk: RiskClass,
349    /// Required confirmation.
350    pub confirmation: ConfirmationPolicy,
351    /// Grouping for execution.
352    pub atomicity: AtomicityScope,
353    /// Claim mode for receipts and narration.
354    pub claim_mode: ClaimMode,
355}
356
357impl CommandPolicy {
358    /// The policy applied to anything unknown: irreversible, explicit click,
359    /// per-case atomicity, server receipts only.
360    #[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    /// Policy for commands that mutate nothing.
371    #[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    /// Policy for reversible low-risk edits applied directly from validated
382    /// user evidence (e.g. setting a draft field).
383    #[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    /// Returns `true` when the policy demands a trusted origin (I12): any risk
394    /// above [`RiskClass::ReversibleLowRisk`] or any confirmation other than
395    /// [`ConfirmationPolicy::None`].
396    #[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/// Pure check of I12: does `origin` satisfy `policy`?
409///
410/// Two independent gates, both of which must pass:
411///
412/// 1. **Risk.** Anything above [`RiskClass::ReversibleLowRisk`] needs a trusted
413///    origin ([`CommandOrigin::is_trusted`]).
414/// 2. **Confirmation.** The origin must be the specific authorization
415///    `policy.confirmation` names
416///    ([`CommandOrigin::satisfies_confirmation`]) — a click on a target
417///    selection card is not a confirmation of the command it disambiguates.
418///
419/// # Examples
420///
421/// ```
422/// use turnframe_core::prelude::*;
423///
424/// let picked_a_case = CommandOrigin::ConfirmedInteraction {
425///     interaction_id: InteractionId::nil(),
426///     payload_hash: Digest::of_bytes(b"payload"),
427///     interaction_kind: InteractionKind::SelectTarget,
428///     action_class: ActionClass::NoCommands,
429///     channel: ResolutionChannel::Click,
430/// };
431/// let confirmed_the_send = CommandOrigin::ConfirmedInteraction {
432///     interaction_id: InteractionId::nil(),
433///     payload_hash: Digest::of_bytes(b"payload"),
434///     interaction_kind: InteractionKind::ConfirmCommand,
435///     action_class: ActionClass::ConfirmsCommands,
436///     channel: ResolutionChannel::Click,
437/// };
438/// let send = CommandPolicy::conservative();
439/// assert!(!origin_satisfies(&picked_a_case, &send));
440/// assert!(origin_satisfies(&confirmed_the_send, &send));
441/// ```
442#[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
450/// Domain-separation prefix of [`CommandId::derive`].
451const COMMAND_ID_DOMAIN: &str = "turnframe.command_id.v2";
452
453/// Domain-separation prefix of [`BatchId::derive`].
454const BATCH_ID_DOMAIN: &str = "turnframe.batch_id.v1";
455
456impl CommandId {
457    /// Derives the identifier of the `position`-th command compiled for `act` of
458    /// `turn_id`.
459    ///
460    /// Shipped reducers **must** use this instead of [`CommandId::new`]: the
461    /// identifier is part of the [`ReductionPlan`](crate::reduce::ReductionPlan)
462    /// hash, so a random one would make two reductions of the same inputs
463    /// disagree and defeat replay (I20).
464    #[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    /// Derives the identifier of the batch that groups the commands of
479    /// `case_key` under `scope` within `turn_id`.
480    ///
481    /// Deterministic for the same reason as [`CommandId::derive`].
482    #[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/// Stable idempotency key of a command (I14).
498#[derive(
499    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
500)]
501#[serde(transparent)]
502pub struct IdempotencyKey(pub String);
503
504/// Domain-separation prefix of the idempotency derivation. Bump when the
505/// derivation input changes.
506const 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    /// Derives the key as `blake3(canonical_json({domain, account, turn_id,
520    /// case_ref, origin, command}))`.
521    ///
522    /// The key is stable for the same inputs across processes and library
523    /// versions that share the domain prefix. It changes when any input changes,
524    /// including the expected revision inside `case_ref`: a command re-planned
525    /// against a newer revision is a new command, while a crash-recovery replay
526    /// of the same turn reproduces the same key.
527    ///
528    /// # Examples
529    ///
530    /// ```
531    /// use turnframe_core::prelude::*;
532    /// use serde_json::json;
533    ///
534    /// let account = AccountId::from("acct");
535    /// let turn = TurnId::nil();
536    /// let case_ref = CaseRef::new("trip", "i1", CaseRevision(3));
537    /// let origin = CommandOrigin::DirectSafeUserAct {
538    ///     evidence_digest: Digest::of_bytes(b"evidence"),
539    /// };
540    /// let command = json!({ "set_name": { "value": "Lisbon" } });
541    /// let key = IdempotencyKey::derive(&account, &turn, &case_ref, &origin, &command)?;
542    /// // The same turn replayed after a crash derives the same key ...
543    /// assert_eq!(
544    ///     key,
545    ///     IdempotencyKey::derive(&account, &turn, &case_ref, &origin, &command)?
546    /// );
547    /// // ... but a command planned against a newer revision is a new command.
548    /// let newer = case_ref.with_revision(CaseRevision(4));
549    /// assert_ne!(
550    ///     key,
551    ///     IdempotencyKey::derive(&account, &turn, &newer, &origin, &command)?
552    /// );
553    /// # Ok::<(), turnframe_core::hash::HashError>(())
554    /// ```
555    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    /// Wraps an externally supplied key (e.g. from a callback).
574    #[must_use]
575    pub fn new(value: impl Into<String>) -> Self {
576        Self(value.into())
577    }
578
579    /// Borrows the key.
580    #[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/// A typed command with everything the executor and the journal need (spec §14.2).
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct CommandEnvelope<C> {
595    /// Identifier of this envelope.
596    pub command_id: CommandId,
597    /// Turn that produced it.
598    pub turn_id: TurnId,
599    /// Actor on whose behalf it runs.
600    pub actor: ActorContext,
601    /// Target case and expected revision.
602    pub case_ref: CaseRef,
603    /// Idempotency key (I14).
604    pub idempotency_key: IdempotencyKey,
605    /// Authorizing origin.
606    pub origin: CommandOrigin,
607    /// The domain command.
608    pub command: C,
609}
610
611impl<C> CommandEnvelope<C> {
612    /// Account the command runs in.
613    #[must_use]
614    pub fn account_id(&self) -> &AccountId {
615        &self.actor.account_id
616    }
617
618    /// Transforms the command payload, keeping every other field.
619    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/// A group of envelopes executed under one atomicity scope.
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
637pub struct CommandBatch<C> {
638    /// Identifier of the batch.
639    pub batch_id: BatchId,
640    /// Scope under which the envelopes commit.
641    pub scope: AtomicityScope,
642    /// The envelopes, in execution order.
643    pub envelopes: Vec<CommandEnvelope<C>>,
644}
645
646impl<C> CommandBatch<C> {
647    /// Number of envelopes.
648    #[must_use]
649    pub fn len(&self) -> usize {
650        self.envelopes.len()
651    }
652
653    /// Returns `true` when the batch has no envelopes.
654    #[must_use]
655    pub fn is_empty(&self) -> bool {
656        self.envelopes.is_empty()
657    }
658
659    /// Returns `true` when every envelope targets the same case (required for
660    /// [`AtomicityScope::PerCase`]).
661    #[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    /// Transforms every command payload, keeping identifiers and scope.
670    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        // A verified callback stands in for the user's click only where the
756        // policy says an out-of-band authority may answer.
757        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        // The user picked which trip they meant; that is not a confirmation
778        // of an irreversible command (CORE-SPEC-001).
779        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}