Skip to main content

domain/
policy.rs

1//! Policy gates that decide what automation may do safely.
2//!
3//! Policy values encode the operational boundary between labor-saving automation and human review:
4//! group-play eligibility, vaccine requirements, manager approval, medical-document review,
5//! customer-message approval, and refund/deposit exceptions. These types document why an agent may
6//! draft, route, suppress, or escalate work; they do not grant permission to override local resort
7//! policy or invent availability.
8
9use nutype::nutype;
10use serde::{Deserialize, Serialize};
11
12use crate::entities::{ServiceKind, Species};
13
14/// Stable policy identifier used to reference local resort, brand, or portfolio rule sets.
15#[nutype(
16    sanitize(trim),
17    validate(not_empty, len_char_max = 120),
18    derive(
19        Debug,
20        Clone,
21        PartialEq,
22        Eq,
23        PartialOrd,
24        Ord,
25        Hash,
26        Serialize,
27        Deserialize
28    )
29)]
30pub struct Id(String);
31
32/// Vaccine name as it appears in a requirement, proof document, or source-system mapping.
33#[nutype(
34    sanitize(trim),
35    validate(not_empty, len_char_max = 80),
36    derive(
37        Debug,
38        Clone,
39        PartialEq,
40        Eq,
41        PartialOrd,
42        Ord,
43        Hash,
44        Serialize,
45        Deserialize
46    )
47)]
48pub struct VaccineName(String);
49
50/// Named workflow that an automation-safety rule governs.
51#[nutype(
52    sanitize(trim),
53    validate(not_empty, len_char_max = 120),
54    derive(
55        Debug,
56        Clone,
57        PartialEq,
58        Eq,
59        PartialOrd,
60        Ord,
61        Hash,
62        Serialize,
63        Deserialize
64    )
65)]
66pub struct WorkflowName(String);
67
68/// Automation-level policy rules that classify workflows as safe, draft-only, internal, or review-gated.
69pub mod automation {
70    use serde::{Deserialize, Serialize};
71
72    use super::WorkflowName;
73
74    /// Rationale text explaining the operational reason for an automation policy decision.
75    pub mod rationale {
76        use nutype::nutype;
77
78        #[nutype(
79            sanitize(trim),
80            validate(not_empty, len_char_max = 400),
81            derive(
82                Debug,
83                Clone,
84                PartialEq,
85                Eq,
86                PartialOrd,
87                Ord,
88                Hash,
89                Serialize,
90                Deserialize
91            )
92        )]
93        pub struct Rationale(String);
94    }
95
96    pub use rationale::Rationale;
97
98    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99    /// Automation authority level that says whether a workflow may run, draft, create internal tasks, or require approval.
100    pub enum Level {
101        /// Safe to automate outcome in the automation authority or human-review policy.
102        SafeToAutomate,
103        /// Draft only outcome in the automation authority or human-review policy.
104        DraftOnly,
105        /// Internal task only outcome in the automation authority or human-review policy.
106        InternalTaskOnly,
107        /// Manager approval required outcome in the automation authority or human-review policy.
108        ManagerApprovalRequired,
109        /// Never automate outcome in the automation authority or human-review policy.
110        NeverAutomate,
111    }
112
113    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114    /// Automation policy rule tying a workflow name to an authority level and rationale.
115    pub struct Rule {
116        /// Policy input workflow used to explain or enforce an automation gate.
117        pub workflow: WorkflowName,
118        /// Policy input level used to explain or enforce an automation gate.
119        pub level: Level,
120        /// Policy input rationale used to explain or enforce an automation gate.
121        pub rationale: Rationale,
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126/// Vaccine requirement for a species/service pair, including whether proof must come from a licensed vet source.
127pub struct VaccineRequirement {
128    /// Policy input species used to explain or enforce an automation gate.
129    pub species: Species,
130    /// Requested service that drives scheduling and labor estimates.
131    pub service: ServiceKind,
132    /// Policy input vaccines used to explain or enforce an automation gate.
133    pub vaccines: Vec<VaccineName>,
134    /// Policy input source must be licensed vet used to explain or enforce an automation gate.
135    pub source_must_be_licensed_vet: bool,
136}
137
138/// Group-play eligibility policies used to protect pet safety while avoiding unnecessary manual triage.
139pub mod play {
140    pub use eligibility::{
141        ConservativePolicy, Decision, Eligibility, IneligibilityReason, Policy, Reason,
142    };
143
144    /// Decision contract for whether a pet/service combination may enter group-play workflows.
145    pub mod eligibility {
146        use serde::{Deserialize, Serialize};
147
148        use crate::entities::{Pet, ServiceKind, SpayNeuterStatus, Species};
149
150        use super::super::ReviewGate;
151
152        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153        /// Group-play eligibility decision plus any review gate that must be satisfied first.
154        pub struct Decision {
155            /// Policy input eligibility used to explain or enforce an automation gate.
156            pub eligibility: Eligibility,
157            /// Policy input required review used to explain or enforce an automation gate.
158            pub required_review: Option<ReviewGate>,
159        }
160
161        impl Decision {
162            /// Reports whether the policy outcome allows the pet to be treated as a group-play candidate.
163            pub fn eligible_for_group_play(&self) -> bool {
164                matches!(self.eligibility, Eligibility::Eligible(_))
165            }
166        }
167
168        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169        /// Eligibility outcome produced by play-safety policy evaluation.
170        pub enum Eligibility {
171            /// Eligible outcome in the automation authority or human-review policy.
172            Eligible(Reason),
173            /// Ineligible outcome in the automation authority or human-review policy.
174            Ineligible(IneligibilityReason),
175        }
176
177        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178        /// Positive policy reason explaining why no conservative hard stop blocked the workflow.
179        pub enum Reason {
180            /// No conservative hard stop outcome in the automation authority or human-review policy.
181            NoConservativeHardStop,
182        }
183
184        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185        /// Safety or service reason that prevents group play or requires staff review.
186        pub enum IneligibilityReason {
187            /// Service does not require group play outcome in the automation authority or human-review policy.
188            ServiceDoesNotRequireGroupPlay,
189            /// Species receives individual play outcome in the automation authority or human-review policy.
190            SpeciesReceivesIndividualPlay,
191            /// Spay neuter status requires review outcome in the automation authority or human-review policy.
192            SpayNeuterStatusRequiresReview,
193            /// Behavior flags require review outcome in the automation authority or human-review policy.
194            BehaviorFlagsRequireReview,
195        }
196
197        impl std::fmt::Display for IneligibilityReason {
198            fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199                let label = match self {
200                    Self::ServiceDoesNotRequireGroupPlay => "service does not require group play",
201                    Self::SpeciesReceivesIndividualPlay => "species receives individual play",
202                    Self::SpayNeuterStatusRequiresReview => "spay/neuter status requires review",
203                    Self::BehaviorFlagsRequireReview => "behavior flags require review",
204                };
205                formatter.write_str(label)
206            }
207        }
208
209        /// Contract for policy evaluators that turn pet and service facts into explicit play-safety decisions.
210        pub trait Policy {
211            /// Returns the pet for this policy value.
212            fn decide(&self, pet: &Pet, service: &ServiceKind) -> Decision;
213        }
214
215        /// PetSuites/NVA-inspired default from public policy pages.
216        ///
217        /// This is intentionally conservative: it can route to day boarding / review, but it
218        /// should not be the final source of truth for a live location's local policy.
219        #[derive(Debug, Clone, Default)]
220        pub struct ConservativePolicy;
221
222        impl Policy for ConservativePolicy {
223            fn decide(&self, pet: &Pet, service: &ServiceKind) -> Decision {
224                if !matches!(service, ServiceKind::DayPlay | ServiceKind::Boarding) {
225                    return Decision {
226                        eligibility: Eligibility::Ineligible(
227                            IneligibilityReason::ServiceDoesNotRequireGroupPlay,
228                        ),
229                        required_review: None,
230                    };
231                }
232
233                if pet.species != Species::Dog {
234                    return Decision {
235                        eligibility: Eligibility::Ineligible(
236                            IneligibilityReason::SpeciesReceivesIndividualPlay,
237                        ),
238                        required_review: None,
239                    };
240                }
241
242                if matches!(
243                    pet.spay_neuter_status,
244                    SpayNeuterStatus::Intact | SpayNeuterStatus::Unknown
245                ) {
246                    return Decision {
247                        eligibility: Eligibility::Ineligible(
248                            IneligibilityReason::SpayNeuterStatusRequiresReview,
249                        ),
250                        required_review: Some(ReviewGate::BehaviorReview),
251                    };
252                }
253
254                if matches!(
255                    pet.temperament.group_play_observation,
256                    crate::temperament::GroupPlayObservation::StressedInGroupSetting
257                        | crate::temperament::GroupPlayObservation::NeedsIntroAssessment
258                ) || matches!(
259                    pet.temperament.rating,
260                    crate::temperament::Rating::ReviewRequired
261                ) || pet.temperament.behavior_observations.iter().any(
262                    crate::temperament::BehaviorObservation::indicates_behavior_review_evidence,
263                ) {
264                    return Decision {
265                        eligibility: Eligibility::Ineligible(
266                            IneligibilityReason::BehaviorFlagsRequireReview,
267                        ),
268                        required_review: Some(ReviewGate::BehaviorReview),
269                    };
270                }
271
272                Decision {
273                    eligibility: Eligibility::Eligible(Reason::NoConservativeHardStop),
274                    required_review: None,
275                }
276            }
277        }
278    }
279}
280
281/// Denial reasons that explain why a workflow is blocked or escalated to review.
282pub mod denial {
283    use serde::{Deserialize, Serialize};
284
285    use super::play;
286
287    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288    /// Positive policy reason explaining why no conservative hard stop blocked the workflow.
289    pub enum Reason {
290        /// Manager approval required outcome in the automation authority or human-review policy.
291        ManagerApprovalRequired,
292        /// Medical document review required outcome in the automation authority or human-review policy.
293        MedicalDocumentReviewRequired,
294        /// Behavior history requires review before service.
295        BehaviorReviewRequired,
296        /// Customer message approval required outcome in the automation authority or human-review policy.
297        CustomerMessageApprovalRequired,
298        /// Refund or deposit exception outcome in the automation authority or human-review policy.
299        RefundOrDepositException,
300        /// Play eligibility outcome in the automation authority or human-review policy.
301        PlayEligibility(play::IneligibilityReason),
302    }
303
304    impl std::fmt::Display for Reason {
305        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306            let label = match self {
307                Self::ManagerApprovalRequired => "manager approval required",
308                Self::MedicalDocumentReviewRequired => "medical document review required",
309                Self::BehaviorReviewRequired => "behavior review required",
310                Self::CustomerMessageApprovalRequired => "customer message approval required",
311                Self::RefundOrDepositException => "refund or deposit exception",
312                Self::PlayEligibility(reason) => {
313                    return write!(formatter, "play eligibility denied: {reason}");
314                }
315            };
316            formatter.write_str(label)
317        }
318    }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322/// Human review gate required before automation may proceed with sensitive work.
323pub enum ReviewGate {
324    /// Manager approval outcome in the automation authority or human-review policy.
325    ManagerApproval,
326    /// Medical document review outcome in the automation authority or human-review policy.
327    MedicalDocumentReview,
328    /// Behavior review outcome in the automation authority or human-review policy.
329    BehaviorReview,
330    /// Customer message approval outcome in the automation authority or human-review policy.
331    CustomerMessageApproval,
332    /// Refund or deposit exception outcome in the automation authority or human-review policy.
333    RefundOrDepositException,
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::entities::{self, CustomerId, Pet, PetId, SpayNeuterStatus, TemperamentProfile};
340    use crate::policy::play::Policy;
341    use crate::temperament::{BehaviorObservation, GroupPlayObservation, Rating};
342    use uuid::Uuid;
343
344    fn dog(spay_neuter_status: SpayNeuterStatus) -> Pet {
345        Pet {
346            id: PetId(Uuid::new_v4()),
347            customer_id: CustomerId(Uuid::new_v4()),
348            name: crate::pet::Name::try_new("Moose").expect("test pet name is valid"),
349            species: Species::Dog,
350            birth_date: None,
351            sex: None,
352            spay_neuter_status,
353            temperament: TemperamentProfile::default(),
354            care_profile: entities::CareProfile::default(),
355        }
356    }
357
358    #[test]
359    fn intact_dog_routes_away_from_group_play() {
360        let decision =
361            play::ConservativePolicy.decide(&dog(SpayNeuterStatus::Intact), &ServiceKind::DayPlay);
362        assert!(!decision.eligible_for_group_play());
363        assert_eq!(
364            decision.eligibility,
365            play::Eligibility::Ineligible(
366                play::IneligibilityReason::SpayNeuterStatusRequiresReview
367            )
368        );
369        assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
370    }
371
372    #[test]
373    fn neutered_dog_can_be_group_play_candidate() {
374        let decision = play::ConservativePolicy
375            .decide(&dog(SpayNeuterStatus::Neutered), &ServiceKind::DayPlay);
376        assert!(decision.eligible_for_group_play());
377        assert_eq!(
378            decision.eligibility,
379            play::Eligibility::Eligible(play::Reason::NoConservativeHardStop)
380        );
381    }
382
383    #[test]
384    fn bite_history_requires_behavior_review() {
385        let mut pet = dog(SpayNeuterStatus::Neutered);
386        pet.temperament
387            .behavior_observations
388            .push(BehaviorObservation::BiteHistory);
389
390        let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
391
392        assert!(!decision.eligible_for_group_play());
393        assert_eq!(
394            decision.eligibility,
395            play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
396        );
397        assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
398    }
399
400    #[test]
401    fn explicit_manager_review_flag_requires_behavior_review() {
402        let mut pet = dog(SpayNeuterStatus::Neutered);
403        pet.temperament
404            .behavior_observations
405            .push(BehaviorObservation::RequiresManagerReview);
406
407        let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
408
409        assert!(!decision.eligible_for_group_play());
410        assert_eq!(
411            decision.eligibility,
412            play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
413        );
414        assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
415    }
416
417    #[test]
418    fn staff_evaluation_observation_requires_behavior_review() {
419        let mut pet = dog(SpayNeuterStatus::Neutered);
420        pet.temperament.group_play_observation = GroupPlayObservation::NeedsIntroAssessment;
421
422        let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
423
424        assert!(!decision.eligible_for_group_play());
425        assert_eq!(
426            decision.eligibility,
427            play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
428        );
429        assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
430    }
431
432    #[test]
433    fn observed_group_stress_requires_behavior_review() {
434        let mut pet = dog(SpayNeuterStatus::Neutered);
435        pet.temperament.group_play_observation = GroupPlayObservation::StressedInGroupSetting;
436
437        let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
438
439        assert!(!decision.eligible_for_group_play());
440        assert_eq!(
441            decision.eligibility,
442            play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
443        );
444        assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
445    }
446
447    #[test]
448    fn comfortable_group_observation_has_no_conservative_hard_stop() {
449        let mut pet = dog(SpayNeuterStatus::Neutered);
450        pet.temperament.group_play_observation = GroupPlayObservation::ComfortableInObservedGroup;
451
452        let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
453
454        assert!(decision.eligible_for_group_play());
455        assert_eq!(
456            decision.eligibility,
457            play::Eligibility::Eligible(play::Reason::NoConservativeHardStop)
458        );
459    }
460
461    #[test]
462    fn play_eligibility_denial_reason_displays_group_play_context() {
463        let reason =
464            denial::Reason::PlayEligibility(play::IneligibilityReason::BehaviorFlagsRequireReview);
465
466        assert_eq!(
467            reason.to_string(),
468            "play eligibility denied: behavior flags require review"
469        );
470    }
471
472    #[test]
473    fn review_required_temperament_rating_requires_behavior_review() {
474        let mut pet = dog(SpayNeuterStatus::Neutered);
475        pet.temperament.rating = Rating::ReviewRequired;
476
477        let decision = play::ConservativePolicy.decide(&pet, &ServiceKind::DayPlay);
478
479        assert!(!decision.eligible_for_group_play());
480        assert_eq!(
481            decision.eligibility,
482            play::Eligibility::Ineligible(play::IneligibilityReason::BehaviorFlagsRequireReview)
483        );
484        assert_eq!(decision.required_review, Some(ReviewGate::BehaviorReview));
485    }
486}