Skip to main content

app/
daily_update.rs

1use bon::Builder;
2use chrono::{DateTime, Utc};
3use nutype::nutype;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::collections::BTreeMap;
6use uuid::Uuid;
7
8use crate::agents;
9use crate::agents::WorkflowAgent;
10use domain::{agent, audit, customer, entities, message, pet, policy, workflow};
11
12#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
13/// Decision choices for error in the daily update workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
14pub enum Error {
15    #[error("daily update preview requires a DailyNoteCreated or DailyUpdateNeeded workflow event")]
16    /// Identifies unsupported workflow event as the reason the workflow must stop, retry, or request review.
17    UnsupportedWorkflowEvent,
18    #[error("daily update preview requires at least one staff note")]
19    /// Identifies missing staff notes as the reason the workflow must stop, retry, or request review.
20    MissingStaffNotes,
21    #[error("daily update preview requires at least one policy-allowed draft/summarize action")]
22    /// Identifies missing allowed action as the reason the workflow must stop, retry, or request review.
23    MissingAllowedAction,
24    #[error("daily update preview could not build a validated domain value: {0}")]
25    /// Identifies invalid domain value as the reason the workflow must stop, retry, or request review.
26    InvalidDomainValue(String),
27}
28
29/// Result type returned by fallible daily update operations.
30pub type Result<T> = core::result::Result<T, Error>;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
33/// Mvp preview request used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
34pub struct MvpPreviewRequest {
35    /// Event copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
36    pub event: workflow::Event,
37    /// Pet name copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
38    pub pet_name: pet::Name,
39    /// Owner display name copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
40    pub owner_display_name: customer::Name,
41    /// Policy snapshot id copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
42    pub policy_snapshot_id: policy::Id,
43    /// Notes copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
44    pub notes: Vec<entities::CareNote>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48/// Mvp preview used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
49pub struct MvpPreview {
50    /// Agent packet copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
51    pub agent_packet: agents::AgentPromptPacket<daily_care_update::Input>,
52    /// Output copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
53    pub output: daily_care_update::Output,
54    /// Owner message draft copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
55    pub owner_message_draft: CustomerMessageDraft,
56    /// Approval copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
57    pub approval: entities::approval::Record,
58    /// Send stub copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
59    pub send_stub: SendStub,
60    /// Audit log copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
61    pub audit_log: Vec<entities::audit::Event>,
62}
63
64/// Daily care notes prepared for staff review before they become operational updates.
65pub mod daily_care_update {
66    use serde::{Deserialize, Serialize};
67
68    use super::{
69        CustomerMessageDraft, IncludedFact, InternalFlag, OmittedFact, ReviewDisposition, customer,
70        entities, pet, policy,
71    };
72
73    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74    /// Input used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
75    pub struct Input {
76        /// Pet name copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
77        pub pet_name: pet::Name,
78        /// Owner display name copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
79        pub owner_display_name: customer::Name,
80        /// Policy snapshot id copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
81        pub policy_snapshot_id: policy::Id,
82        /// Notes copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
83        pub notes: Vec<entities::CareNote>,
84    }
85
86    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87    /// Output used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
88    pub struct Output {
89        /// Customer message copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
90        pub customer_message: CustomerMessageDraft,
91        /// Internal flags copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
92        pub internal_flags: Vec<InternalFlag>,
93        #[serde(flatten)]
94        /// Disposition copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
95        pub disposition: ReviewDisposition,
96        /// Included facts copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
97        pub included_facts: Vec<IncludedFact>,
98        /// Omitted facts copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
99        pub omitted_facts: Vec<OmittedFact>,
100    }
101
102    #[derive(Debug, Clone, Copy)]
103    /// Agent used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
104    pub struct Agent;
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108/// Decision choices for review disposition in the daily update workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
109pub enum ReviewDisposition {
110    /// Reason copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
111    DraftOnlyRequiresReview {
112        /// Reason value stored on this variant.
113        reason: ReviewReason,
114    },
115}
116
117impl ReviewDisposition {
118    /// Returns the allows live send evidence available to daily update review while leaving provider, customer, payment, and schedule systems unchanged.
119    pub const fn allows_live_send(&self) -> bool {
120        false
121    }
122
123    /// Reports whether the daily update workflow satisfies the requires human review safety condition.
124    pub const fn requires_human_review(&self) -> bool {
125        true
126    }
127
128    /// Returns the review reason evidence available to daily update review while leaving provider, customer, payment, and schedule systems unchanged.
129    pub const fn review_reason(&self) -> &ReviewReason {
130        match self {
131            Self::DraftOnlyRequiresReview { reason } => reason,
132        }
133    }
134}
135
136impl Serialize for ReviewDisposition {
137    fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
138    where
139        S: Serializer,
140    {
141        #[derive(Serialize)]
142        struct Wire<'a> {
143            should_send: bool,
144            requires_review: bool,
145            review_reason: &'a ReviewReason,
146        }
147
148        Wire {
149            should_send: self.allows_live_send(),
150            requires_review: self.requires_human_review(),
151            review_reason: self.review_reason(),
152        }
153        .serialize(serializer)
154    }
155}
156
157impl<'de> Deserialize<'de> for ReviewDisposition {
158    fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
159    where
160        D: Deserializer<'de>,
161    {
162        #[derive(Deserialize)]
163        struct Wire {
164            should_send: bool,
165            requires_review: bool,
166            review_reason: Option<ReviewReason>,
167        }
168
169        let wire = Wire::deserialize(deserializer)?;
170        match (wire.should_send, wire.requires_review, wire.review_reason) {
171            (false, true, Some(reason)) => Ok(Self::DraftOnlyRequiresReview { reason }),
172            _ => Err(serde::de::Error::custom(
173                "daily care update v1 output must remain a draft-only review-required disposition",
174            )),
175        }
176    }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180/// Customer message draft used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
181pub struct CustomerMessageDraft {
182    /// Body ref copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
183    pub body_ref: message::BodyRef,
184    /// Channel hint copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
185    pub channel_hint: message::Channel,
186    /// Language copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
187    pub language: LanguageTag,
188    /// Tone copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
189    pub tone: ToneLabel,
190    /// Audience copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
191    pub audience: Audience,
192    /// Redaction profile copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
193    pub redaction_profile: RedactionProfile,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197/// Decision choices for audience in the daily update workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
198pub enum Audience {
199    /// Selects customer for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
200    Customer,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204/// Decision choices for internal flag code in the daily update workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
205pub enum InternalFlagCode {
206    /// Uses customer message approval not configured as source-grounded evidence for the deterministic decision.
207    CustomerMessageApprovalNotConfigured,
208    /// Uses raw internal note not customer safe as source-grounded evidence for the deterministic decision.
209    RawInternalNoteNotCustomerSafe,
210    /// Uses behavior review required as source-grounded evidence for the deterministic decision.
211    BehaviorReviewRequired,
212    /// Uses medical or medication review required as source-grounded evidence for the deterministic decision.
213    MedicalOrMedicationReviewRequired,
214    /// Uses policy gap requires review as source-grounded evidence for the deterministic decision.
215    PolicyGapRequiresReview,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219/// Decision choices for internal flag severity in the daily update workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
220pub enum InternalFlagSeverity {
221    /// Selects info for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
222    Info,
223    /// Selects needs staff review for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
224    NeedsStaffReview,
225    /// Selects needs manager review for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
226    NeedsManagerReview,
227    /// Selects do not send for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
228    DoNotSend,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232/// Decision choices for recommended flag action in the daily update workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
233pub enum RecommendedFlagAction {
234    /// Selects staff review for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
235    StaffReview,
236    /// Selects manager review for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
237    ManagerReview,
238    /// Selects suppress update for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
239    SuppressUpdate,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243/// Internal flag used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
244pub struct InternalFlag {
245    /// Code copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
246    pub code: InternalFlagCode,
247    /// Severity copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
248    pub severity: InternalFlagSeverity,
249    /// Message copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
250    pub message: FlagMessage,
251    /// Source note ids copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
252    pub source_note_ids: Vec<entities::care_note::Id>,
253    /// Recommended action copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
254    pub recommended_action: RecommendedFlagAction,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258/// Included fact used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
259pub struct IncludedFact {
260    /// Source note id copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
261    pub source_note_id: entities::care_note::Id,
262    /// Summary copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
263    pub summary: FactSummary,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267/// Omitted fact used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
268pub struct OmittedFact {
269    /// Source note id copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
270    pub source_note_id: entities::care_note::Id,
271    /// Reason copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
272    pub reason: OmissionReason,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
276/// Decision choices for omission reason in the daily update workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
277pub enum OmissionReason {
278    /// Uses internal only as source-grounded evidence for the deterministic decision.
279    InternalOnly,
280    /// Uses sensitive requires review as source-grounded evidence for the deterministic decision.
281    SensitiveRequiresReview,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285/// Send stub used by the daily update workflow; it packages operational changes into reviewable staff updates instead of free-form agent output.
286pub struct SendStub {
287    /// Mode copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
288    pub mode: SendMode,
289    /// Blocked by copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
290    pub blocked_by: Vec<policy::ReviewGate>,
291    /// Audit action copied from reviewed source input for audit, reviewer explanation, or agent context; callers must not invent or mutate it.
292    pub audit_action: entities::audit::Action,
293}
294
295impl SendStub {
296    /// Reports whether the daily update workflow satisfies the is blocked until human approval safety condition.
297    pub fn is_blocked_until_human_approval(&self) -> bool {
298        matches!(self.mode, SendMode::ApprovalRequiredStub) && !self.blocked_by.is_empty()
299    }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303/// Decision choices for send mode in the daily update workflow; each value routes reviewed source facts to the right queue, draft, or staff gate.
304pub enum SendMode {
305    /// Selects approval required stub for the daily update decision model so the app can choose a review, evidence, or draft path without taking live action.
306    ApprovalRequiredStub,
307}
308
309#[nutype(
310    sanitize(trim),
311    validate(not_empty, len_char_max = 120),
312    derive(
313        Debug,
314        Clone,
315        PartialEq,
316        Eq,
317        PartialOrd,
318        Ord,
319        Hash,
320        Serialize,
321        Deserialize
322    )
323)]
324pub struct LanguageTag(String);
325
326#[nutype(
327    sanitize(trim),
328    validate(not_empty, len_char_max = 120),
329    derive(
330        Debug,
331        Clone,
332        PartialEq,
333        Eq,
334        PartialOrd,
335        Ord,
336        Hash,
337        Serialize,
338        Deserialize
339    )
340)]
341pub struct ToneLabel(String);
342
343#[nutype(
344    sanitize(trim),
345    validate(not_empty, len_char_max = 160),
346    derive(
347        Debug,
348        Clone,
349        PartialEq,
350        Eq,
351        PartialOrd,
352        Ord,
353        Hash,
354        Serialize,
355        Deserialize
356    )
357)]
358pub struct RedactionProfile(String);
359
360#[nutype(
361    sanitize(trim),
362    validate(not_empty, len_char_max = 160),
363    derive(
364        Debug,
365        Clone,
366        PartialEq,
367        Eq,
368        PartialOrd,
369        Ord,
370        Hash,
371        Serialize,
372        Deserialize
373    )
374)]
375pub struct ReviewReason(String);
376
377#[nutype(
378    sanitize(trim),
379    validate(not_empty, len_char_max = 400),
380    derive(
381        Debug,
382        Clone,
383        PartialEq,
384        Eq,
385        PartialOrd,
386        Ord,
387        Hash,
388        Serialize,
389        Deserialize
390    )
391)]
392pub struct FlagMessage(String);
393
394#[nutype(
395    sanitize(trim),
396    validate(not_empty, len_char_max = 500),
397    derive(
398        Debug,
399        Clone,
400        PartialEq,
401        Eq,
402        PartialOrd,
403        Ord,
404        Hash,
405        Serialize,
406        Deserialize
407    )
408)]
409pub struct FactSummary(String);
410
411/// Builds the mvp preview output for the daily update workflow.
412pub fn build_mvp_preview(request: MvpPreviewRequest) -> Result<MvpPreview> {
413    validate_request(&request)?;
414
415    let input = daily_care_update::Input {
416        pet_name: request.pet_name,
417        owner_display_name: request.owner_display_name,
418        policy_snapshot_id: request.policy_snapshot_id,
419        notes: request.notes,
420    };
421
422    let agent = daily_care_update::Agent;
423    let agent_packet = agent.build_prompt_packet(&request.event, input.clone());
424    let output = generate_output(&input)?;
425    let review_gate = review_gate_for(&output);
426
427    let message_id =
428        entities::MessageId(Uuid::from_u128(0xDA17_0000_0000_0000_0000_0000_0000_0001));
429    let approval_id =
430        entities::approval::Id(Uuid::from_u128(0xDA17_0000_0000_0000_0000_0000_0000_0002));
431    let _owner_message_record = entities::Message::builder()
432        .id(message_id)
433        .subject(entities::MessageSubject::Reservation(
434            subject_reservation_id(&request.event),
435        ))
436        .direction(message::Direction::OutboundDraft)
437        .channel(message::Channel::Portal)
438        .status(message::Status::ApprovalRequested)
439        .body_ref(output.customer_message.body_ref.clone())
440        .approval_gate(review_gate.clone())
441        .audit_refs(vec![audit::EventId(Uuid::from_u128(
442            0xDA17_0000_0000_0000_0000_0000_0000_0003,
443        ))])
444        .build();
445
446    let approval = entities::approval::Record::builder()
447        .id(approval_id)
448        .target(entities::approval::Target::Message(message_id))
449        .gate(review_gate.clone())
450        .lifecycle(entities::approval::Lifecycle::ApprovalRequested)
451        .requested_by(entities::ActorRef::Agent {
452            workflow: agent_name()?,
453        })
454        .requested_at(request.event.occurred_at)
455        .audit_refs(vec![audit::EventId(Uuid::from_u128(
456            0xDA17_0000_0000_0000_0000_0000_0000_0004,
457        ))])
458        .build();
459
460    let send_stub = SendStub {
461        mode: SendMode::ApprovalRequiredStub,
462        blocked_by: vec![review_gate],
463        audit_action: entities::audit::Action::Extension(audit_action_label(
464            "message.send.blocked_stub",
465        )?),
466    };
467
468    let audit_log = audit_log(&request.event, message_id, approval_id)?;
469    let owner_message_draft = output.customer_message.clone();
470
471    Ok(MvpPreview {
472        agent_packet,
473        owner_message_draft,
474        output,
475        approval,
476        send_stub,
477        audit_log,
478    })
479}
480
481impl agents::WorkflowAgent<daily_care_update::Input, daily_care_update::Output>
482    for daily_care_update::Agent
483{
484    fn spec(&self) -> agents::AgentSpec {
485        agents::baseline_agent_specs()
486            .into_iter()
487            .find(|spec| spec.name.clone().into_inner() == "daily-care-update")
488            .expect("baseline daily-care-update agent spec exists")
489    }
490
491    fn build_prompt_packet(
492        &self,
493        event: &workflow::Event,
494        input: daily_care_update::Input,
495    ) -> agents::AgentPromptPacket<daily_care_update::Input> {
496        agents::AgentPromptPacket::builder()
497            .workflow_name(agent_name().expect("static daily-update agent name is valid"))
498            .goal(agent::Purpose::try_new(
499                "Transform source-backed staff care notes into a customer-safe draft preview while preserving approval gates and audit lineage.",
500            ).expect("static daily-update purpose is valid"))
501            .event(event.clone())
502            .input(input)
503            .policies(vec![agent::PolicyInstruction::try_new(
504                "Draft only: live customer sends and health/behavior concern wording require human approval.",
505            ).expect("static daily-update policy instruction is valid")])
506            .output_schema_name(agent::OutputSchemaName::try_new("DailyCareUpdateOutput.v1").expect("static daily-update schema name is valid"))
507            .build()
508    }
509
510    fn validate_output(
511        &self,
512        output: workflow::Result<daily_care_update::Output>,
513    ) -> workflow::Result<daily_care_update::Output> {
514        output
515    }
516}
517
518fn validate_request(request: &MvpPreviewRequest) -> Result<()> {
519    if !matches!(
520        request.event.event_type,
521        workflow::EventType::DailyNoteCreated | workflow::EventType::DailyUpdateNeeded
522    ) {
523        return Err(Error::UnsupportedWorkflowEvent);
524    }
525    if request.notes.is_empty() {
526        return Err(Error::MissingStaffNotes);
527    }
528    if !request
529        .event
530        .policy_context
531        .allowed_actions
532        .iter()
533        .any(|action| {
534            matches!(
535                action,
536                workflow::AllowedAction::SummarizeCareNotes
537                    | workflow::AllowedAction::DraftCustomerMessage
538            )
539        })
540    {
541        return Err(Error::MissingAllowedAction);
542    }
543    Ok(())
544}
545
546fn generate_output(input: &daily_care_update::Input) -> Result<daily_care_update::Output> {
547    let mut included_facts = Vec::new();
548    let mut omitted_facts = Vec::new();
549    let mut internal_flags = vec![InternalFlag {
550        code: InternalFlagCode::CustomerMessageApprovalNotConfigured,
551        severity: InternalFlagSeverity::NeedsStaffReview,
552        message: flag_message(
553            "Daily care updates are draft-only until a location/channel/template send policy is approved.",
554        )?,
555        source_note_ids: input.notes.iter().map(|note| note.id).collect(),
556        recommended_action: RecommendedFlagAction::StaffReview,
557    }];
558
559    let mut safe_note_bodies = Vec::new();
560    let mut sensitive_note_ids = Vec::new();
561
562    for note in &input.notes {
563        if matches!(
564            note.visibility,
565            entities::care_note::Visibility::InternalOnly
566        ) {
567            omitted_facts.push(OmittedFact {
568                source_note_id: note.id,
569                reason: OmissionReason::InternalOnly,
570            });
571            internal_flags.push(InternalFlag {
572                code: InternalFlagCode::RawInternalNoteNotCustomerSafe,
573                severity: InternalFlagSeverity::DoNotSend,
574                message: flag_message("Raw internal staff notes are omitted from customer copy.")?,
575                source_note_ids: vec![note.id],
576                recommended_action: RecommendedFlagAction::SuppressUpdate,
577            });
578            continue;
579        }
580
581        if sensitive_kind(note.kind) {
582            omitted_facts.push(OmittedFact {
583                source_note_id: note.id,
584                reason: OmissionReason::SensitiveRequiresReview,
585            });
586            sensitive_note_ids.push(note.id);
587            continue;
588        }
589
590        let summary = normalize_sentence(note.body.clone().into_inner());
591        included_facts.push(IncludedFact {
592            source_note_id: note.id,
593            summary: FactSummary::try_new(summary.clone()).map_err(invalid_domain_value)?,
594        });
595        safe_note_bodies.push(summary);
596    }
597
598    let sensitive_review = if !sensitive_note_ids.is_empty() {
599        let code = if input
600            .notes
601            .iter()
602            .any(|note| matches!(note.kind, entities::care_note::Kind::Behavior))
603        {
604            InternalFlagCode::BehaviorReviewRequired
605        } else {
606            InternalFlagCode::MedicalOrMedicationReviewRequired
607        };
608        internal_flags.push(InternalFlag {
609            code,
610            severity: InternalFlagSeverity::NeedsManagerReview,
611            message: flag_message("Sensitive care-note content was suppressed until manager review approves customer wording.")?,
612            source_note_ids: sensitive_note_ids,
613            recommended_action: RecommendedFlagAction::ManagerReview,
614        });
615        Some(code)
616    } else {
617        None
618    };
619
620    let review_reason = match sensitive_review {
621        Some(InternalFlagCode::BehaviorReviewRequired) => "behavior_review_required",
622        Some(_) => "medical_or_medication_review_required",
623        None => "customer_message_approval_not_configured",
624    };
625
626    let body = if safe_note_bodies.is_empty() {
627        format!(
628            "Hi {} — {}'s daily update is being reviewed by our care team before we share customer-facing wording.",
629            input.owner_display_name.clone().into_inner(),
630            input.pet_name.clone().into_inner()
631        )
632    } else {
633        format!(
634            "Hi {} — {} {}",
635            input.owner_display_name.clone().into_inner(),
636            input.pet_name.clone().into_inner(),
637            safe_note_bodies.join(" ")
638        )
639    };
640
641    Ok(daily_care_update::Output {
642        customer_message: CustomerMessageDraft {
643            body_ref: message::BodyRef::try_new(body).map_err(invalid_domain_value)?,
644            channel_hint: message::Channel::Portal,
645            language: LanguageTag::try_new("en-US").map_err(invalid_domain_value)?,
646            tone: ToneLabel::try_new("warm_concise_factual").map_err(invalid_domain_value)?,
647            audience: Audience::Customer,
648            redaction_profile: RedactionProfile::try_new("customer_safe_daily_update_v1")
649                .map_err(invalid_domain_value)?,
650        },
651        internal_flags,
652        disposition: ReviewDisposition::DraftOnlyRequiresReview {
653            reason: ReviewReason::try_new(review_reason).map_err(invalid_domain_value)?,
654        },
655        included_facts,
656        omitted_facts,
657    })
658}
659
660fn review_gate_for(output: &daily_care_update::Output) -> policy::ReviewGate {
661    if output.internal_flags.iter().any(|flag| {
662        matches!(
663            flag.code,
664            InternalFlagCode::BehaviorReviewRequired
665                | InternalFlagCode::MedicalOrMedicationReviewRequired
666        )
667    }) {
668        policy::ReviewGate::ManagerApproval
669    } else {
670        policy::ReviewGate::CustomerMessageApproval
671    }
672}
673
674fn audit_log(
675    event: &workflow::Event,
676    message_id: entities::MessageId,
677    approval_id: entities::approval::Id,
678) -> Result<Vec<entities::audit::Event>> {
679    Ok(vec![
680        audit_event(
681            event.occurred_at,
682            event.actor.clone(),
683            entities::audit::Subject::WorkflowEvent(event.event_id),
684            entities::audit::Action::WorkflowEventRecorded,
685            "daily-care-update workflow event recorded for MVP preview",
686        )?,
687        audit_event(
688            event.occurred_at,
689            entities::ActorRef::Agent {
690                workflow: agent_name()?,
691            },
692            entities::audit::Subject::Message(message_id),
693            entities::audit::Action::MessageApprovalRequested,
694            "daily care update owner-message draft created; no live send attempted",
695        )?,
696        audit_event(
697            event.occurred_at,
698            entities::ActorRef::Agent {
699                workflow: agent_name()?,
700            },
701            entities::audit::Subject::Approval(approval_id),
702            entities::audit::Action::ApprovalDecisionRecorded,
703            "approval record opened for staff/manager review stub",
704        )?,
705    ])
706}
707
708fn audit_event(
709    at: DateTime<Utc>,
710    actor: entities::ActorRef,
711    subject: entities::audit::Subject,
712    action: entities::audit::Action,
713    summary: &str,
714) -> Result<entities::audit::Event> {
715    let mut metadata = BTreeMap::new();
716    metadata.insert(
717        entities::audit::MetadataKey::try_new("summary").map_err(invalid_domain_value)?,
718        entities::audit::MetadataValue::try_new(summary).map_err(invalid_domain_value)?,
719    );
720    Ok(entities::audit::Event {
721        at,
722        actor,
723        subject,
724        action,
725        metadata,
726    })
727}
728
729fn subject_reservation_id(event: &workflow::Event) -> entities::reservation::Id {
730    match event.subject {
731        workflow::Subject::Reservation(id) => id,
732        _ => entities::reservation::Id(Uuid::nil()),
733    }
734}
735
736fn sensitive_kind(kind: entities::care_note::Kind) -> bool {
737    matches!(
738        kind,
739        entities::care_note::Kind::Medication
740            | entities::care_note::Kind::Medical
741            | entities::care_note::Kind::Behavior
742    )
743}
744
745fn normalize_sentence(body: String) -> String {
746    let mut normalized = body.trim().to_owned();
747    if !normalized.ends_with(['.', '!', '?']) {
748        normalized.push('.');
749    }
750    normalized
751}
752
753fn agent_name() -> Result<agent::Name> {
754    agent::Name::try_new("daily-care-update").map_err(invalid_domain_value)
755}
756
757fn audit_action_label(label: &str) -> Result<entities::audit::ActionLabel> {
758    entities::audit::ActionLabel::try_new(label).map_err(invalid_domain_value)
759}
760
761fn flag_message(message: &str) -> Result<FlagMessage> {
762    FlagMessage::try_new(message).map_err(invalid_domain_value)
763}
764
765fn invalid_domain_value(error: impl std::fmt::Display) -> Error {
766    Error::InvalidDomainValue(error.to_string())
767}