Skip to main content

turnframe_runtime/
policy.rs

1//! Whether a command may run now, and if not, what would let it (spec §14.3).
2//!
3//! [`PolicySnapshot::decide`](turnframe_core::policy::PolicySnapshot::decide) in
4//! core answers the narrow question: does this origin satisfy this policy?
5//! [`PolicyEngine`] answers the question a turn actually asks, which has three
6//! more inputs — the constraints the user put on the turn ("do not submit
7//! anything yet"), the orchestration mode, and the configuration — and which has
8//! to produce not just a verdict but the *card* that would change it.
9//!
10//! # Gates, in order
11//!
12//! 1. **Mode** (§11.4). A sandboxed run is not eligible for destructive,
13//!    irreversible or externally regulated commands at all. No card helps.
14//! 2. **Constraints** (§10.4). Evaluated in a fixed order so the same set of
15//!    constraints always names the same blocker:
16//!
17//!    | Constraint | Blocks |
18//!    | --- | --- |
19//!    | [`DoNotSubmit`] | anything that leaves the system: [`RiskClass::ExternalRegulated`], or an [`AtomicityScope::ExternalSaga`] |
20//!    | [`DoNotDelete`] | [`RiskClass::Destructive`] |
21//!    | [`DraftOnly`] | anything above [`RiskClass::ReversibleLowRisk`] |
22//!    | [`NoExternalEffects`] | [`RiskClass::ExternalRegulated`] |
23//!    | [`AskBeforeApplying`] | nothing — it *raises* [`ConfirmationPolicy::None`] to [`ConfirmationPolicy::ExplicitClick`] |
24//!    | [`ApplyOnlyIf`] | nothing here; the reducer turns it into a clarification |
25//!
26//! 3. **Policy** (§14.3, I12). The core snapshot decides, against the policy as
27//!    the constraints left it.
28//!
29//! # An absent policy is a conservative policy
30//!
31//! [`PolicyRequest::policy`] is an `Option` because a domain that has not
32//! classified a command is a real situation. It is treated as
33//! [`CommandPolicy::conservative`]: irreversible, explicit click, per case,
34//! server receipts only. Silence is never permission.
35//!
36//! [`DoNotSubmit`]: ConstraintKind::DoNotSubmit
37//! [`DoNotDelete`]: ConstraintKind::DoNotDelete
38//! [`DraftOnly`]: ConstraintKind::DraftOnly
39//! [`NoExternalEffects`]: ConstraintKind::NoExternalEffects
40//! [`AskBeforeApplying`]: ConstraintKind::AskBeforeApplying
41//! [`ApplyOnlyIf`]: ConstraintKind::ApplyOnlyIf
42
43use std::fmt;
44use std::sync::Arc;
45
46use turnframe_core::case::CaseRef;
47use turnframe_core::command::{
48    AtomicityScope, CommandOrigin, CommandPolicy, ConfirmationPolicy, RiskClass,
49};
50use turnframe_core::flow::ConfirmationSubject;
51use turnframe_core::ids::OptionId;
52use turnframe_core::interaction::{
53    InteractionKind, InteractionOption, InteractionPayload, InteractionSpec, OptionStyle,
54    ReviewDiffEntry, StoredInteractionAction, TextResolutionPolicy,
55};
56use turnframe_core::locale::LocalizedText;
57use turnframe_core::policy::{PolicyDecision, PolicySnapshot};
58use turnframe_core::reduce::CommandRef;
59use turnframe_core::understanding::ConstraintKind;
60
61use crate::config::{InteractionConfig, OrchestrationMode, OrchestratorConfig};
62
63/// Option identifier of the confirming CTA on a card the engine builds.
64pub const CONFIRM_OPTION_ID: &str = "confirm";
65/// Option identifier of the declining CTA on a card the engine builds.
66pub const DECLINE_OPTION_ID: &str = "decline";
67
68/// Reason keys the engine adds to the ones in
69/// [`turnframe_core::policy::reason`].
70pub mod reason {
71    /// A constraint the user placed on the turn blocks the command.
72    pub const BLOCKED_BY_CONSTRAINT: &str = "turnframe.policy.blocked_by_constraint";
73    /// The orchestration mode is not eligible for this risk class (§11.4).
74    pub const BLOCKED_BY_MODE: &str = "turnframe.policy.blocked_by_mode";
75}
76
77/// One command as the policy engine sees it.
78///
79/// It carries no domain knowledge on purpose: everything the engine needs to
80/// apply a constraint is already in the [`CommandPolicy`], so a domain cannot
81/// accidentally exempt itself from "do not submit" by forgetting a flag.
82#[derive(Debug, Clone)]
83pub struct PolicyRequest<'a> {
84    /// Which command the decision is about.
85    pub command_ref: CommandRef,
86    /// Stable key for the card the decision may require (e.g.
87    /// `"confirm:acts[0]"`). Two requests with the same key describe one card.
88    pub interaction_key: String,
89    /// The case the command runs on, and the revision a card would bind to.
90    pub case_ref: &'a CaseRef,
91    /// The domain's policy. `None` means "unclassified", treated as
92    /// [`CommandPolicy::conservative`].
93    pub policy: Option<&'a CommandPolicy>,
94    /// The origin the command would carry.
95    pub origin: &'a CommandOrigin,
96    /// The erased command, handed to the review diff builder.
97    pub command: &'a serde_json::Value,
98}
99
100impl PolicyRequest<'_> {
101    /// The policy actually applied: the domain's, or the conservative one.
102    #[must_use]
103    pub fn effective_policy(&self) -> CommandPolicy {
104        self.policy
105            .cloned()
106            .unwrap_or_else(CommandPolicy::conservative)
107    }
108}
109
110/// Builds the before/after lines of a [`InteractionKind::ReviewChanges`] card.
111///
112/// The engine cannot know what a domain command changes, so the diff is
113/// injected. Returning an empty diff is allowed and safe: the engine then falls
114/// back to a plain [`InteractionKind::ConfirmCommand`] card, which
115/// [`ConfirmationPolicy::ReviewCard`] also accepts, rather than persisting a
116/// review card with nothing to review.
117pub trait ReviewDiffBuilder: Send + Sync {
118    /// Lines to show for `request`.
119    fn diff(&self, request: &PolicyRequest<'_>) -> Vec<ReviewDiffEntry>;
120}
121
122/// A diff builder that shows nothing, so review cards degrade to confirmations.
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
124pub struct NoReviewDiff;
125
126impl ReviewDiffBuilder for NoReviewDiff {
127    fn diff(&self, _request: &PolicyRequest<'_>) -> Vec<ReviewDiffEntry> {
128        Vec::new()
129    }
130}
131
132impl<F> ReviewDiffBuilder for F
133where
134    F: Fn(&PolicyRequest<'_>) -> Vec<ReviewDiffEntry> + Send + Sync,
135{
136    fn diff(&self, request: &PolicyRequest<'_>) -> Vec<ReviewDiffEntry> {
137        self(request)
138    }
139}
140
141/// Server-authored copy for the cards the engine builds.
142///
143/// Every field is [`LocalizedText`], so an application replaces the English
144/// defaults without touching the engine.
145#[derive(Debug, Clone, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct ConfirmationCopy {
148    /// Title of a [`InteractionKind::ReviewChanges`] card.
149    pub review_title: LocalizedText,
150    /// Title of a [`InteractionKind::ConfirmCommand`] card.
151    pub confirm_title: LocalizedText,
152    /// Title of a [`InteractionKind::Reauthenticate`] card.
153    pub reauthenticate_title: LocalizedText,
154    /// Title of a [`InteractionKind::ExternalSignature`] card.
155    pub signature_title: LocalizedText,
156    /// Label of the confirming CTA.
157    pub confirm_label: LocalizedText,
158    /// Label of the declining CTA.
159    pub decline_label: LocalizedText,
160}
161
162impl ConfirmationCopy {
163    /// The built-in copy: English, with Italian.
164    #[must_use]
165    pub fn standard() -> Self {
166        crate::copy::ServerCopy::translated(Self::english(), "it", CONFIRMATION_ITALIAN)
167    }
168
169    /// English alone.
170    #[must_use]
171    pub fn english() -> Self {
172        Self {
173            review_title: LocalizedText::new("Review these changes"),
174            confirm_title: LocalizedText::new("Confirm this action"),
175            reauthenticate_title: LocalizedText::new("Confirm your identity"),
176            signature_title: LocalizedText::new("Sign this document"),
177            confirm_label: LocalizedText::new("Confirm"),
178            decline_label: LocalizedText::new("Cancel"),
179        }
180    }
181
182    fn title_for(&self, kind: InteractionKind) -> LocalizedText {
183        match kind {
184            InteractionKind::ReviewChanges => self.review_title.clone(),
185            InteractionKind::Reauthenticate => self.reauthenticate_title.clone(),
186            InteractionKind::ExternalSignature => self.signature_title.clone(),
187            _ => self.confirm_title.clone(),
188        }
189    }
190}
191
192impl Default for ConfirmationCopy {
193    fn default() -> Self {
194        Self::standard()
195    }
196}
197
198crate::copy::server_copy!(
199    ConfirmationCopy,
200    [
201        review_title,
202        confirm_title,
203        reauthenticate_title,
204        signature_title,
205        confirm_label,
206        decline_label
207    ]
208);
209
210/// The built-in Italian of [`ConfirmationCopy`], by field.
211const CONFIRMATION_ITALIAN: &[(&str, &str)] = &[
212    ("review_title", "Controlla queste modifiche"),
213    ("confirm_title", "Conferma questa operazione"),
214    ("reauthenticate_title", "Conferma la tua identità"),
215    ("signature_title", "Firma questo documento"),
216    ("confirm_label", "Conferma"),
217    ("decline_label", "Annulla"),
218];
219
220/// Why a command may not run, when no card the end user can click would help.
221///
222/// Serializable so a reducer can put it in the `details` of the
223/// [`DomainRejection`](turnframe_core::error::DomainRejection) the user's client
224/// receives: "blocked" is not an answer, "blocked because you said not to
225/// submit" is.
226#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
227#[serde(tag = "kind", rename_all = "snake_case")]
228#[non_exhaustive]
229pub enum BlockReason {
230    /// A constraint the user placed on the turn (§10.4).
231    Constraint(ConstraintKind),
232    /// The orchestration mode is not eligible for the risk class (§11.4).
233    Mode {
234        /// The refused class.
235        risk: RiskClass,
236    },
237    /// The policy snapshot forbids the risk class outright.
238    ForbiddenRiskClass {
239        /// The refused class.
240        risk: RiskClass,
241    },
242    /// Only a qualified human other than the end user may authorize this
243    /// ([`ConfirmationPolicy::HumanProfessionalReview`]).
244    HumanReview,
245}
246
247/// What the engine decided about one command.
248#[derive(Debug, Clone, PartialEq, Eq)]
249#[non_exhaustive]
250pub enum PolicyOutcome {
251    /// The command may execute with the origin it carries.
252    Allowed {
253        /// The recorded decision.
254        decision: PolicyDecision,
255    },
256    /// The command needs a specific confirmation first; here is the card.
257    ///
258    /// The card is boxed because it is by far the largest thing an outcome can
259    /// carry and most outcomes are not this one.
260    NeedsConfirmation {
261        /// The recorded decision.
262        decision: PolicyDecision,
263        /// The card to persist before anything executes.
264        interaction: Box<InteractionSpec>,
265    },
266    /// The command may not execute, and no card the user can click changes that.
267    Blocked {
268        /// The recorded decision.
269        decision: PolicyDecision,
270        /// Why.
271        reason: BlockReason,
272    },
273}
274
275impl PolicyOutcome {
276    /// The decision, whatever the outcome.
277    #[must_use]
278    pub fn decision(&self) -> &PolicyDecision {
279        match self {
280            Self::Allowed { decision }
281            | Self::NeedsConfirmation { decision, .. }
282            | Self::Blocked { decision, .. } => decision,
283        }
284    }
285
286    /// Consumes the outcome and returns the decision.
287    #[must_use]
288    pub fn into_decision(self) -> PolicyDecision {
289        match self {
290            Self::Allowed { decision }
291            | Self::NeedsConfirmation { decision, .. }
292            | Self::Blocked { decision, .. } => decision,
293        }
294    }
295
296    /// The card the outcome requires, when there is one.
297    #[must_use]
298    pub fn interaction(&self) -> Option<&InteractionSpec> {
299        match self {
300            Self::NeedsConfirmation { interaction, .. } => Some(interaction.as_ref()),
301            _ => None,
302        }
303    }
304
305    /// Returns `true` for [`Self::Allowed`].
306    #[must_use]
307    pub fn is_allowed(&self) -> bool {
308        matches!(self, Self::Allowed { .. })
309    }
310}
311
312/// Applies constraints, mode and policy to one command, and builds the card
313/// that would unblock it.
314#[derive(Clone)]
315pub struct PolicyEngine {
316    mode: OrchestrationMode,
317    interaction: InteractionConfig,
318    copy: ConfirmationCopy,
319    diff: Arc<dyn ReviewDiffBuilder>,
320}
321
322impl fmt::Debug for PolicyEngine {
323    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324        f.debug_struct("PolicyEngine")
325            .field("mode", &self.mode)
326            .field("interaction", &self.interaction)
327            .finish_non_exhaustive()
328    }
329}
330
331impl PolicyEngine {
332    /// Builds an engine from a configuration, with the built-in copy and no review
333    /// diff.
334    #[must_use]
335    pub fn new(config: &OrchestratorConfig) -> Self {
336        Self {
337            mode: config.mode.clone(),
338            interaction: config.interaction,
339            copy: ConfirmationCopy::standard(),
340            diff: Arc::new(NoReviewDiff),
341        }
342    }
343
344    /// Replaces the copy on the cards the policy engine raises.
345    ///
346    /// # The shipped default is English, and only English
347    ///
348    /// Not a placeholder: it is real copy, it renders, and a deployment that
349    /// never calls this ships it to every user in every locale. That is the
350    /// failure worth naming here, because nothing goes wrong until a user in
351    /// another language reads a sentence the rest of the product would never
352    /// have written — which is how it was found.
353    ///
354    /// A [`LocalizedText`] carries a default plus one string per locale, so
355    /// supplying your own is additive rather than a rewrite: start from
356    /// `english()` and use
357    /// [`LocalizedText::with`](turnframe_core::locale::LocalizedText::with) to
358    /// add the languages you answer in.
359    #[must_use]
360    pub fn with_copy(mut self, copy: ConfirmationCopy) -> Self {
361        self.copy = copy;
362        self
363    }
364
365    /// Replaces the review diff builder.
366    ///
367    /// ```
368    /// use turnframe_core::interaction::ReviewDiffEntry;
369    /// use turnframe_runtime::config::OrchestratorConfig;
370    /// use turnframe_runtime::policy::PolicyEngine;
371    /// use std::sync::Arc;
372    ///
373    /// let engine = PolicyEngine::new(&OrchestratorConfig::conservative())
374    ///     .with_review_diff(Arc::new(|_: &turnframe_runtime::policy::PolicyRequest<'_>| {
375    ///         vec![ReviewDiffEntry::new("name", "Name")]
376    ///     }));
377    /// assert!(format!("{engine:?}").starts_with("PolicyEngine"));
378    /// ```
379    #[must_use]
380    pub fn with_review_diff(mut self, diff: Arc<dyn ReviewDiffBuilder>) -> Self {
381        self.diff = diff;
382        self
383    }
384
385    /// The orchestration mode the engine gates on.
386    #[must_use]
387    pub fn mode(&self) -> &OrchestrationMode {
388        &self.mode
389    }
390
391    /// The diff a review card would show for `request`.
392    #[must_use]
393    pub fn review_diff(&self, request: &PolicyRequest<'_>) -> Vec<ReviewDiffEntry> {
394        self.diff.diff(request)
395    }
396
397    /// Decides one command against `snapshot` and the turn's `constraints`.
398    ///
399    /// See the module documentation for the order of the gates.
400    #[must_use]
401    pub fn decide(
402        &self,
403        snapshot: &PolicySnapshot,
404        request: &PolicyRequest<'_>,
405        constraints: &[ConstraintKind],
406    ) -> PolicyOutcome {
407        let declared = request.effective_policy();
408
409        if !self.mode.allows_risk(declared.risk) {
410            return PolicyOutcome::Blocked {
411                decision: refusal(request, &declared, reason::BLOCKED_BY_MODE),
412                reason: BlockReason::Mode {
413                    risk: declared.risk,
414                },
415            };
416        }
417
418        if let Some(constraint) = blocking_constraint(&declared, constraints) {
419            return PolicyOutcome::Blocked {
420                decision: refusal(request, &declared, reason::BLOCKED_BY_CONSTRAINT),
421                reason: BlockReason::Constraint(constraint),
422            };
423        }
424
425        let effective = apply_ask_before_applying(declared, constraints);
426        let decision = snapshot.decide(request.command_ref, &effective, request.origin);
427        if decision.allowed {
428            return PolicyOutcome::Allowed { decision };
429        }
430        if snapshot.is_risk_forbidden(effective.risk) {
431            return PolicyOutcome::Blocked {
432                decision,
433                reason: BlockReason::ForbiddenRiskClass {
434                    risk: effective.risk,
435                },
436            };
437        }
438        match self.confirmation_card(
439            request.interaction_key.clone(),
440            request.case_ref,
441            &effective,
442            vec![request.command_ref],
443            self.diff.diff(request),
444            // This path is the engine on its own, with no workflow in reach to
445            // ask; the reducer's is the one that carries a domain's answer.
446            None,
447        ) {
448            Some(interaction) => PolicyOutcome::NeedsConfirmation {
449                decision,
450                interaction: Box::new(interaction),
451            },
452            None => PolicyOutcome::Blocked {
453                decision,
454                reason: BlockReason::HumanReview,
455            },
456        }
457    }
458
459    /// The card whose answer satisfies `policy`, or `None` when the end user
460    /// cannot supply the confirmation at all.
461    ///
462    /// `None` comes back for [`ConfirmationPolicy::None`] (nothing to confirm)
463    /// and for [`ConfirmationPolicy::HumanProfessionalReview`] (offering the
464    /// user a card would let them approve themselves).
465    ///
466    /// The card always carries a confirming CTA that authorizes exactly
467    /// `command_refs` and a declining CTA, so refusing is always possible (I6).
468    /// A [`InteractionKind::ReviewChanges`] card with an empty `diff` degrades
469    /// to [`InteractionKind::ConfirmCommand`], which
470    /// [`ConfirmationPolicy::ReviewCard`] accepts just as well.
471    #[must_use]
472    pub fn confirmation_card(
473        &self,
474        key: impl Into<String>,
475        case_ref: &CaseRef,
476        policy: &CommandPolicy,
477        command_refs: Vec<CommandRef>,
478        diff: Vec<ReviewDiffEntry>,
479        subject: Option<ConfirmationSubject>,
480    ) -> Option<InteractionSpec> {
481        let mut kind = policy.confirmation.interaction_kind()?;
482        if kind == InteractionKind::ReviewChanges && diff.is_empty() {
483            kind = InteractionKind::ConfirmCommand;
484        }
485        // The engine knows a confirmation is needed; only the domain knows what
486        // it is about. Where it says nothing this is the per-kind box exactly as
487        // before, which is what a card with no subject has always been.
488        let subject = subject.unwrap_or_default();
489        let title = subject.title.unwrap_or_else(|| self.copy.title_for(kind));
490        // Only the confirming option carries it. Declining is the end of the
491        // matter by definition, so a turn that declines has nothing to go on
492        // with — and the option that says "no" must never be the one that lets
493        // a plan run.
494        let confirm = InteractionOption::new(
495            OptionId::from(CONFIRM_OPTION_ID),
496            self.copy.confirm_label.clone(),
497            StoredInteractionAction::ConfirmCommands { command_refs },
498        )
499        .with_style(OptionStyle::Primary);
500        let mut payload = InteractionPayload::new(title)
501            .with_option(confirm)
502            .with_option(
503                InteractionOption::new(
504                    OptionId::from(DECLINE_OPTION_ID),
505                    self.copy.decline_label.clone(),
506                    StoredInteractionAction::DeclineCommands,
507                )
508                .with_style(OptionStyle::Danger),
509            );
510        if let Some(body) = subject.body {
511            payload = payload.with_body(body);
512        }
513        if kind == InteractionKind::ReviewChanges {
514            for entry in diff {
515                payload = payload.with_review_entry(entry);
516            }
517        }
518        let mut spec = InteractionSpec::new(key, case_ref.clone(), kind, payload)
519            .with_confirms_risk(policy.risk)
520            // Every kind this method builds authorizes commands, so typed text
521            // may never resolve it (§15.7): an inferred "yes" is not consent.
522            .with_text_resolution(TextResolutionPolicy::Never);
523        if let Some(ttl) = self.interaction.default_ttl {
524            spec = spec.expires_in(ttl);
525        }
526        if !self.interaction.confirmation_cards_bind_to_revision {
527            spec = spec.revision_independent();
528        }
529        Some(spec)
530    }
531
532    /// The card that lets the user pick between ambiguous targets (§12.3).
533    ///
534    /// Returns `None` when there are fewer than two candidates — a selection
535    /// with one answer is not a selection — or more than
536    /// [`InteractionConfig::max_selection_candidates`], because truncating the
537    /// list would let list order decide which cases the user may reach (I8).
538    #[must_use]
539    pub fn selection_card(
540        &self,
541        key: impl Into<String>,
542        case_ref: &CaseRef,
543        title: LocalizedText,
544        cancel_label: LocalizedText,
545        candidates: &[turnframe_core::target::TargetCandidate],
546    ) -> Option<InteractionSpec> {
547        if candidates.len() < 2 || candidates.len() > self.interaction.max_selection_candidates {
548            return None;
549        }
550        let mut payload = InteractionPayload::new(title);
551        for candidate in candidates {
552            payload = payload.with_option(InteractionOption::new(
553                OptionId::from(candidate.token.as_str()),
554                LocalizedText::new(candidate.label.clone()),
555                StoredInteractionAction::SelectTarget {
556                    case_ref: candidate.case_ref.clone(),
557                },
558            ));
559        }
560        payload = payload.with_option(
561            InteractionOption::new(
562                OptionId::from(DECLINE_OPTION_ID),
563                cancel_label,
564                StoredInteractionAction::Dismiss,
565            )
566            .with_style(OptionStyle::Danger),
567        );
568        let mut spec = InteractionSpec::new(
569            key,
570            case_ref.clone(),
571            InteractionKind::SelectTarget,
572            payload,
573        )
574        // Picking a case authorizes nothing, which is exactly why it is safe to
575        // let the answer arrive as low risk.
576        .with_confirms_risk(RiskClass::ReversibleLowRisk)
577        // "Which of these did you mean?" does not stop being a fair question
578        // when one of the candidates moves on, so the card outlives a revision
579        // change; the act it unblocks is re-planned against the state of the
580        // day it is answered.
581        .revision_independent();
582        if !self.interaction.selection_cards_block_the_case {
583            spec = spec.non_blocking();
584        }
585        if let Some(ttl) = self.interaction.default_ttl {
586            spec = spec.expires_in(ttl);
587        }
588        Some(spec)
589    }
590}
591
592/// Whether the constraint set blocks a command with this policy, and which
593/// constraint did it. Evaluated in a fixed order, not in plan order, so the
594/// same set always names the same blocker.
595fn blocking_constraint(
596    policy: &CommandPolicy,
597    constraints: &[ConstraintKind],
598) -> Option<ConstraintKind> {
599    let submits = policy.risk == RiskClass::ExternalRegulated
600        || matches!(policy.atomicity, AtomicityScope::ExternalSaga { .. });
601    let external = policy.risk == RiskClass::ExternalRegulated;
602    let has = |wanted: &ConstraintKind| constraints.iter().any(|c| c == wanted);
603    if submits && has(&ConstraintKind::DoNotSubmit) {
604        return Some(ConstraintKind::DoNotSubmit);
605    }
606    if policy.risk == RiskClass::Destructive && has(&ConstraintKind::DoNotDelete) {
607        return Some(ConstraintKind::DoNotDelete);
608    }
609    if policy.risk > RiskClass::ReversibleLowRisk && has(&ConstraintKind::DraftOnly) {
610        return Some(ConstraintKind::DraftOnly);
611    }
612    if external && has(&ConstraintKind::NoExternalEffects) {
613        return Some(ConstraintKind::NoExternalEffects);
614    }
615    None
616}
617
618/// `AskBeforeApplying` never blocks; it turns a command that needed no
619/// confirmation into one that does.
620fn apply_ask_before_applying(
621    mut policy: CommandPolicy,
622    constraints: &[ConstraintKind],
623) -> CommandPolicy {
624    if policy.confirmation == ConfirmationPolicy::None
625        && constraints
626            .iter()
627            .any(|c| c == &ConstraintKind::AskBeforeApplying)
628    {
629        policy.confirmation = ConfirmationPolicy::ExplicitClick;
630    }
631    policy
632}
633
634fn refusal(
635    request: &PolicyRequest<'_>,
636    policy: &CommandPolicy,
637    reason_key: &str,
638) -> PolicyDecision {
639    PolicyDecision {
640        command_ref: request.command_ref,
641        policy: policy.clone(),
642        requires_interaction: None,
643        allowed: false,
644        reason_key: reason_key.to_owned(),
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use serde_json::json;
652    use turnframe_core::command::{ClaimMode, ResolutionChannel};
653    use turnframe_core::hash::Digest;
654    use turnframe_core::ids::{BatchId, CaseRevision, CommandId, InteractionId, TargetToken};
655    use turnframe_core::interaction::{ActionClass, FieldValue};
656    use turnframe_core::policy::reason as core_reason;
657    use turnframe_core::target::TargetCandidate;
658
659    use crate::config::{ResourceBudget, SandboxAcknowledgement};
660
661    fn case() -> CaseRef {
662        CaseRef::new("trip", "i1", CaseRevision(3))
663    }
664
665    fn command_ref() -> CommandRef {
666        CommandRef {
667            batch_id: BatchId::nil(),
668            command_id: CommandId::nil(),
669        }
670    }
671
672    fn direct() -> CommandOrigin {
673        CommandOrigin::DirectSafeUserAct {
674            evidence_digest: Digest::of_bytes(b"e"),
675        }
676    }
677
678    fn card(kind: InteractionKind, action_class: ActionClass) -> CommandOrigin {
679        CommandOrigin::ConfirmedInteraction {
680            interaction_id: InteractionId::nil(),
681            payload_hash: Digest::of_bytes(b"p"),
682            interaction_kind: kind,
683            action_class,
684            channel: ResolutionChannel::Click,
685        }
686    }
687
688    fn confirmed() -> CommandOrigin {
689        card(
690            InteractionKind::ConfirmCommand,
691            ActionClass::ConfirmsCommands,
692        )
693    }
694
695    fn engine() -> PolicyEngine {
696        PolicyEngine::new(&OrchestratorConfig::conservative())
697    }
698
699    fn request<'a>(policy: &'a CommandPolicy, origin: &'a CommandOrigin) -> PolicyRequest<'a> {
700        PolicyRequest {
701            command_ref: command_ref(),
702            interaction_key: "confirm:acts[0]".into(),
703            case_ref: CASE.get_or_init(case),
704            policy: Some(policy),
705            origin,
706            command: COMMAND.get_or_init(|| json!({"do": true})),
707        }
708    }
709
710    static CASE: std::sync::OnceLock<CaseRef> = std::sync::OnceLock::new();
711    static COMMAND: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
712
713    fn with(risk: RiskClass, confirmation: ConfirmationPolicy) -> CommandPolicy {
714        CommandPolicy {
715            risk,
716            confirmation,
717            atomicity: AtomicityScope::PerCase,
718            claim_mode: ClaimMode::ServerReceiptOnly,
719        }
720    }
721
722    #[test]
723    fn an_unclassified_command_is_treated_as_irreversible() {
724        let origin = direct();
725        let unclassified = PolicyRequest {
726            command_ref: command_ref(),
727            interaction_key: "confirm:acts[0]".into(),
728            case_ref: CASE.get_or_init(case),
729            policy: None,
730            origin: &origin,
731            command: COMMAND.get_or_init(|| json!({"do": true})),
732        };
733        assert_eq!(
734            unclassified.effective_policy(),
735            CommandPolicy::conservative()
736        );
737        let outcome = engine().decide(&PolicySnapshot::conservative(), &unclassified, &[]);
738        assert!(!outcome.is_allowed());
739        assert_eq!(
740            outcome.interaction().map(|s| s.kind),
741            Some(InteractionKind::ConfirmCommand)
742        );
743    }
744
745    #[test]
746    fn confirmation_policy_maps_to_the_card_that_satisfies_it() {
747        let engine = engine();
748        let snapshot = PolicySnapshot::conservative();
749        let origin = direct();
750        // (confirmation, expected card kind)
751        let table = [
752            (
753                ConfirmationPolicy::ReviewCard,
754                Some(InteractionKind::ConfirmCommand),
755            ),
756            (
757                ConfirmationPolicy::ExplicitClick,
758                Some(InteractionKind::ConfirmCommand),
759            ),
760            (
761                ConfirmationPolicy::Reauthentication,
762                Some(InteractionKind::Reauthenticate),
763            ),
764            (
765                ConfirmationPolicy::QualifiedSignature,
766                Some(InteractionKind::ExternalSignature),
767            ),
768            (ConfirmationPolicy::HumanProfessionalReview, None),
769        ];
770        for (confirmation, expected) in table {
771            let policy = with(RiskClass::Irreversible, confirmation);
772            let outcome = engine.decide(&snapshot, &request(&policy, &origin), &[]);
773            assert_eq!(
774                outcome.interaction().map(|spec| spec.kind),
775                expected,
776                "{confirmation:?}"
777            );
778            if expected.is_none() {
779                assert_eq!(
780                    outcome,
781                    PolicyOutcome::Blocked {
782                        decision: outcome.decision().clone(),
783                        reason: BlockReason::HumanReview
784                    }
785                );
786                assert_eq!(
787                    outcome.decision().reason_key,
788                    core_reason::HUMAN_REVIEW_REQUIRED
789                );
790            }
791            // Whatever the engine builds, it must be a card somebody can answer.
792            if let Some(spec) = outcome.interaction() {
793                assert_eq!(spec.validate(), Ok(()));
794                assert_eq!(spec.confirms_risk, RiskClass::Irreversible);
795                assert_eq!(spec.text_resolution, TextResolutionPolicy::Never);
796                assert_eq!(spec.key, "confirm:acts[0]");
797                assert!(spec.blocking && spec.binds_to_revision);
798            }
799        }
800    }
801
802    #[test]
803    fn a_review_card_shows_its_diff_and_degrades_when_there_is_none() {
804        let with_diff = engine().with_review_diff(Arc::new(|_: &PolicyRequest<'_>| {
805            vec![
806                ReviewDiffEntry::new("name", "Name")
807                    .with_before(FieldValue::present("Old"))
808                    .with_after(FieldValue::present("New")),
809            ]
810        }));
811        let policy = with(
812            RiskClass::SensitiveDataChange,
813            ConfirmationPolicy::ReviewCard,
814        );
815        let origin = direct();
816        let outcome = with_diff.decide(
817            &PolicySnapshot::conservative(),
818            &request(&policy, &origin),
819            &[],
820        );
821        let spec = outcome.interaction().unwrap();
822        assert_eq!(spec.kind, InteractionKind::ReviewChanges);
823        assert_eq!(spec.payload.review_entries.len(), 1);
824        assert_eq!(spec.validate(), Ok(()));
825        assert_eq!(with_diff.review_diff(&request(&policy, &origin)).len(), 1);
826        // Without a diff, a review card would be unanswerable, so it becomes a
827        // plain confirmation — which `ReviewCard` accepts anyway.
828        let outcome = engine().decide(
829            &PolicySnapshot::conservative(),
830            &request(&policy, &origin),
831            &[],
832        );
833        let spec = outcome.interaction().unwrap();
834        assert_eq!(spec.kind, InteractionKind::ConfirmCommand);
835        assert_eq!(spec.validate(), Ok(()));
836    }
837
838    #[test]
839    fn origins_are_judged_against_the_specific_confirmation_they_claim() {
840        let engine = engine();
841        let snapshot = PolicySnapshot::conservative();
842        let irreversible = with(RiskClass::Irreversible, ConfirmationPolicy::ExplicitClick);
843        let low = CommandPolicy::low_risk();
844        let selection = card(InteractionKind::SelectTarget, ActionClass::NoCommands);
845        let signature = card(
846            InteractionKind::ExternalSignature,
847            ActionClass::ConfirmsCommands,
848        );
849        let interpreted = CommandOrigin::ConfirmedInteraction {
850            interaction_id: InteractionId::nil(),
851            payload_hash: Digest::of_bytes(b"p"),
852            interaction_kind: InteractionKind::ConfirmCommand,
853            action_class: ActionClass::ConfirmsCommands,
854            channel: ResolutionChannel::ModelInterpreted,
855        };
856        let confirmed_origin = confirmed();
857        let direct_origin = direct();
858        // (name, policy, origin, allowed)
859        let table: [(&str, &CommandPolicy, &CommandOrigin, bool); 7] = [
860            ("direct low risk", &low, &direct_origin, true),
861            ("direct irreversible", &irreversible, &direct_origin, false),
862            (
863                "confirmed irreversible",
864                &irreversible,
865                &confirmed_origin,
866                true,
867            ),
868            // The user said which trip they meant. That is not consent to
869            // send one (CORE-SPEC-001).
870            ("disambiguation click", &irreversible, &selection, false),
871            (
872                "signature on a click policy",
873                &irreversible,
874                &signature,
875                false,
876            ),
877            ("inferred yes", &irreversible, &interpreted, false),
878            ("disambiguation click, low risk", &low, &selection, true),
879        ];
880        for (name, policy, origin, allowed) in table {
881            let outcome = engine.decide(&snapshot, &request(policy, origin), &[]);
882            assert_eq!(outcome.is_allowed(), allowed, "case {name}");
883        }
884    }
885
886    #[test]
887    fn constraints_block_what_they_name_and_nothing_else() {
888        let engine = engine();
889        let snapshot = PolicySnapshot::conservative();
890        let origin = confirmed();
891        let submission = CommandPolicy {
892            risk: RiskClass::ExternalRegulated,
893            ..CommandPolicy::conservative()
894        };
895        let saga = CommandPolicy {
896            risk: RiskClass::ReversibleLowRisk,
897            confirmation: ConfirmationPolicy::None,
898            atomicity: AtomicityScope::ExternalSaga {
899                saga: "airline".into(),
900            },
901            claim_mode: ClaimMode::ServerReceiptOnly,
902        };
903        let deletion = CommandPolicy {
904            risk: RiskClass::Destructive,
905            ..CommandPolicy::conservative()
906        };
907        let edit = CommandPolicy::low_risk();
908        // (name, policy, constraints, blocked by)
909        let table: [(
910            &str,
911            &CommandPolicy,
912            &[ConstraintKind],
913            Option<ConstraintKind>,
914        ); 8] = [
915            (
916                "submission vs do not submit",
917                &submission,
918                &[ConstraintKind::DoNotSubmit],
919                Some(ConstraintKind::DoNotSubmit),
920            ),
921            (
922                "saga vs do not submit",
923                &saga,
924                &[ConstraintKind::DoNotSubmit],
925                Some(ConstraintKind::DoNotSubmit),
926            ),
927            (
928                "edit vs do not submit",
929                &edit,
930                &[ConstraintKind::DoNotSubmit],
931                None,
932            ),
933            (
934                "deletion vs do not delete",
935                &deletion,
936                &[ConstraintKind::DoNotDelete],
937                Some(ConstraintKind::DoNotDelete),
938            ),
939            (
940                "edit vs do not delete",
941                &edit,
942                &[ConstraintKind::DoNotDelete],
943                None,
944            ),
945            (
946                "deletion vs draft only",
947                &deletion,
948                &[ConstraintKind::DraftOnly],
949                Some(ConstraintKind::DraftOnly),
950            ),
951            (
952                "edit vs draft only",
953                &edit,
954                &[ConstraintKind::DraftOnly],
955                None,
956            ),
957            (
958                "submission vs no external effects",
959                &submission,
960                &[ConstraintKind::NoExternalEffects],
961                Some(ConstraintKind::NoExternalEffects),
962            ),
963        ];
964        for (name, policy, constraints, expected) in table {
965            let outcome = engine.decide(&snapshot, &request(policy, &origin), constraints);
966            match expected {
967                Some(constraint) => {
968                    assert_eq!(
969                        outcome,
970                        PolicyOutcome::Blocked {
971                            decision: outcome.decision().clone(),
972                            reason: BlockReason::Constraint(constraint)
973                        },
974                        "case {name}"
975                    );
976                    assert_eq!(
977                        outcome.decision().reason_key,
978                        reason::BLOCKED_BY_CONSTRAINT,
979                        "case {name}"
980                    );
981                }
982                None => assert!(outcome.is_allowed(), "case {name}"),
983            }
984        }
985    }
986
987    #[test]
988    fn ask_before_applying_turns_a_free_edit_into_a_confirmation() {
989        let engine = engine();
990        let origin = direct();
991        let policy = CommandPolicy::low_risk();
992        let free = engine.decide(
993            &PolicySnapshot::conservative(),
994            &request(&policy, &origin),
995            &[],
996        );
997        assert!(free.is_allowed());
998        let asked = engine.decide(
999            &PolicySnapshot::conservative(),
1000            &request(&policy, &origin),
1001            &[ConstraintKind::AskBeforeApplying],
1002        );
1003        let spec = asked.interaction().unwrap();
1004        assert_eq!(spec.kind, InteractionKind::ConfirmCommand);
1005        assert_eq!(
1006            asked.decision().policy.confirmation,
1007            ConfirmationPolicy::ExplicitClick,
1008            "the recorded decision must show the policy that was actually applied"
1009        );
1010        // A click then satisfies it.
1011        let confirmed_origin = confirmed();
1012        assert!(
1013            engine
1014                .decide(
1015                    &PolicySnapshot::conservative(),
1016                    &request(&policy, &confirmed_origin),
1017                    &[ConstraintKind::AskBeforeApplying]
1018                )
1019                .is_allowed()
1020        );
1021    }
1022
1023    #[test]
1024    fn the_sandbox_refuses_before_any_card_is_offered() {
1025        let config =
1026            OrchestratorConfig::conservative().with_mode(OrchestrationMode::sandboxed_autonomous(
1027                ResourceBudget::conservative(),
1028                SandboxAcknowledgement::i_accept_unreviewed_autonomous_writes(),
1029            ));
1030        let engine = PolicyEngine::new(&config);
1031        assert!(engine.mode().is_sandboxed());
1032        let origin = confirmed();
1033        let policy = with(RiskClass::Irreversible, ConfirmationPolicy::ExplicitClick);
1034        let outcome = engine.decide(
1035            &PolicySnapshot::conservative(),
1036            &request(&policy, &origin),
1037            &[],
1038        );
1039        assert_eq!(
1040            outcome,
1041            PolicyOutcome::Blocked {
1042                decision: outcome.decision().clone(),
1043                reason: BlockReason::Mode {
1044                    risk: RiskClass::Irreversible
1045                }
1046            }
1047        );
1048        assert_eq!(outcome.decision().reason_key, reason::BLOCKED_BY_MODE);
1049        assert!(outcome.interaction().is_none());
1050        // What the sandbox does allow still goes through the normal gates.
1051        let low = CommandPolicy::low_risk();
1052        let direct_origin = direct();
1053        assert!(
1054            engine
1055                .decide(
1056                    &PolicySnapshot::conservative(),
1057                    &request(&low, &direct_origin),
1058                    &[]
1059                )
1060                .is_allowed()
1061        );
1062    }
1063
1064    #[test]
1065    fn a_snapshot_that_forbids_a_class_offers_no_card_either() {
1066        let policy = with(RiskClass::Destructive, ConfirmationPolicy::ExplicitClick);
1067        let origin = confirmed();
1068        let outcome = engine().decide(&PolicySnapshot::sandbox(), &request(&policy, &origin), &[]);
1069        assert_eq!(
1070            outcome,
1071            PolicyOutcome::Blocked {
1072                decision: outcome.decision().clone(),
1073                reason: BlockReason::ForbiddenRiskClass {
1074                    risk: RiskClass::Destructive
1075                }
1076            }
1077        );
1078        assert_eq!(
1079            outcome.decision().reason_key,
1080            core_reason::FORBIDDEN_RISK_CLASS
1081        );
1082    }
1083
1084    #[test]
1085    fn a_selection_card_is_built_only_for_a_real_choice() {
1086        let engine = engine();
1087        let candidate = |id: &str, label: &str| TargetCandidate {
1088            token: TargetToken::from(format!("t_{id}")),
1089            case_ref: CaseRef::new("trip", id, CaseRevision(1)),
1090            label: label.to_owned(),
1091        };
1092        let two = [candidate("i1", "Ferri"), candidate("i2", "Luca Ferri")];
1093        let spec = engine
1094            .selection_card(
1095                "select_target:acts[0]",
1096                &case(),
1097                LocalizedText::new("Which one?"),
1098                LocalizedText::new("Neither"),
1099                &two,
1100            )
1101            .unwrap();
1102        assert_eq!(spec.kind, InteractionKind::SelectTarget);
1103        assert_eq!(spec.validate(), Ok(()));
1104        assert_eq!(
1105            spec.payload.options.len(),
1106            3,
1107            "two candidates plus a way out"
1108        );
1109        assert_eq!(spec.confirms_risk, RiskClass::ReversibleLowRisk);
1110        assert!(!spec.binds_to_revision);
1111        assert!(
1112            !spec.blocking,
1113            "a card about a case nobody has identified yet must not wedge one"
1114        );
1115        assert!(
1116            engine
1117                .selection_card(
1118                    "k",
1119                    &case(),
1120                    LocalizedText::new("t"),
1121                    LocalizedText::new("c"),
1122                    &two[..1]
1123                )
1124                .is_none()
1125        );
1126        let many: Vec<_> = (0..99)
1127            .map(|n| candidate(&format!("i{n}"), "Ferri"))
1128            .collect();
1129        assert!(
1130            engine
1131                .selection_card(
1132                    "k",
1133                    &case(),
1134                    LocalizedText::new("t"),
1135                    LocalizedText::new("c"),
1136                    &many
1137                )
1138                .is_none(),
1139            "a truncated list would let order decide which cases are reachable"
1140        );
1141    }
1142
1143    #[test]
1144    fn outcome_accessors_and_copy() {
1145        let policy = CommandPolicy::conservative();
1146        let origin = direct();
1147        let outcome = engine().decide(
1148            &PolicySnapshot::conservative(),
1149            &request(&policy, &origin),
1150            &[],
1151        );
1152        assert_eq!(outcome.decision().command_ref, command_ref());
1153        assert_eq!(outcome.clone().into_decision().command_ref, command_ref());
1154        let custom = ConfirmationCopy {
1155            confirm_title: LocalizedText::new("Sicuro?"),
1156            ..ConfirmationCopy::default()
1157        };
1158        let engine = engine().with_copy(custom);
1159        let spec = engine
1160            .decide(
1161                &PolicySnapshot::conservative(),
1162                &request(&policy, &origin),
1163                &[],
1164            )
1165            .into_decision();
1166        assert!(!spec.allowed);
1167        assert_eq!(NoReviewDiff.diff(&request(&policy, &origin)), vec![]);
1168    }
1169}