1use 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
63pub const CONFIRM_OPTION_ID: &str = "confirm";
65pub const DECLINE_OPTION_ID: &str = "decline";
67
68pub mod reason {
71 pub const BLOCKED_BY_CONSTRAINT: &str = "turnframe.policy.blocked_by_constraint";
73 pub const BLOCKED_BY_MODE: &str = "turnframe.policy.blocked_by_mode";
75}
76
77#[derive(Debug, Clone)]
83pub struct PolicyRequest<'a> {
84 pub command_ref: CommandRef,
86 pub interaction_key: String,
89 pub case_ref: &'a CaseRef,
91 pub policy: Option<&'a CommandPolicy>,
94 pub origin: &'a CommandOrigin,
96 pub command: &'a serde_json::Value,
98}
99
100impl PolicyRequest<'_> {
101 #[must_use]
103 pub fn effective_policy(&self) -> CommandPolicy {
104 self.policy
105 .cloned()
106 .unwrap_or_else(CommandPolicy::conservative)
107 }
108}
109
110pub trait ReviewDiffBuilder: Send + Sync {
118 fn diff(&self, request: &PolicyRequest<'_>) -> Vec<ReviewDiffEntry>;
120}
121
122#[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#[derive(Debug, Clone, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct ConfirmationCopy {
148 pub review_title: LocalizedText,
150 pub confirm_title: LocalizedText,
152 pub reauthenticate_title: LocalizedText,
154 pub signature_title: LocalizedText,
156 pub confirm_label: LocalizedText,
158 pub decline_label: LocalizedText,
160}
161
162impl ConfirmationCopy {
163 #[must_use]
165 pub fn standard() -> Self {
166 crate::copy::ServerCopy::translated(Self::english(), "it", CONFIRMATION_ITALIAN)
167 }
168
169 #[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
210const 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
227#[serde(tag = "kind", rename_all = "snake_case")]
228#[non_exhaustive]
229pub enum BlockReason {
230 Constraint(ConstraintKind),
232 Mode {
234 risk: RiskClass,
236 },
237 ForbiddenRiskClass {
239 risk: RiskClass,
241 },
242 HumanReview,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
249#[non_exhaustive]
250pub enum PolicyOutcome {
251 Allowed {
253 decision: PolicyDecision,
255 },
256 NeedsConfirmation {
261 decision: PolicyDecision,
263 interaction: Box<InteractionSpec>,
265 },
266 Blocked {
268 decision: PolicyDecision,
270 reason: BlockReason,
272 },
273}
274
275impl PolicyOutcome {
276 #[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 #[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 #[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 #[must_use]
307 pub fn is_allowed(&self) -> bool {
308 matches!(self, Self::Allowed { .. })
309 }
310}
311
312#[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 #[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 #[must_use]
360 pub fn with_copy(mut self, copy: ConfirmationCopy) -> Self {
361 self.copy = copy;
362 self
363 }
364
365 #[must_use]
380 pub fn with_review_diff(mut self, diff: Arc<dyn ReviewDiffBuilder>) -> Self {
381 self.diff = diff;
382 self
383 }
384
385 #[must_use]
387 pub fn mode(&self) -> &OrchestrationMode {
388 &self.mode
389 }
390
391 #[must_use]
393 pub fn review_diff(&self, request: &PolicyRequest<'_>) -> Vec<ReviewDiffEntry> {
394 self.diff.diff(request)
395 }
396
397 #[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 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 #[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 let subject = subject.unwrap_or_default();
489 let title = subject.title.unwrap_or_else(|| self.copy.title_for(kind));
490 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 .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 #[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 .with_confirms_risk(RiskClass::ReversibleLowRisk)
577 .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
592fn 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
618fn 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 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 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 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 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 ("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 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 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 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}