Skip to main content

canwu_decision/
model.rs

1use canwu_core::{
2    CommandRequestId, DecisionRequestId, DecisionTicketId, DecisionTraceId, EntityRef, PersonId,
3    RandomDrawId,
4};
5use canwu_time::SimTime;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9use std::error::Error;
10use std::fmt::{Display, Formatter};
11
12#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DecisionErrorCode {
15    ClosedTicket,
16    DuplicateController,
17    DuplicateResponse,
18    DuplicateTicket,
19    DecisionHistoryUnavailable,
20    InvalidController,
21    InvalidDecision,
22    InvalidOption,
23    PolicyMismatch,
24    TicketNotFound,
25    QueryBudgetExceeded,
26    VersionConflict,
27}
28
29#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum DecisionAttemptErrorCode {
32    SimulationRevisionConflict,
33    CommandRequestConflict,
34    EntityUnavailable,
35    ClosedTicket,
36    DuplicateController,
37    DuplicateResponse,
38    DuplicateTicket,
39    DecisionHistoryUnavailable,
40    InvalidController,
41    InvalidDecision,
42    InvalidOption,
43    PolicyMismatch,
44    TicketNotFound,
45    QueryBudgetExceeded,
46    VersionConflict,
47}
48
49impl From<DecisionErrorCode> for DecisionAttemptErrorCode {
50    fn from(value: DecisionErrorCode) -> Self {
51        match value {
52            DecisionErrorCode::ClosedTicket => Self::ClosedTicket,
53            DecisionErrorCode::DuplicateController => Self::DuplicateController,
54            DecisionErrorCode::DuplicateResponse => Self::DuplicateResponse,
55            DecisionErrorCode::DuplicateTicket => Self::DuplicateTicket,
56            DecisionErrorCode::DecisionHistoryUnavailable => Self::DecisionHistoryUnavailable,
57            DecisionErrorCode::InvalidController => Self::InvalidController,
58            DecisionErrorCode::InvalidDecision => Self::InvalidDecision,
59            DecisionErrorCode::InvalidOption => Self::InvalidOption,
60            DecisionErrorCode::PolicyMismatch => Self::PolicyMismatch,
61            DecisionErrorCode::TicketNotFound => Self::TicketNotFound,
62            DecisionErrorCode::QueryBudgetExceeded => Self::QueryBudgetExceeded,
63            DecisionErrorCode::VersionConflict => Self::VersionConflict,
64        }
65    }
66}
67
68#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
69pub struct DecisionError {
70    pub code: DecisionErrorCode,
71    pub message: String,
72}
73
74impl DecisionError {
75    #[must_use]
76    pub fn new(code: DecisionErrorCode, message: impl Into<String>) -> Self {
77        Self {
78            code,
79            message: message.into(),
80        }
81    }
82}
83
84impl Display for DecisionError {
85    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
86        write!(formatter, "{:?}: {}", self.code, self.message)
87    }
88}
89
90impl Error for DecisionError {}
91
92#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
93#[serde(rename_all = "snake_case")]
94pub enum DecisionPolicyKind {
95    Utility,
96    Rule,
97    Random,
98    Human,
99    External,
100    Llm,
101}
102
103#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
104pub struct DecisionPolicyIdentity {
105    pub kind: DecisionPolicyKind,
106    pub id: String,
107    pub version: String,
108}
109
110impl DecisionPolicyIdentity {
111    #[must_use]
112    pub fn new(
113        kind: DecisionPolicyKind,
114        id: impl Into<String>,
115        version: impl Into<String>,
116    ) -> Self {
117        Self {
118            kind,
119            id: id.into(),
120            version: version.into(),
121        }
122    }
123
124    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
125        require_identifier(&self.id, "policy ID")?;
126        require_text(&self.version, "policy version")
127    }
128}
129
130#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
131#[serde(tag = "type", rename_all = "snake_case")]
132pub enum DecisionAuthority {
133    Actor {
134        actor: PersonId,
135    },
136    Institution {
137        institution: EntityRef,
138        responsible_actor: Option<PersonId>,
139    },
140    Council {
141        council_id: String,
142    },
143    NoResponsibleActor {
144        reason: String,
145    },
146}
147
148impl DecisionAuthority {
149    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
150        match self {
151            Self::Actor { .. } | Self::Institution { .. } => Ok(()),
152            Self::Council { council_id } => require_identifier(council_id, "council ID"),
153            Self::NoResponsibleActor { reason } => require_text(reason, "authority reason"),
154        }
155    }
156}
157
158#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
159pub struct DecisionControllerBinding {
160    pub id: String,
161    pub policy: DecisionPolicyIdentity,
162    pub authority: DecisionAuthority,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub seat_id: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub permission_profile_id: Option<String>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub command_subject: Option<EntityRef>,
169}
170
171impl DecisionControllerBinding {
172    #[must_use]
173    pub fn new(
174        id: impl Into<String>,
175        policy: DecisionPolicyIdentity,
176        authority: DecisionAuthority,
177    ) -> Self {
178        Self {
179            id: id.into(),
180            policy,
181            authority,
182            seat_id: None,
183            permission_profile_id: None,
184            command_subject: None,
185        }
186    }
187
188    #[must_use]
189    pub fn with_seat(
190        mut self,
191        seat_id: impl Into<String>,
192        permission_profile_id: impl Into<String>,
193    ) -> Self {
194        self.seat_id = Some(seat_id.into());
195        self.permission_profile_id = Some(permission_profile_id.into());
196        self
197    }
198
199    #[must_use]
200    pub fn with_command_subject(mut self, command_subject: EntityRef) -> Self {
201        self.command_subject = Some(command_subject);
202        self
203    }
204
205    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
206        require_identifier(&self.id, "controller ID")?;
207        self.policy.validate()?;
208        self.authority.validate()?;
209        if self.seat_id.is_some() != self.permission_profile_id.is_some() {
210            return Err(DecisionError::new(
211                DecisionErrorCode::InvalidController,
212                "seat ID and permission-profile ID must be supplied together",
213            ));
214        }
215        if let Some(seat_id) = &self.seat_id {
216            require_identifier(seat_id, "seat ID")?;
217        }
218        if let Some(profile) = &self.permission_profile_id {
219            require_identifier(profile, "permission-profile ID")?;
220        }
221        Ok(())
222    }
223}
224
225#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
226pub struct DecisionContext {
227    pub schema: String,
228    pub payload: Value,
229}
230
231impl DecisionContext {
232    #[must_use]
233    pub fn new(schema: impl Into<String>, payload: Value) -> Self {
234        Self {
235            schema: schema.into(),
236            payload,
237        }
238    }
239
240    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
241        require_identifier(&self.schema, "decision context schema")
242    }
243}
244
245#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
246#[serde(tag = "type", rename_all = "snake_case")]
247pub enum DecisionAction {
248    #[default]
249    None,
250    /// A serialized `canwu_sim::Command`. The controller supplies issuer and
251    /// authority; a policy can only select this existing envelope.
252    Command { command: Value },
253}
254
255#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
256pub struct DecisionOption {
257    pub id: String,
258    pub label: String,
259    pub description: String,
260    #[serde(default)]
261    pub action: DecisionAction,
262    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
263    pub utility_inputs: BTreeMap<String, i64>,
264    #[serde(default, skip_serializing_if = "Vec::is_empty")]
265    pub blockers: Vec<String>,
266    #[serde(default)]
267    pub metadata: Value,
268}
269
270impl DecisionOption {
271    #[must_use]
272    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
273        Self {
274            id: id.into(),
275            label: label.into(),
276            description: String::new(),
277            action: DecisionAction::None,
278            utility_inputs: BTreeMap::new(),
279            blockers: Vec::new(),
280            metadata: Value::Null,
281        }
282    }
283
284    #[must_use]
285    pub fn with_command(mut self, command: Value) -> Self {
286        self.action = DecisionAction::Command { command };
287        self
288    }
289
290    #[must_use]
291    pub fn is_available(&self) -> bool {
292        self.blockers.is_empty()
293    }
294
295    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
296        require_identifier(&self.id, "option ID")?;
297        require_text(&self.label, "option label")?;
298        for key in self.utility_inputs.keys() {
299            require_identifier(key, "utility factor")?;
300        }
301        if self.blockers.iter().any(|value| !is_canonical_text(value)) {
302            return Err(DecisionError::new(
303                DecisionErrorCode::InvalidOption,
304                "option blockers must be non-empty canonical text",
305            ));
306        }
307        Ok(())
308    }
309}
310
311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
312pub struct DecisionTicketDraft {
313    pub id: DecisionTicketId,
314    pub definition: String,
315    pub decision_maker: EntityRef,
316    pub assigned_controller: String,
317    pub summary: String,
318    pub context: DecisionContext,
319    pub options: Vec<DecisionOption>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub deadline: Option<SimTime>,
322}
323
324impl DecisionTicketDraft {
325    pub(crate) fn validate(&mut self) -> Result<(), DecisionError> {
326        if self.id.get() == 0 {
327            return Err(DecisionError::new(
328                DecisionErrorCode::InvalidDecision,
329                "decision ticket IDs must be nonzero",
330            ));
331        }
332        require_identifier(&self.definition, "decision definition")?;
333        require_identifier(&self.assigned_controller, "assigned controller")?;
334        require_text(&self.summary, "decision summary")?;
335        self.context.validate()?;
336        canonicalize_options(&mut self.options)
337    }
338}
339
340#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[serde(tag = "state", rename_all = "snake_case")]
342pub enum DecisionTicketState {
343    Open,
344    Resolved {
345        option_id: String,
346        trace_id: DecisionTraceId,
347    },
348    Cancelled {
349        reason: String,
350    },
351    Expired,
352}
353
354#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
355pub struct DecisionTicket {
356    pub id: DecisionTicketId,
357    pub definition: String,
358    pub decision_maker: EntityRef,
359    pub assigned_controller: String,
360    pub summary: String,
361    pub context: DecisionContext,
362    pub options: Vec<DecisionOption>,
363    pub opened_at: SimTime,
364    pub updated_at: SimTime,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub deadline: Option<SimTime>,
367    pub version: u64,
368    pub state: DecisionTicketState,
369}
370
371impl DecisionTicket {
372    #[must_use]
373    pub fn option(&self, id: &str) -> Option<&DecisionOption> {
374        self.options
375            .binary_search_by(|option| option.id.as_str().cmp(id))
376            .ok()
377            .and_then(|index| self.options.get(index))
378    }
379
380    #[must_use]
381    pub const fn is_open(&self) -> bool {
382        matches!(self.state, DecisionTicketState::Open)
383    }
384
385    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
386        require_identifier(&self.definition, "decision definition")?;
387        require_identifier(&self.assigned_controller, "assigned controller")?;
388        require_text(&self.summary, "decision summary")?;
389        self.context.validate()?;
390        let mut options = self.options.clone();
391        canonicalize_options(&mut options)?;
392        if options != self.options || self.version == 0 || self.updated_at < self.opened_at {
393            return Err(DecisionError::new(
394                DecisionErrorCode::InvalidDecision,
395                "decision ticket ordering, version, or timestamps are invalid",
396            ));
397        }
398        if self
399            .deadline
400            .is_some_and(|deadline| deadline < self.opened_at)
401        {
402            return Err(DecisionError::new(
403                DecisionErrorCode::InvalidDecision,
404                "decision deadline precedes the ticket opening time",
405            ));
406        }
407        match &self.state {
408            DecisionTicketState::Resolved { option_id, .. } if self.option(option_id).is_none() => {
409                Err(DecisionError::new(
410                    DecisionErrorCode::InvalidDecision,
411                    "resolved decision references an unknown option",
412                ))
413            }
414            DecisionTicketState::Cancelled { reason } => {
415                require_text(reason, "decision cancellation reason")
416            }
417            DecisionTicketState::Open
418            | DecisionTicketState::Resolved { .. }
419            | DecisionTicketState::Expired => Ok(()),
420        }
421    }
422}
423
424#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
425pub struct DecisionFactorContribution {
426    pub factor: String,
427    pub value: i64,
428    pub weight: i64,
429    pub contribution: i64,
430}
431
432#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
433pub struct DecisionOptionEvaluation {
434    pub option_id: String,
435    pub available: bool,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub score: Option<i64>,
438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
439    pub factors: Vec<DecisionFactorContribution>,
440    #[serde(default, skip_serializing_if = "Vec::is_empty")]
441    pub blockers: Vec<String>,
442}
443
444#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
445pub struct DecisionExternalEvidence {
446    pub provider: String,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub model: Option<String>,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub prompt_contract: Option<String>,
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub request_id: Option<String>,
453    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
454    pub metadata: BTreeMap<String, String>,
455}
456
457#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
458pub struct DecisionOptionWeight {
459    pub option_id: String,
460    pub weight: u64,
461}
462
463impl DecisionOptionWeight {
464    #[must_use]
465    pub fn new(option_id: impl Into<String>, weight: u64) -> Self {
466        Self {
467            option_id: option_id.into(),
468            weight,
469        }
470    }
471}
472
473#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
474pub struct DecisionRandomEvidence {
475    pub draw_id: RandomDrawId,
476    pub value: u64,
477    pub upper_exclusive: u64,
478    pub option_weights: Vec<DecisionOptionWeight>,
479}
480
481impl DecisionRandomEvidence {
482    pub fn selected_option(
483        ticket: &DecisionTicket,
484        option_weights: &[DecisionOptionWeight],
485        value: u64,
486    ) -> Result<String, DecisionError> {
487        validate_option_weights(ticket, option_weights)?;
488        let upper_exclusive = checked_option_weight_total(option_weights)?;
489        Self::selected_option_from_weights(option_weights, value, upper_exclusive)
490    }
491
492    pub fn selected_option_from_weights(
493        option_weights: &[DecisionOptionWeight],
494        value: u64,
495        upper_exclusive: u64,
496    ) -> Result<String, DecisionError> {
497        let observed_total = checked_option_weight_total(option_weights)?;
498        if observed_total != upper_exclusive {
499            return Err(DecisionError::new(
500                DecisionErrorCode::InvalidDecision,
501                "random decision option weights disagree with the draw bound",
502            ));
503        }
504        if value >= upper_exclusive {
505            return Err(DecisionError::new(
506                DecisionErrorCode::InvalidDecision,
507                "random decision value is outside its positive total weight",
508            ));
509        }
510        let mut cursor = 0_u64;
511        for option in option_weights {
512            cursor = cursor.checked_add(option.weight).ok_or_else(|| {
513                DecisionError::new(
514                    DecisionErrorCode::InvalidDecision,
515                    "random decision option weights overflow the supported range",
516                )
517            })?;
518            if value < cursor {
519                return Ok(option.option_id.clone());
520            }
521        }
522        Err(DecisionError::new(
523            DecisionErrorCode::InvalidDecision,
524            "random decision weights did not select an option",
525        ))
526    }
527
528    fn validate(
529        &self,
530        ticket: &DecisionTicket,
531        selected_option: &str,
532    ) -> Result<(), DecisionError> {
533        if self.draw_id.get() == 0 {
534            return Err(DecisionError::new(
535                DecisionErrorCode::InvalidDecision,
536                "random decision evidence requires a nonzero draw ID",
537            ));
538        }
539        let observed = Self::selected_option(ticket, &self.option_weights, self.value)?;
540        if checked_option_weight_total(&self.option_weights)? != self.upper_exclusive {
541            return Err(DecisionError::new(
542                DecisionErrorCode::InvalidDecision,
543                "random decision total weight disagrees with its draw bound",
544            ));
545        }
546        if observed != selected_option {
547            return Err(DecisionError::new(
548                DecisionErrorCode::InvalidDecision,
549                "random decision evidence does not select the recorded option",
550            ));
551        }
552        Ok(())
553    }
554}
555
556#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
557#[serde(tag = "type", rename_all = "snake_case")]
558pub enum DecisionOutcome {
559    Selected { option_id: String },
560    Deferred { reason: String },
561    Pending { reason: String },
562}
563
564#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
565pub struct PolicyDecision {
566    pub outcome: DecisionOutcome,
567    pub summary: String,
568    #[serde(default, skip_serializing_if = "Vec::is_empty")]
569    pub evaluations: Vec<DecisionOptionEvaluation>,
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub external: Option<DecisionExternalEvidence>,
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub random: Option<DecisionRandomEvidence>,
574}
575
576impl PolicyDecision {
577    #[must_use]
578    pub fn selected(option_id: impl Into<String>, summary: impl Into<String>) -> Self {
579        Self {
580            outcome: DecisionOutcome::Selected {
581                option_id: option_id.into(),
582            },
583            summary: summary.into(),
584            evaluations: Vec::new(),
585            external: None,
586            random: None,
587        }
588    }
589
590    #[must_use]
591    pub fn pending(reason: impl Into<String>) -> Self {
592        let reason = reason.into();
593        Self {
594            outcome: DecisionOutcome::Pending {
595                reason: reason.clone(),
596            },
597            summary: reason,
598            evaluations: Vec::new(),
599            external: None,
600            random: None,
601        }
602    }
603
604    pub(crate) fn validate(&self, ticket: &DecisionTicket) -> Result<(), DecisionError> {
605        require_text(&self.summary, "policy decision summary")?;
606        match &self.outcome {
607            DecisionOutcome::Selected { option_id } => {
608                let option = ticket.option(option_id).ok_or_else(|| {
609                    DecisionError::new(
610                        DecisionErrorCode::InvalidOption,
611                        format!("policy selected unknown option {option_id}"),
612                    )
613                })?;
614                if !option.is_available() {
615                    return Err(DecisionError::new(
616                        DecisionErrorCode::InvalidOption,
617                        format!("policy selected blocked option {option_id}"),
618                    ));
619                }
620            }
621            DecisionOutcome::Deferred { reason } | DecisionOutcome::Pending { reason } => {
622                require_text(reason, "decision outcome reason")?;
623            }
624        }
625        for evaluation in &self.evaluations {
626            if ticket.option(&evaluation.option_id).is_none() {
627                return Err(DecisionError::new(
628                    DecisionErrorCode::InvalidDecision,
629                    "policy evaluation references an unknown option",
630                ));
631            }
632        }
633        if self.external.is_some() && self.random.is_some() {
634            return Err(DecisionError::new(
635                DecisionErrorCode::InvalidDecision,
636                "a decision cannot carry both external and random evidence",
637            ));
638        }
639        if let Some(random) = &self.random {
640            let DecisionOutcome::Selected { option_id } = &self.outcome else {
641                return Err(DecisionError::new(
642                    DecisionErrorCode::InvalidDecision,
643                    "random decision evidence requires a selected outcome",
644                ));
645            };
646            random.validate(ticket, option_id)?;
647        }
648        Ok(())
649    }
650}
651
652#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
653pub struct DecisionTrace {
654    pub id: DecisionTraceId,
655    pub ticket_id: DecisionTicketId,
656    pub ticket_version: u64,
657    pub controller_id: String,
658    pub policy: DecisionPolicyIdentity,
659    pub decided_at: SimTime,
660    pub outcome: DecisionOutcome,
661    pub summary: String,
662    #[serde(default, skip_serializing_if = "Vec::is_empty")]
663    pub evaluations: Vec<DecisionOptionEvaluation>,
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub external: Option<DecisionExternalEvidence>,
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub random: Option<DecisionRandomEvidence>,
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub command_request_id: Option<CommandRequestId>,
670}
671
672#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
673#[serde(tag = "outcome", rename_all = "snake_case")]
674pub enum DecisionAttemptOutcome {
675    Accepted {
676        #[serde(default, skip_serializing_if = "Option::is_none")]
677        trace_id: Option<DecisionTraceId>,
678        #[serde(default, skip_serializing_if = "Option::is_none")]
679        command_request_id: Option<CommandRequestId>,
680    },
681    Rejected {
682        code: DecisionAttemptErrorCode,
683        message: String,
684    },
685}
686
687#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
688pub struct DecisionAttemptRecord {
689    pub request_id: DecisionRequestId,
690    /// Canonical commitment to the complete admitted decision ingress request.
691    #[serde(default, skip_serializing_if = "String::is_empty")]
692    pub request_commitment: String,
693    pub at: SimTime,
694    /// Authoritative revision immediately before decision admission.
695    pub revision_before: u64,
696    pub expected_revision: u64,
697    pub outcome: DecisionAttemptOutcome,
698}
699
700#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
701#[serde(tag = "type", rename_all = "snake_case")]
702pub enum DecisionMutation {
703    RegisterController {
704        controller: DecisionControllerBinding,
705    },
706    Open {
707        ticket: DecisionTicketDraft,
708    },
709    ReplaceOptions {
710        ticket_id: DecisionTicketId,
711        expected_version: u64,
712        context: DecisionContext,
713        options: Vec<DecisionOption>,
714    },
715    Resolve {
716        ticket_id: DecisionTicketId,
717        expected_version: u64,
718        controller_id: String,
719        policy: DecisionPolicyIdentity,
720        decision: PolicyDecision,
721        #[serde(default, skip_serializing_if = "Option::is_none")]
722        command_request_id: Option<CommandRequestId>,
723    },
724    Cancel {
725        ticket_id: DecisionTicketId,
726        expected_version: u64,
727        reason: String,
728    },
729}
730
731pub(crate) fn canonicalize_options(options: &mut Vec<DecisionOption>) -> Result<(), DecisionError> {
732    for option in &mut *options {
733        option.blockers.sort();
734        option.blockers.dedup();
735        option.validate()?;
736    }
737    options.sort_by(|left, right| left.id.cmp(&right.id));
738    if options.is_empty() || options.windows(2).any(|pair| pair[0].id == pair[1].id) {
739        return Err(DecisionError::new(
740            DecisionErrorCode::InvalidOption,
741            "decision options must contain at least one unique option",
742        ));
743    }
744    Ok(())
745}
746
747fn validate_option_weights(
748    ticket: &DecisionTicket,
749    option_weights: &[DecisionOptionWeight],
750) -> Result<(), DecisionError> {
751    checked_option_weight_total(option_weights)?;
752    let available = ticket
753        .options
754        .iter()
755        .filter(|option| option.is_available())
756        .map(|option| option.id.as_str())
757        .collect::<Vec<_>>();
758    if available.len() != option_weights.len()
759        || available
760            .iter()
761            .zip(option_weights)
762            .any(|(option_id, weight)| *option_id != weight.option_id)
763    {
764        return Err(DecisionError::new(
765            DecisionErrorCode::InvalidDecision,
766            "random decision weights must cover every available option exactly once",
767        ));
768    }
769    Ok(())
770}
771
772fn checked_option_weight_total(
773    option_weights: &[DecisionOptionWeight],
774) -> Result<u64, DecisionError> {
775    if option_weights
776        .windows(2)
777        .any(|pair| pair[0].option_id >= pair[1].option_id)
778    {
779        return Err(DecisionError::new(
780            DecisionErrorCode::InvalidDecision,
781            "random decision option weights must be in canonical option-ID order",
782        ));
783    }
784    for option in option_weights {
785        require_identifier(&option.option_id, "random decision option ID")?;
786    }
787    let total = option_weights
788        .iter()
789        .try_fold(0_u64, |sum, option| sum.checked_add(option.weight))
790        .ok_or_else(|| {
791            DecisionError::new(
792                DecisionErrorCode::InvalidDecision,
793                "random decision option weights overflow the supported range",
794            )
795        })?;
796    if total == 0 {
797        return Err(DecisionError::new(
798            DecisionErrorCode::InvalidDecision,
799            "random decision option weights require a positive total",
800        ));
801    }
802    Ok(total)
803}
804
805pub(crate) fn require_identifier(value: &str, label: &str) -> Result<(), DecisionError> {
806    if !is_canonical_text(value) || value.chars().any(char::is_whitespace) {
807        return Err(DecisionError::new(
808            DecisionErrorCode::InvalidDecision,
809            format!("{label} must be non-empty canonical text without whitespace"),
810        ));
811    }
812    Ok(())
813}
814
815pub(crate) fn require_text(value: &str, label: &str) -> Result<(), DecisionError> {
816    if !is_canonical_text(value) {
817        return Err(DecisionError::new(
818            DecisionErrorCode::InvalidDecision,
819            format!("{label} must be non-empty canonical text"),
820        ));
821    }
822    Ok(())
823}
824
825fn is_canonical_text(value: &str) -> bool {
826    !value.is_empty() && value == value.trim()
827}