Skip to main content

app/
data_quality_hygiene.rs

1//! Data-quality hygiene workflow rules for source-grounded internal cleanup.
2//!
3//! Crosswalk navigation: this module is the workflow-use surface for data-quality
4//! issues, source refs, hygiene candidates/actions, draft validation, and
5//! reviewed outcome capture. The bidirectional docs path is
6//! `docs/entity-atlas/contract-crosswalk/workflow-packets.md` for workflow use,
7//! `source-provider-flows.md` for source entry and normalization,
8//! `storage-persistence.md` for `DataQualityHygieneOutcomeRecord`,
9//! `runtime-exposure.md` for API/smoke exposure, and
10//! `app/tests/data_quality_hygiene_workflow_contracts.rs` plus API/storage tests
11//! for executable proof.
12
13use serde::{Deserialize, Serialize};
14
15use domain::{data_quality, entities, operations, policy, source};
16
17/// Stable Workflow name constant for the data quality hygiene layer.
18pub const WORKFLOW_NAME: &str = "data-quality-hygiene";
19/// Stable Schema version constant for the data quality hygiene layer.
20pub const SCHEMA_VERSION: &str = "data-quality-hygiene-context-v1";
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23/// Issue ref used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
24pub struct IssueRef(String);
25
26impl IssueRef {
27    /// Validates a non-zero value for the data-quality hygiene workflow before it can appear in a manager packet or outcome record.
28    pub fn try_new(value: impl Into<String>) -> Result<Self> {
29        trimmed_non_empty(value, Error::EmptyIssueRef).map(Self)
30    }
31
32    /// Returns the as str evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
39/// Action id used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
40pub struct ActionId(String);
41
42impl ActionId {
43    /// Validates a non-zero value for the data-quality hygiene workflow before it can appear in a manager packet or outcome record.
44    pub fn try_new(value: impl Into<String>) -> Result<Self> {
45        trimmed_non_empty(value, Error::EmptyActionId).map(Self)
46    }
47
48    /// Returns the as str evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55/// Context packet id used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
56pub struct ContextPacketId(String);
57
58impl ContextPacketId {
59    /// Validates a non-zero value for the data-quality hygiene workflow before it can appear in a manager packet or outcome record.
60    pub fn try_new(value: impl Into<String>) -> Result<Self> {
61        trimmed_non_empty(value, Error::EmptyContextPacketId).map(Self)
62    }
63
64    /// Returns the as str evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71/// Correlation id used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
72pub struct CorrelationId(String);
73
74impl CorrelationId {
75    /// Validates a non-zero value for the data-quality hygiene workflow before it can appear in a manager packet or outcome record.
76    pub fn try_new(value: impl Into<String>) -> Result<Self> {
77        trimmed_non_empty(value, Error::EmptyCorrelationId).map(Self)
78    }
79
80    /// Returns the as str evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
81    pub fn as_str(&self) -> &str {
82        &self.0
83    }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
87/// Action rationale used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
88pub struct ActionRationale(String);
89
90impl ActionRationale {
91    /// Validates a non-zero value for the data-quality hygiene workflow before it can appear in a manager packet or outcome record.
92    pub fn try_new(value: impl Into<String>) -> Result<Self> {
93        trimmed_non_empty(value, Error::EmptyActionRationale).map(Self)
94    }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
98/// Labor minutes used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
99pub struct LaborMinutes(u16);
100
101impl LaborMinutes {
102    /// Validates a non-zero value for the data-quality hygiene workflow before it can appear in a manager packet or outcome record.
103    pub const fn try_new(value: u16) -> Result<Self> {
104        if value == 0 {
105            return Err(Error::ZeroLaborMinutes);
106        }
107        Ok(Self(value))
108    }
109
110    /// Returns the numeric value available to data-quality hygiene review without touching provider, customer, payment, or schedule systems.
111    pub const fn get(self) -> u16 {
112        self.0
113    }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
117/// Aggregate labor minutes used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
118pub struct AggregateLaborMinutes(u16);
119
120impl AggregateLaborMinutes {
121    /// Stores the reviewed value for the data-quality hygiene workflow without triggering provider, customer, payment, or schedule side effects.
122    pub const fn new(value: u16) -> Self {
123        Self(value)
124    }
125
126    /// Returns the numeric value available to data-quality hygiene review without touching provider, customer, payment, or schedule systems.
127    pub const fn get(self) -> u16 {
128        self.0
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
133/// Decision choices for hygiene persona in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
134pub enum HygienePersona {
135    /// Selects general manager for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
136    GeneralManager,
137    /// Selects assistant general manager for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
138    AssistantGeneralManager,
139    /// Selects front desk lead for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
140    FrontDeskLead,
141    /// Selects front desk agent for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
142    FrontDeskAgent,
143    /// Selects regional operator for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
144    RegionalOperator,
145    /// Selects operations analyst for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
146    OperationsAnalyst,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
150/// Decision choices for candidate kind in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
151pub enum CandidateKind {
152    /// Selects source issue for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
153    SourceIssue,
154    /// Selects duplicate candidate for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
155    DuplicateCandidate,
156    /// Selects profile gap for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
157    ProfileGap,
158    /// Selects service line mapping for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
159    ServiceLineMapping,
160    /// Selects source freshness for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
161    SourceFreshness,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
165/// Decision choices for source freshness in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
166pub enum SourceFreshness {
167    /// Selects current for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
168    Current,
169    /// Selects stale for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
170    Stale,
171    /// Selects conflicting for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
172    Conflicting,
173    /// Selects missing for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
174    Missing,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
178/// Decision choices for sensitivity in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
179pub enum Sensitivity {
180    /// Selects standard operational evidence for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
181    StandardOperationalEvidence,
182    /// Selects vaccine evidence for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
183    VaccineEvidence,
184    /// Selects incident or behavior evidence for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
185    IncidentOrBehaviorEvidence,
186    /// Selects payment evidence for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
187    PaymentEvidence,
188    /// Selects quarantined sensitive payload for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
189    QuarantinedSensitivePayload,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
193/// Candidate used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
194pub struct Candidate {
195    id: IssueRef,
196    kind: CandidateKind,
197    issue: data_quality::Issue,
198    #[builder(default)]
199    source_record_refs: Vec<source::RecordRef>,
200    source_freshness: SourceFreshness,
201    sensitivity: Sensitivity,
202}
203
204impl Candidate {
205    /// Returns the id evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
206    pub const fn id(&self) -> &IssueRef {
207        &self.id
208    }
209
210    /// Returns the kind evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
211    pub const fn kind(&self) -> CandidateKind {
212        self.kind
213    }
214
215    /// Returns the issue evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
216    pub const fn issue(&self) -> &data_quality::Issue {
217        &self.issue
218    }
219
220    /// Returns the source record refs evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
221    pub fn source_record_refs(&self) -> &[source::RecordRef] {
222        &self.source_record_refs
223    }
224
225    /// Returns the source freshness evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
226    pub const fn source_freshness(&self) -> SourceFreshness {
227        self.source_freshness
228    }
229
230    /// Returns the sensitivity evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
231    pub const fn sensitivity(&self) -> Sensitivity {
232        self.sensitivity
233    }
234
235    fn effective_source_record_refs(&self) -> Vec<source::RecordRef> {
236        let mut refs = self.source_record_refs.clone();
237        let issue_ref = self.issue.source_record_ref().clone();
238        if !refs.contains(&issue_ref) {
239            refs.push(issue_ref);
240        }
241        refs
242    }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
246/// Decision choices for action kind in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
247pub enum ActionKind {
248    /// Selects investigate missing source evidence for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
249    InvestigateMissingSourceEvidence,
250    /// Selects reconcile duplicate customer or pet candidate for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
251    ReconcileDuplicateCustomerOrPetCandidate,
252    /// Selects complete missing pet or customer profile fields for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
253    CompleteMissingPetOrCustomerProfileFields,
254    /// Selects review stale vaccination source freshness for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
255    ReviewStaleVaccinationSourceFreshness,
256    /// Selects normalize ambiguous service line naming for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
257    NormalizeAmbiguousServiceLineNaming,
258    /// Selects review checkout or unclosed reservation evidence for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
259    ReviewCheckoutOrUnclosedReservationEvidence,
260    /// Selects escalate sensitive or quarantined payload for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
261    EscalateSensitiveOrQuarantinedPayload,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
265/// Decision choices for action priority in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
266pub enum ActionPriority {
267    /// Selects high for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
268    High,
269    /// Selects medium for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
270    Medium,
271    /// Selects low for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
272    Low,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
276/// Decision choices for removed manual work in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
277pub enum RemovedManualWork {
278    /// Selects missing evidence investigation for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
279    MissingEvidenceInvestigation,
280    /// Selects duplicate candidate reconciliation for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
281    DuplicateCandidateReconciliation,
282    /// Selects incomplete profile cleanup preparation for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
283    IncompleteProfileCleanupPreparation,
284    /// Selects source freshness review for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
285    SourceFreshnessReview,
286    /// Selects service line normalization review for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
287    ServiceLineNormalizationReview,
288    /// Selects checkout evidence review for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
289    CheckoutEvidenceReview,
290    /// Selects sensitive payload escalation for the data-quality hygiene decision model so the app can choose a review, evidence, or draft path without taking live action.
291    SensitivePayloadEscalation,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
295/// Review-safe agent tasks allowed to save staff time without crossing mutation or send gates.
296pub enum SafeAgentAction {
297    /// Allows agents to summarize source evidence for staff review without mutating records or contacting customers.
298    SummarizeSourceEvidence,
299    /// Allows agents to rank hygiene actions for staff review without mutating records or contacting customers.
300    RankHygieneActions,
301    /// Allows agents to draft internal cleanup task for staff review without mutating records or contacting customers.
302    DraftInternalCleanupTask,
303    /// Allows agents to preserve ambiguity for review for staff review without mutating records or contacting customers.
304    PreserveAmbiguityForReview,
305    /// Allows agents to estimate reconciliation minutes saved for staff review without mutating records or contacting customers.
306    EstimateReconciliationMinutesSaved,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
310/// Actions the agent must never perform without a human/operator system of record.
311pub enum BlockedAction {
312    /// Blocks agents from send customer message until staff or the system of record performs the action.
313    SendCustomerMessage,
314    /// Blocks agents from mutate provider or pms record until staff or the system of record performs the action.
315    MutateProviderOrPmsRecord,
316    /// Blocks agents from change staff schedule until staff or the system of record performs the action.
317    ChangeStaffSchedule,
318    /// Blocks agents from move refund discount or payment until staff or the system of record performs the action.
319    MoveRefundDiscountOrPayment,
320    /// Blocks agents from hide or auto resolve source ambiguity until staff or the system of record performs the action.
321    HideOrAutoResolveSourceAmbiguity,
322    /// Blocks agents from expose quarantined sensitive payload until staff or the system of record performs the action.
323    ExposeQuarantinedSensitivePayload,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327/// Labor impact estimate used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
328pub struct LaborImpactEstimate {
329    before_minutes: LaborMinutes,
330    after_minutes: LaborMinutes,
331}
332
333impl LaborImpactEstimate {
334    /// Stores the reviewed value for the data-quality hygiene workflow without triggering provider, customer, payment, or schedule side effects.
335    pub const fn new(before_minutes: LaborMinutes, after_minutes: LaborMinutes) -> Self {
336        Self {
337            before_minutes,
338            after_minutes,
339        }
340    }
341
342    /// Returns the before minutes evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
343    pub const fn before_minutes(&self) -> LaborMinutes {
344        self.before_minutes
345    }
346
347    /// Returns the after minutes evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
348    pub const fn after_minutes(&self) -> LaborMinutes {
349        self.after_minutes
350    }
351
352    /// Returns the minutes saved evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
353    pub const fn minutes_saved(&self) -> u16 {
354        self.before_minutes.0.saturating_sub(self.after_minutes.0)
355    }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
359/// Action used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
360pub struct Action {
361    id: ActionId,
362    kind: ActionKind,
363    priority: ActionPriority,
364    owner_persona: HygienePersona,
365    removed_manual_work: RemovedManualWork,
366    rationale: ActionRationale,
367    #[builder(default)]
368    source_record_refs: Vec<source::RecordRef>,
369    #[builder(default)]
370    issue_refs: Vec<IssueRef>,
371    #[builder(default)]
372    required_review_gates: Vec<policy::ReviewGate>,
373    labor_impact: LaborImpactEstimate,
374}
375
376impl Action {
377    /// Returns the id evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
378    pub const fn id(&self) -> &ActionId {
379        &self.id
380    }
381
382    /// Returns the kind evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
383    pub const fn kind(&self) -> ActionKind {
384        self.kind
385    }
386
387    /// Returns the priority evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
388    pub const fn priority(&self) -> ActionPriority {
389        self.priority
390    }
391
392    /// Returns the owner persona evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
393    pub const fn owner_persona(&self) -> HygienePersona {
394        self.owner_persona
395    }
396
397    /// Returns the removed manual work evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
398    pub const fn removed_manual_work(&self) -> RemovedManualWork {
399        self.removed_manual_work
400    }
401
402    /// Returns the rationale evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
403    pub const fn rationale(&self) -> &ActionRationale {
404        &self.rationale
405    }
406
407    /// Returns the source record refs evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
408    pub fn source_record_refs(&self) -> &[source::RecordRef] {
409        &self.source_record_refs
410    }
411
412    /// Returns the issue refs evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
413    pub fn issue_refs(&self) -> &[IssueRef] {
414        &self.issue_refs
415    }
416
417    /// Returns the required review gates evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
418    pub fn required_review_gates(&self) -> &[policy::ReviewGate] {
419        &self.required_review_gates
420    }
421
422    /// Returns the labor impact evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
423    pub const fn labor_impact(&self) -> &LaborImpactEstimate {
424        &self.labor_impact
425    }
426
427    /// Reports whether the data-quality hygiene workflow satisfies the is source grounded safety condition.
428    pub fn is_source_grounded(&self) -> bool {
429        !self.source_record_refs.is_empty() && !self.issue_refs.is_empty()
430    }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
434/// Input rules for building the workflow packet from source-grounded records.
435pub struct Request {
436    location_id: entities::LocationId,
437    operating_day: operations::operating_day::Date,
438    prepared_for: HygienePersona,
439    #[builder(default)]
440    candidates: Vec<Candidate>,
441}
442
443impl Request {
444    /// Returns the location id evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
445    pub const fn location_id(&self) -> entities::LocationId {
446        self.location_id
447    }
448
449    /// Returns the operating day evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
450    pub const fn operating_day(&self) -> operations::operating_day::Date {
451        self.operating_day
452    }
453
454    /// Returns the prepared for evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
455    pub const fn prepared_for(&self) -> HygienePersona {
456        self.prepared_for
457    }
458
459    /// Returns the candidates evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
460    pub fn candidates(&self) -> &[Candidate] {
461        &self.candidates
462    }
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466/// Reviewable packet handed to staff or agents with deterministic gates already applied.
467pub struct Packet {
468    workflow: &'static str,
469    schema_version: &'static str,
470    context_packet_id: ContextPacketId,
471    correlation_id: CorrelationId,
472    location_id: entities::LocationId,
473    operating_day: operations::operating_day::Date,
474    prepared_for: HygienePersona,
475    candidates: Vec<Candidate>,
476    actions: Vec<Action>,
477    safe_agent_actions: Vec<SafeAgentAction>,
478    blocked_actions: Vec<BlockedAction>,
479    before_minutes: AggregateLaborMinutes,
480    after_minutes: AggregateLaborMinutes,
481}
482
483impl Packet {
484    /// Returns the workflow evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
485    pub const fn workflow(&self) -> &'static str {
486        self.workflow
487    }
488
489    /// Returns the schema version evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
490    pub const fn schema_version(&self) -> &'static str {
491        self.schema_version
492    }
493
494    /// Returns the context packet id evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
495    pub const fn context_packet_id(&self) -> &ContextPacketId {
496        &self.context_packet_id
497    }
498
499    /// Returns the correlation id evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
500    pub const fn correlation_id(&self) -> &CorrelationId {
501        &self.correlation_id
502    }
503
504    /// Returns the location id evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
505    pub const fn location_id(&self) -> entities::LocationId {
506        self.location_id
507    }
508
509    /// Returns the operating day evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
510    pub const fn operating_day(&self) -> operations::operating_day::Date {
511        self.operating_day
512    }
513
514    /// Returns the prepared for evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
515    pub const fn prepared_for(&self) -> HygienePersona {
516        self.prepared_for
517    }
518
519    /// Returns the candidates evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
520    pub fn candidates(&self) -> &[Candidate] {
521        &self.candidates
522    }
523
524    /// Returns the actions evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
525    pub fn actions(&self) -> &[Action] {
526        &self.actions
527    }
528
529    /// Returns the safe agent actions evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
530    pub fn safe_agent_actions(&self) -> &[SafeAgentAction] {
531        &self.safe_agent_actions
532    }
533
534    /// Returns the blocked actions evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
535    pub fn blocked_actions(&self) -> &[BlockedAction] {
536        &self.blocked_actions
537    }
538
539    /// Returns the before minutes evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
540    pub const fn before_minutes(&self) -> AggregateLaborMinutes {
541        self.before_minutes
542    }
543
544    /// Returns the after minutes evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
545    pub const fn after_minutes(&self) -> AggregateLaborMinutes {
546        self.after_minutes
547    }
548
549    /// Returns the minutes saved evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
550    pub const fn minutes_saved(&self) -> u16 {
551        self.before_minutes.0.saturating_sub(self.after_minutes.0)
552    }
553
554    /// Returns the all actions are source grounded evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
555    pub fn all_actions_are_source_grounded(&self) -> bool {
556        self.actions.iter().all(Action::is_source_grounded)
557    }
558
559    /// Returns the validate draft evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
560    pub fn validate_draft(&self, draft: &DraftSubmission) -> DraftValidation {
561        let mut rejection_reasons = Vec::new();
562
563        if draft.context_packet_id != self.context_packet_id
564            || draft.correlation_id != self.correlation_id
565        {
566            rejection_reasons.push(DraftRejectionReason::StaleOrUnknownContextPacket);
567        }
568
569        for action in &draft.actions {
570            validate_draft_action(self, action, &mut rejection_reasons);
571        }
572
573        rejection_reasons.dedup();
574        DraftValidation { rejection_reasons }
575    }
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579/// Draft action used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
580pub struct DraftAction {
581    action_id: ActionId,
582    kind: ActionKind,
583    source_record_refs: Vec<source::RecordRef>,
584    issue_refs: Vec<IssueRef>,
585    required_review_gates: Vec<policy::ReviewGate>,
586    requested_side_effects: Vec<String>,
587    attempted_ambiguity_resolution: bool,
588}
589
590impl DraftAction {
591    /// Builds the from action result for the data-quality hygiene workflow from reviewed source facts while preserving human review gates and draft-only side effects.
592    pub fn from_action(action: Action) -> Self {
593        Self {
594            action_id: action.id,
595            kind: action.kind,
596            source_record_refs: action.source_record_refs,
597            issue_refs: action.issue_refs,
598            required_review_gates: action.required_review_gates,
599            requested_side_effects: Vec::new(),
600            attempted_ambiguity_resolution: false,
601        }
602    }
603
604    /// Returns the with requested side effect evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
605    pub fn with_requested_side_effect(mut self, side_effect: impl Into<String>) -> Self {
606        self.requested_side_effects.push(side_effect.into());
607        self
608    }
609
610    /// Returns the with attempted ambiguity resolution evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
611    pub const fn with_attempted_ambiguity_resolution(mut self) -> Self {
612        self.attempted_ambiguity_resolution = true;
613        self
614    }
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
618/// Draft submission used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
619pub struct DraftSubmission {
620    context_packet_id: ContextPacketId,
621    correlation_id: CorrelationId,
622    #[builder(default)]
623    actions: Vec<DraftAction>,
624}
625
626#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
627/// Draft validation used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
628pub struct DraftValidation {
629    rejection_reasons: Vec<DraftRejectionReason>,
630}
631
632impl DraftValidation {
633    /// Reports whether the data-quality hygiene workflow satisfies the is accepted safety condition.
634    pub fn is_accepted(&self) -> bool {
635        self.rejection_reasons.is_empty()
636    }
637
638    /// Returns the rejection reasons evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
639    pub fn rejection_reasons(&self) -> &[DraftRejectionReason] {
640        &self.rejection_reasons
641    }
642}
643
644#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
645/// Decision choices for draft rejection reason in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
646pub enum DraftRejectionReason {
647    /// Uses stale or unknown context packet as source-grounded evidence for the deterministic decision.
648    StaleOrUnknownContextPacket,
649    /// Uses unsupported action kind as source-grounded evidence for the deterministic decision.
650    UnsupportedActionKind,
651    /// Uses missing source refs as source-grounded evidence for the deterministic decision.
652    MissingSourceRefs,
653    /// Uses source refs not present in context packet as source-grounded evidence for the deterministic decision.
654    SourceRefsNotPresentInContextPacket,
655    /// Uses missing data quality issue refs as source-grounded evidence for the deterministic decision.
656    MissingDataQualityIssueRefs,
657    /// Uses wrong review gate as source-grounded evidence for the deterministic decision.
658    WrongReviewGate,
659    /// Uses blocked side effect requested as source-grounded evidence for the deterministic decision.
660    BlockedSideEffectRequested,
661    /// Uses unsupported side effect requested as source-grounded evidence for the deterministic decision.
662    UnsupportedSideEffectRequested,
663    /// Uses attempted ambiguity hiding as source-grounded evidence for the deterministic decision.
664    AttemptedAmbiguityHiding,
665    /// Uses sensitive payload exposure attempted as source-grounded evidence for the deterministic decision.
666    SensitivePayloadExposureAttempted,
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
670/// Decision choices for feedback outcome in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
671pub enum FeedbackOutcome {
672    /// Records a completed result so follow-up impact is auditable.
673    Completed,
674    /// Records a deferred result so follow-up impact is auditable.
675    Deferred,
676    /// Records a suppressed by manager result so follow-up impact is auditable.
677    SuppressedByManager,
678    /// Records a source fact was wrong result so follow-up impact is auditable.
679    SourceFactWasWrong,
680    /// Records a not actionable result so follow-up impact is auditable.
681    NotActionable,
682}
683
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
685/// Outcome record used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
686pub struct OutcomeRecord {
687    action_id: ActionId,
688    recorded_by: entities::ActorRef,
689    outcome: FeedbackOutcome,
690    before_minutes: LaborMinutes,
691    actual_minutes: LaborMinutes,
692    #[builder(default)]
693    source_record_refs: Vec<source::RecordRef>,
694    #[builder(default)]
695    issue_refs: Vec<IssueRef>,
696    reviewed_resolution_status: Option<data_quality::ResolutionStatus>,
697}
698
699impl OutcomeRecord {
700    /// Returns the action id evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
701    pub const fn action_id(&self) -> &ActionId {
702        &self.action_id
703    }
704
705    /// Returns the recorded by evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
706    pub const fn recorded_by(&self) -> &entities::ActorRef {
707        &self.recorded_by
708    }
709
710    /// Returns the outcome evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
711    pub const fn outcome(&self) -> FeedbackOutcome {
712        self.outcome
713    }
714
715    /// Returns the before minutes evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
716    pub const fn before_minutes(&self) -> LaborMinutes {
717        self.before_minutes
718    }
719
720    /// Returns the actual minutes evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
721    pub const fn actual_minutes(&self) -> LaborMinutes {
722        self.actual_minutes
723    }
724
725    /// Returns the actual minutes saved evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
726    pub const fn actual_minutes_saved(&self) -> u16 {
727        self.before_minutes.0.saturating_sub(self.actual_minutes.0)
728    }
729
730    /// Returns the source record refs evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
731    pub fn source_record_refs(&self) -> &[source::RecordRef] {
732        &self.source_record_refs
733    }
734
735    /// Returns the issue refs evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
736    pub fn issue_refs(&self) -> &[IssueRef] {
737        &self.issue_refs
738    }
739
740    /// Returns the reviewed resolution status evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
741    pub const fn reviewed_resolution_status(&self) -> Option<data_quality::ResolutionStatus> {
742        self.reviewed_resolution_status
743    }
744
745    /// Returns the records feedback without external mutation evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
746    pub fn records_feedback_without_external_mutation(&self) -> bool {
747        true
748    }
749
750    /// Returns the blocked actions evidence available to data-quality hygiene review while leaving provider, customer, payment, and schedule systems unchanged.
751    pub fn blocked_actions(&self) -> Vec<BlockedAction> {
752        blocked_actions_for()
753    }
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
757/// Decision choices for error in the data-quality hygiene workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
758pub enum Error {
759    #[error("issue ref cannot be empty")]
760    /// Identifies empty issue ref as the reason the workflow must stop, retry, or request review.
761    EmptyIssueRef,
762    #[error("action id cannot be empty")]
763    /// Identifies empty action id as the reason the workflow must stop, retry, or request review.
764    EmptyActionId,
765    #[error("context packet id cannot be empty")]
766    /// Identifies empty context packet id as the reason the workflow must stop, retry, or request review.
767    EmptyContextPacketId,
768    #[error("correlation id cannot be empty")]
769    /// Identifies empty correlation id as the reason the workflow must stop, retry, or request review.
770    EmptyCorrelationId,
771    #[error("action rationale cannot be empty")]
772    /// Identifies empty action rationale as the reason the workflow must stop, retry, or request review.
773    EmptyActionRationale,
774    #[error("labor minutes must be greater than zero")]
775    /// Identifies zero labor minutes as the reason the workflow must stop, retry, or request review.
776    ZeroLaborMinutes,
777}
778
779/// Result type returned by fallible data quality hygiene operations.
780pub type Result<T> = std::result::Result<T, Error>;
781
782#[derive(Debug, Clone, Copy, PartialEq, Eq)]
783/// Workflow used by the data-quality hygiene workflow; it finds duplicate, stale, or inconsistent records while blocking automatic provider-system mutation.
784pub struct Workflow;
785
786impl Workflow {
787    /// Builds the evaluate result for the data-quality hygiene workflow from reviewed source facts while preserving human review gates and draft-only side effects.
788    pub fn evaluate(request: Request) -> Packet {
789        let actions = request
790            .candidates
791            .iter()
792            .map(action_for_candidate)
793            .collect::<Vec<_>>();
794        let before_minutes = total_before_minutes(&actions);
795        let after_minutes = total_after_minutes(&actions);
796
797        Packet {
798            workflow: WORKFLOW_NAME,
799            schema_version: SCHEMA_VERSION,
800            context_packet_id: ContextPacketId::try_new(format!(
801                "data-quality-hygiene-context:{:?}:{:?}",
802                request.location_id, request.operating_day
803            ))
804            .expect("formatted context packet id is non-empty"),
805            correlation_id: CorrelationId::try_new(format!(
806                "data-quality-hygiene:{:?}:{:?}",
807                request.location_id, request.operating_day
808            ))
809            .expect("formatted correlation id is non-empty"),
810            location_id: request.location_id,
811            operating_day: request.operating_day,
812            prepared_for: request.prepared_for,
813            candidates: request.candidates,
814            actions,
815            safe_agent_actions: safe_agent_actions_for(),
816            blocked_actions: blocked_actions_for(),
817            before_minutes,
818            after_minutes,
819        }
820    }
821}
822
823fn action_for_candidate(candidate: &Candidate) -> Action {
824    let (kind, owner_persona, removed_manual_work, before, after) = action_shape_for(candidate);
825    Action::builder()
826        .id(
827            ActionId::try_new(format!("dq-action-{}", candidate.id().as_str()))
828                .expect("candidate ids are non-empty"),
829        )
830        .kind(kind)
831        .priority(priority_for(candidate))
832        .owner_persona(owner_persona)
833        .removed_manual_work(removed_manual_work)
834        .rationale(rationale_for(candidate))
835        .source_record_refs(candidate.effective_source_record_refs())
836        .issue_refs(vec![candidate.id.clone()])
837        .required_review_gates(review_gates_for(candidate))
838        .labor_impact(LaborImpactEstimate::new(
839            LaborMinutes::try_new(before).expect("static before minutes are valid"),
840            LaborMinutes::try_new(after).expect("static after minutes are valid"),
841        ))
842        .build()
843}
844
845fn action_shape_for(
846    candidate: &Candidate,
847) -> (ActionKind, HygienePersona, RemovedManualWork, u16, u16) {
848    if candidate.sensitivity == Sensitivity::QuarantinedSensitivePayload {
849        return (
850            ActionKind::EscalateSensitiveOrQuarantinedPayload,
851            HygienePersona::GeneralManager,
852            RemovedManualWork::SensitivePayloadEscalation,
853            20,
854            8,
855        );
856    }
857
858    match candidate.issue.kind() {
859        data_quality::Kind::MissingVaccinationRecord => (
860            ActionKind::ReviewStaleVaccinationSourceFreshness,
861            HygienePersona::FrontDeskLead,
862            RemovedManualWork::SourceFreshnessReview,
863            25,
864            10,
865        ),
866        data_quality::Kind::DuplicateSourceRecord => (
867            ActionKind::ReconcileDuplicateCustomerOrPetCandidate,
868            HygienePersona::GeneralManager,
869            RemovedManualWork::DuplicateCandidateReconciliation,
870            30,
871            12,
872        ),
873        data_quality::Kind::IncompletePetProfile
874        | data_quality::Kind::AmbiguousOwnerPetRelationship => (
875            ActionKind::CompleteMissingPetOrCustomerProfileFields,
876            HygienePersona::FrontDeskLead,
877            RemovedManualWork::IncompleteProfileCleanupPreparation,
878            20,
879            7,
880        ),
881        data_quality::Kind::UnmappedServiceType | data_quality::Kind::LocationScopeAmbiguity => (
882            ActionKind::NormalizeAmbiguousServiceLineNaming,
883            HygienePersona::GeneralManager,
884            RemovedManualWork::ServiceLineNormalizationReview,
885            20,
886            6,
887        ),
888        data_quality::Kind::CheckoutEvidenceMissing | data_quality::Kind::UnclosedReservation => (
889            ActionKind::ReviewCheckoutOrUnclosedReservationEvidence,
890            HygienePersona::FrontDeskLead,
891            RemovedManualWork::CheckoutEvidenceReview,
892            20,
893            8,
894        ),
895        data_quality::Kind::SensitivePayloadQuarantined => (
896            ActionKind::EscalateSensitiveOrQuarantinedPayload,
897            HygienePersona::GeneralManager,
898            RemovedManualWork::SensitivePayloadEscalation,
899            20,
900            8,
901        ),
902        data_quality::Kind::MissingRequiredField { .. }
903        | data_quality::Kind::AssumptionInForce { .. }
904        | data_quality::Kind::UnknownSourceStatus { .. }
905        | data_quality::Kind::ConflictingTimestamps
906        | data_quality::Kind::PaymentStateConflict => (
907            ActionKind::InvestigateMissingSourceEvidence,
908            HygienePersona::FrontDeskLead,
909            RemovedManualWork::MissingEvidenceInvestigation,
910            25,
911            8,
912        ),
913    }
914}
915
916fn priority_for(candidate: &Candidate) -> ActionPriority {
917    match candidate.issue.severity() {
918        data_quality::Severity::Critical | data_quality::Severity::Blocking => ActionPriority::High,
919        data_quality::Severity::Warning => ActionPriority::Medium,
920        data_quality::Severity::Informational => ActionPriority::Low,
921    }
922}
923
924fn rationale_for(candidate: &Candidate) -> ActionRationale {
925    let text = match candidate.issue.kind() {
926        data_quality::Kind::MissingVaccinationRecord => {
927            "Route stale or missing vaccination source evidence to staff review while preserving ambiguity; this workflow does not approve service eligibility or send the customer a message."
928        }
929        data_quality::Kind::DuplicateSourceRecord => {
930            "Prepare a source-grounded duplicate candidate for manager review without merging or mutating provider records."
931        }
932        data_quality::Kind::UnmappedServiceType => {
933            "Prepare ambiguous service-line naming for manager review before reporting or labor automation consumes the source value."
934        }
935        data_quality::Kind::SensitivePayloadQuarantined => {
936            "Escalate quarantined sensitive evidence as metadata only; do not expose raw payload contents to the agent."
937        }
938        _ => {
939            "Prepare a source-grounded internal data-quality hygiene task for human review without hiding ambiguity or mutating source systems."
940        }
941    };
942    ActionRationale::try_new(text).expect("static rationale is valid")
943}
944
945fn review_gates_for(candidate: &Candidate) -> Vec<policy::ReviewGate> {
946    match candidate.issue.kind() {
947        data_quality::Kind::MissingVaccinationRecord => vec![policy::ReviewGate::ManagerApproval],
948        data_quality::Kind::SensitivePayloadQuarantined => {
949            vec![policy::ReviewGate::ManagerApproval]
950        }
951        data_quality::Kind::PaymentStateConflict => vec![
952            policy::ReviewGate::ManagerApproval,
953            policy::ReviewGate::RefundOrDepositException,
954        ],
955        _ if matches!(
956            candidate.issue.severity(),
957            data_quality::Severity::Blocking | data_quality::Severity::Critical
958        ) =>
959        {
960            vec![policy::ReviewGate::ManagerApproval]
961        }
962        _ => vec![policy::ReviewGate::ManagerApproval],
963    }
964}
965
966fn validate_draft_action(
967    packet: &Packet,
968    action: &DraftAction,
969    rejection_reasons: &mut Vec<DraftRejectionReason>,
970) {
971    if action.source_record_refs.is_empty() {
972        rejection_reasons.push(DraftRejectionReason::MissingSourceRefs);
973    }
974    if action.issue_refs.is_empty() {
975        rejection_reasons.push(DraftRejectionReason::MissingDataQualityIssueRefs);
976    }
977    if action.attempted_ambiguity_resolution {
978        rejection_reasons.push(DraftRejectionReason::AttemptedAmbiguityHiding);
979    }
980
981    if action
982        .source_record_refs
983        .iter()
984        .any(|source_ref| !packet_has_source_ref(packet, source_ref))
985    {
986        rejection_reasons.push(DraftRejectionReason::SourceRefsNotPresentInContextPacket);
987    }
988
989    let matching_packet_action = packet.actions.iter().find(|packet_action| {
990        packet_action.id == action.action_id && packet_action.kind == action.kind
991    });
992    match matching_packet_action {
993        Some(packet_action)
994            if packet_action.required_review_gates != action.required_review_gates =>
995        {
996            rejection_reasons.push(DraftRejectionReason::WrongReviewGate);
997        }
998        Some(_) => {}
999        None => rejection_reasons.push(DraftRejectionReason::UnsupportedActionKind),
1000    }
1001
1002    for side_effect in &action.requested_side_effects {
1003        match classify_requested_side_effect(side_effect.as_str()) {
1004            RequestedSideEffect::KnownBlocked => {
1005                rejection_reasons.push(DraftRejectionReason::BlockedSideEffectRequested)
1006            }
1007            RequestedSideEffect::Unsupported => {
1008                rejection_reasons.push(DraftRejectionReason::UnsupportedSideEffectRequested)
1009            }
1010        }
1011    }
1012}
1013
1014fn packet_has_source_ref(packet: &Packet, source_ref: &source::RecordRef) -> bool {
1015    packet
1016        .candidates
1017        .iter()
1018        .flat_map(Candidate::effective_source_record_refs)
1019        .any(|packet_ref| packet_ref == *source_ref)
1020}
1021
1022enum RequestedSideEffect {
1023    KnownBlocked,
1024    Unsupported,
1025}
1026
1027fn classify_requested_side_effect(side_effect: &str) -> RequestedSideEffect {
1028    match side_effect.trim() {
1029        "send_customer_message"
1030        | "mutate_provider_or_pms_record"
1031        | "change_staff_schedule"
1032        | "move_refund_discount_or_payment"
1033        | "hide_or_auto_resolve_source_ambiguity"
1034        | "expose_quarantined_sensitive_payload" => RequestedSideEffect::KnownBlocked,
1035        _ => RequestedSideEffect::Unsupported,
1036    }
1037}
1038
1039fn total_before_minutes(actions: &[Action]) -> AggregateLaborMinutes {
1040    AggregateLaborMinutes::new(
1041        actions
1042            .iter()
1043            .map(|action| action.labor_impact.before_minutes().get())
1044            .sum::<u16>(),
1045    )
1046}
1047
1048fn total_after_minutes(actions: &[Action]) -> AggregateLaborMinutes {
1049    AggregateLaborMinutes::new(
1050        actions
1051            .iter()
1052            .map(|action| action.labor_impact.after_minutes().get())
1053            .sum::<u16>(),
1054    )
1055}
1056
1057fn safe_agent_actions_for() -> Vec<SafeAgentAction> {
1058    vec![
1059        SafeAgentAction::SummarizeSourceEvidence,
1060        SafeAgentAction::RankHygieneActions,
1061        SafeAgentAction::DraftInternalCleanupTask,
1062        SafeAgentAction::PreserveAmbiguityForReview,
1063        SafeAgentAction::EstimateReconciliationMinutesSaved,
1064    ]
1065}
1066
1067fn blocked_actions_for() -> Vec<BlockedAction> {
1068    vec![
1069        BlockedAction::SendCustomerMessage,
1070        BlockedAction::MutateProviderOrPmsRecord,
1071        BlockedAction::ChangeStaffSchedule,
1072        BlockedAction::MoveRefundDiscountOrPayment,
1073        BlockedAction::HideOrAutoResolveSourceAmbiguity,
1074        BlockedAction::ExposeQuarantinedSensitivePayload,
1075    ]
1076}
1077
1078fn trimmed_non_empty(value: impl Into<String>, empty_error: Error) -> Result<String> {
1079    let value = value.into().trim().to_owned();
1080    if value.is_empty() {
1081        Err(empty_error)
1082    } else {
1083        Ok(value)
1084    }
1085}