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    /// The resolving controller acts for a person who is not alive or is
48    /// detained or captive.
49    IssuerUnavailable,
50    /// The decision maker is a person who is not alive or is detained or
51    /// captive.
52    DecisionMakerUnavailable,
53}
54
55impl From<DecisionErrorCode> for DecisionAttemptErrorCode {
56    fn from(value: DecisionErrorCode) -> Self {
57        match value {
58            DecisionErrorCode::ClosedTicket => Self::ClosedTicket,
59            DecisionErrorCode::DuplicateController => Self::DuplicateController,
60            DecisionErrorCode::DuplicateResponse => Self::DuplicateResponse,
61            DecisionErrorCode::DuplicateTicket => Self::DuplicateTicket,
62            DecisionErrorCode::DecisionHistoryUnavailable => Self::DecisionHistoryUnavailable,
63            DecisionErrorCode::InvalidController => Self::InvalidController,
64            DecisionErrorCode::InvalidDecision => Self::InvalidDecision,
65            DecisionErrorCode::InvalidOption => Self::InvalidOption,
66            DecisionErrorCode::PolicyMismatch => Self::PolicyMismatch,
67            DecisionErrorCode::TicketNotFound => Self::TicketNotFound,
68            DecisionErrorCode::QueryBudgetExceeded => Self::QueryBudgetExceeded,
69            DecisionErrorCode::VersionConflict => Self::VersionConflict,
70        }
71    }
72}
73
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75pub struct DecisionError {
76    pub code: DecisionErrorCode,
77    pub message: String,
78}
79
80impl DecisionError {
81    #[must_use]
82    pub fn new(code: DecisionErrorCode, message: impl Into<String>) -> Self {
83        Self {
84            code,
85            message: message.into(),
86        }
87    }
88}
89
90impl Display for DecisionError {
91    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
92        write!(formatter, "{:?}: {}", self.code, self.message)
93    }
94}
95
96impl Error for DecisionError {}
97
98#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
99#[serde(rename_all = "snake_case")]
100pub enum DecisionPolicyKind {
101    Utility,
102    Rule,
103    Random,
104    Human,
105    External,
106    Llm,
107}
108
109#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
110pub struct DecisionPolicyIdentity {
111    pub kind: DecisionPolicyKind,
112    pub id: String,
113    pub version: String,
114    /// Canonical lower-case BLAKE3 digest of the policy's semantic
115    /// configuration, for SDK adapters whose configuration is part of their
116    /// identity (for example [`crate::GuardedUtilityPolicy`]). A controller
117    /// binding then rejects a policy whose configuration drifted without a
118    /// version change. `None` keeps the historical identity shape.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub semantic_hash: Option<String>,
121}
122
123impl DecisionPolicyIdentity {
124    #[must_use]
125    pub fn new(
126        kind: DecisionPolicyKind,
127        id: impl Into<String>,
128        version: impl Into<String>,
129    ) -> Self {
130        Self {
131            kind,
132            id: id.into(),
133            version: version.into(),
134            semantic_hash: None,
135        }
136    }
137
138    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
139        require_identifier(&self.id, "policy ID")?;
140        require_text(&self.version, "policy version")?;
141        if self.semantic_hash.as_deref().is_some_and(|hash| {
142            hash.len() != 64
143                || !hash
144                    .bytes()
145                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
146        }) {
147            return Err(DecisionError::new(
148                DecisionErrorCode::InvalidController,
149                "policy semantic hash must be lower-case 32-byte hexadecimal",
150            ));
151        }
152        Ok(())
153    }
154}
155
156#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
157#[serde(tag = "type", rename_all = "snake_case")]
158pub enum DecisionAuthority {
159    Actor {
160        actor: PersonId,
161    },
162    Institution {
163        institution: EntityRef,
164        responsible_actor: Option<PersonId>,
165    },
166    Council {
167        council_id: String,
168    },
169    NoResponsibleActor {
170        reason: String,
171    },
172}
173
174impl DecisionAuthority {
175    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
176        match self {
177            Self::Actor { .. } | Self::Institution { .. } => Ok(()),
178            Self::Council { council_id } => require_identifier(council_id, "council ID"),
179            Self::NoResponsibleActor { reason } => require_text(reason, "authority reason"),
180        }
181    }
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
185pub struct DecisionControllerBinding {
186    pub id: String,
187    pub policy: DecisionPolicyIdentity,
188    pub authority: DecisionAuthority,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub seat_id: Option<String>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub permission_profile_id: Option<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub command_subject: Option<EntityRef>,
195    /// Opts a utility-policy controller into random tie-breaks: its tickets
196    /// may then be resolved by `ResolveDecisionRandomly` over the candidates
197    /// of a pending [`DecisionOutcome::PendingRandom`] decision. Without this
198    /// opt-in, only random-policy controllers accept draw evidence.
199    #[serde(default, skip_serializing_if = "is_false")]
200    pub random_tie_break: bool,
201}
202
203#[allow(clippy::trivially_copy_pass_by_ref)]
204const fn is_false(value: &bool) -> bool {
205    !*value
206}
207
208impl DecisionControllerBinding {
209    #[must_use]
210    pub fn new(
211        id: impl Into<String>,
212        policy: DecisionPolicyIdentity,
213        authority: DecisionAuthority,
214    ) -> Self {
215        Self {
216            id: id.into(),
217            policy,
218            authority,
219            seat_id: None,
220            permission_profile_id: None,
221            command_subject: None,
222            random_tie_break: false,
223        }
224    }
225
226    /// Permits random tie-breaks for this utility-policy controller.
227    #[must_use]
228    pub const fn with_random_tie_break(mut self) -> Self {
229        self.random_tie_break = true;
230        self
231    }
232
233    #[must_use]
234    pub fn with_seat(
235        mut self,
236        seat_id: impl Into<String>,
237        permission_profile_id: impl Into<String>,
238    ) -> Self {
239        self.seat_id = Some(seat_id.into());
240        self.permission_profile_id = Some(permission_profile_id.into());
241        self
242    }
243
244    #[must_use]
245    pub fn with_command_subject(mut self, command_subject: EntityRef) -> Self {
246        self.command_subject = Some(command_subject);
247        self
248    }
249
250    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
251        require_identifier(&self.id, "controller ID")?;
252        self.policy.validate()?;
253        self.authority.validate()?;
254        if self.seat_id.is_some() != self.permission_profile_id.is_some() {
255            return Err(DecisionError::new(
256                DecisionErrorCode::InvalidController,
257                "seat ID and permission-profile ID must be supplied together",
258            ));
259        }
260        if let Some(seat_id) = &self.seat_id {
261            require_identifier(seat_id, "seat ID")?;
262        }
263        if let Some(profile) = &self.permission_profile_id {
264            require_identifier(profile, "permission-profile ID")?;
265        }
266        if self.random_tie_break && self.policy.kind != DecisionPolicyKind::Utility {
267            return Err(DecisionError::new(
268                DecisionErrorCode::InvalidController,
269                "only utility-policy controllers can opt into random tie-breaks",
270            ));
271        }
272        Ok(())
273    }
274}
275
276#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
277pub struct DecisionContext {
278    pub schema: String,
279    pub payload: Value,
280}
281
282impl DecisionContext {
283    #[must_use]
284    pub fn new(schema: impl Into<String>, payload: Value) -> Self {
285        Self {
286            schema: schema.into(),
287            payload,
288        }
289    }
290
291    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
292        require_identifier(&self.schema, "decision context schema")
293    }
294}
295
296#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
297#[serde(tag = "type", rename_all = "snake_case")]
298pub enum DecisionAction {
299    #[default]
300    None,
301    /// A serialized `canwu_sim::Command`. The controller supplies issuer and
302    /// authority; a policy can only select this existing envelope.
303    Command { command: Value },
304}
305
306#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
307pub struct DecisionOption {
308    pub id: String,
309    pub label: String,
310    pub description: String,
311    #[serde(default)]
312    pub action: DecisionAction,
313    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
314    pub utility_inputs: BTreeMap<String, i64>,
315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
316    pub blockers: Vec<String>,
317    #[serde(default)]
318    pub metadata: Value,
319}
320
321impl DecisionOption {
322    #[must_use]
323    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
324        Self {
325            id: id.into(),
326            label: label.into(),
327            description: String::new(),
328            action: DecisionAction::None,
329            utility_inputs: BTreeMap::new(),
330            blockers: Vec::new(),
331            metadata: Value::Null,
332        }
333    }
334
335    #[must_use]
336    pub fn with_command(mut self, command: Value) -> Self {
337        self.action = DecisionAction::Command { command };
338        self
339    }
340
341    #[must_use]
342    pub fn is_available(&self) -> bool {
343        self.blockers.is_empty()
344    }
345
346    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
347        require_identifier(&self.id, "option ID")?;
348        require_text(&self.label, "option label")?;
349        for key in self.utility_inputs.keys() {
350            require_identifier(key, "utility factor")?;
351        }
352        if self.blockers.iter().any(|value| !is_canonical_text(value)) {
353            return Err(DecisionError::new(
354                DecisionErrorCode::InvalidOption,
355                "option blockers must be non-empty canonical text",
356            ));
357        }
358        Ok(())
359    }
360}
361
362#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
363pub struct DecisionTicketDraft {
364    pub id: DecisionTicketId,
365    pub definition: String,
366    pub decision_maker: EntityRef,
367    pub assigned_controller: String,
368    pub summary: String,
369    pub context: DecisionContext,
370    pub options: Vec<DecisionOption>,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub deadline: Option<SimTime>,
373    /// Earlier ticket this decision follows up. At admission the parent must
374    /// be a terminal ticket in hot decision history with the same
375    /// `decision_maker`.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub parent_ticket: Option<DecisionTicketId>,
378}
379
380impl DecisionTicketDraft {
381    pub(crate) fn validate(&mut self) -> Result<(), DecisionError> {
382        if self.id.get() == 0 {
383            return Err(DecisionError::new(
384                DecisionErrorCode::InvalidDecision,
385                "decision ticket IDs must be nonzero",
386            ));
387        }
388        validate_parent_reference(self.id, self.parent_ticket)?;
389        require_identifier(&self.definition, "decision definition")?;
390        require_identifier(&self.assigned_controller, "assigned controller")?;
391        require_text(&self.summary, "decision summary")?;
392        self.context.validate()?;
393        canonicalize_options(&mut self.options)
394    }
395}
396
397#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
398#[serde(tag = "state", rename_all = "snake_case")]
399pub enum DecisionTicketState {
400    Open,
401    Resolved {
402        option_id: String,
403        trace_id: DecisionTraceId,
404    },
405    Cancelled {
406        reason: String,
407    },
408    Expired,
409}
410
411#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
412pub struct DecisionTicket {
413    pub id: DecisionTicketId,
414    pub definition: String,
415    pub decision_maker: EntityRef,
416    pub assigned_controller: String,
417    pub summary: String,
418    pub context: DecisionContext,
419    pub options: Vec<DecisionOption>,
420    pub opened_at: SimTime,
421    pub updated_at: SimTime,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub deadline: Option<SimTime>,
424    pub version: u64,
425    pub state: DecisionTicketState,
426    /// Terminal ticket, of the same decision maker, that this ticket follows.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub parent_ticket: Option<DecisionTicketId>,
429}
430
431impl DecisionTicket {
432    #[must_use]
433    pub fn option(&self, id: &str) -> Option<&DecisionOption> {
434        self.options
435            .binary_search_by(|option| option.id.as_str().cmp(id))
436            .ok()
437            .and_then(|index| self.options.get(index))
438    }
439
440    #[must_use]
441    pub const fn is_open(&self) -> bool {
442        matches!(self.state, DecisionTicketState::Open)
443    }
444
445    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
446        validate_parent_reference(self.id, self.parent_ticket)?;
447        require_identifier(&self.definition, "decision definition")?;
448        require_identifier(&self.assigned_controller, "assigned controller")?;
449        require_text(&self.summary, "decision summary")?;
450        self.context.validate()?;
451        let mut options = self.options.clone();
452        canonicalize_options(&mut options)?;
453        if options != self.options || self.version == 0 || self.updated_at < self.opened_at {
454            return Err(DecisionError::new(
455                DecisionErrorCode::InvalidDecision,
456                "decision ticket ordering, version, or timestamps are invalid",
457            ));
458        }
459        if self
460            .deadline
461            .is_some_and(|deadline| deadline < self.opened_at)
462        {
463            return Err(DecisionError::new(
464                DecisionErrorCode::InvalidDecision,
465                "decision deadline precedes the ticket opening time",
466            ));
467        }
468        match &self.state {
469            DecisionTicketState::Resolved { option_id, .. } if self.option(option_id).is_none() => {
470                Err(DecisionError::new(
471                    DecisionErrorCode::InvalidDecision,
472                    "resolved decision references an unknown option",
473                ))
474            }
475            DecisionTicketState::Cancelled { reason } => {
476                require_text(reason, "decision cancellation reason")
477            }
478            DecisionTicketState::Open
479            | DecisionTicketState::Resolved { .. }
480            | DecisionTicketState::Expired => Ok(()),
481        }
482    }
483}
484
485#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
486pub struct DecisionFactorContribution {
487    pub factor: String,
488    pub value: i64,
489    pub weight: i64,
490    pub contribution: i64,
491}
492
493#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
494pub struct DecisionOptionEvaluation {
495    pub option_id: String,
496    pub available: bool,
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub score: Option<i64>,
499    #[serde(default, skip_serializing_if = "Vec::is_empty")]
500    pub factors: Vec<DecisionFactorContribution>,
501    #[serde(default, skip_serializing_if = "Vec::is_empty")]
502    pub blockers: Vec<String>,
503}
504
505#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
506pub struct DecisionExternalEvidence {
507    pub provider: String,
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub model: Option<String>,
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub prompt_contract: Option<String>,
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub request_id: Option<String>,
514    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
515    pub metadata: BTreeMap<String, String>,
516}
517
518#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
519pub struct DecisionOptionWeight {
520    pub option_id: String,
521    pub weight: u64,
522}
523
524impl DecisionOptionWeight {
525    #[must_use]
526    pub fn new(option_id: impl Into<String>, weight: u64) -> Self {
527        Self {
528            option_id: option_id.into(),
529            weight,
530        }
531    }
532}
533
534#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
535pub struct DecisionRandomEvidence {
536    pub draw_id: RandomDrawId,
537    pub value: u64,
538    pub upper_exclusive: u64,
539    pub option_weights: Vec<DecisionOptionWeight>,
540}
541
542impl DecisionRandomEvidence {
543    /// Selects the option of a random-policy draw. The weights must cover
544    /// every available ticket option exactly once, in canonical order.
545    pub fn selected_option(
546        ticket: &DecisionTicket,
547        option_weights: &[DecisionOptionWeight],
548        value: u64,
549    ) -> Result<String, DecisionError> {
550        validate_option_weights(ticket, option_weights)?;
551        let upper_exclusive = checked_option_weight_total(option_weights)?;
552        Self::selected_option_from_weights(option_weights, value, upper_exclusive)
553    }
554
555    /// Selects the option of a random tie-break draw. The weights name only
556    /// the near-equivalent candidates a policy left pending: at least two
557    /// distinct available ticket options, each with a positive weight, in
558    /// canonical order.
559    pub fn selected_candidate(
560        ticket: &DecisionTicket,
561        candidates: &[DecisionOptionWeight],
562        value: u64,
563    ) -> Result<String, DecisionError> {
564        validate_candidate_weights(ticket, candidates)?;
565        let upper_exclusive = checked_option_weight_total(candidates)?;
566        Self::selected_option_from_weights(candidates, value, upper_exclusive)
567    }
568
569    pub fn selected_option_from_weights(
570        option_weights: &[DecisionOptionWeight],
571        value: u64,
572        upper_exclusive: u64,
573    ) -> Result<String, DecisionError> {
574        let observed_total = checked_option_weight_total(option_weights)?;
575        if observed_total != upper_exclusive {
576            return Err(DecisionError::new(
577                DecisionErrorCode::InvalidDecision,
578                "random decision option weights disagree with the draw bound",
579            ));
580        }
581        if value >= upper_exclusive {
582            return Err(DecisionError::new(
583                DecisionErrorCode::InvalidDecision,
584                "random decision value is outside its positive total weight",
585            ));
586        }
587        let mut cursor = 0_u64;
588        for option in option_weights {
589            cursor = cursor.checked_add(option.weight).ok_or_else(|| {
590                DecisionError::new(
591                    DecisionErrorCode::InvalidDecision,
592                    "random decision option weights overflow the supported range",
593                )
594            })?;
595            if value < cursor {
596                return Ok(option.option_id.clone());
597            }
598        }
599        Err(DecisionError::new(
600            DecisionErrorCode::InvalidDecision,
601            "random decision weights did not select an option",
602        ))
603    }
604
605    fn validate(
606        &self,
607        ticket: &DecisionTicket,
608        selected_option: &str,
609        tie_break: bool,
610    ) -> Result<(), DecisionError> {
611        if self.draw_id.get() == 0 {
612            return Err(DecisionError::new(
613                DecisionErrorCode::InvalidDecision,
614                "random decision evidence requires a nonzero draw ID",
615            ));
616        }
617        let observed = if tie_break {
618            Self::selected_candidate(ticket, &self.option_weights, self.value)?
619        } else {
620            Self::selected_option(ticket, &self.option_weights, self.value)?
621        };
622        if checked_option_weight_total(&self.option_weights)? != self.upper_exclusive {
623            return Err(DecisionError::new(
624                DecisionErrorCode::InvalidDecision,
625                "random decision total weight disagrees with its draw bound",
626            ));
627        }
628        if observed != selected_option {
629            return Err(DecisionError::new(
630                DecisionErrorCode::InvalidDecision,
631                "random decision evidence does not select the recorded option",
632            ));
633        }
634        Ok(())
635    }
636}
637
638#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
639#[serde(tag = "type", rename_all = "snake_case")]
640pub enum DecisionOutcome {
641    Selected {
642        option_id: String,
643    },
644    Deferred {
645        reason: String,
646    },
647    Pending {
648        reason: String,
649    },
650    /// A non-authoritative outcome: the policy found near-equivalent
651    /// candidates and asks the boundary to choose among only these options
652    /// with `ResolveDecisionRandomly`. It is never persisted as a resolution.
653    PendingRandom {
654        candidates: Vec<DecisionOptionWeight>,
655    },
656}
657
658impl DecisionOutcome {
659    /// Returns whether the outcome waits for later input instead of resolving
660    /// or deferring the ticket.
661    #[must_use]
662    pub const fn is_pending(&self) -> bool {
663        matches!(self, Self::Pending { .. } | Self::PendingRandom { .. })
664    }
665}
666
667/// The stage of a composite policy that produced a decision. Decisions from
668/// single-stage policies, and every historical trace, carry no stage.
669#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
670#[serde(rename_all = "snake_case")]
671pub enum DecisionStage {
672    /// An ordered guard selected or deferred before utility scoring.
673    Guard,
674    /// Weighted utility scoring selected or deferred.
675    Utility,
676    /// A bounded random tie-break among near-equivalent utility candidates.
677    Random,
678}
679
680#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
681pub struct PolicyDecision {
682    pub outcome: DecisionOutcome,
683    pub summary: String,
684    #[serde(default, skip_serializing_if = "Vec::is_empty")]
685    pub evaluations: Vec<DecisionOptionEvaluation>,
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub external: Option<DecisionExternalEvidence>,
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub random: Option<DecisionRandomEvidence>,
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub stage: Option<DecisionStage>,
692    /// Guard IDs that returned a choice other than no-match, in evaluation
693    /// order.
694    #[serde(default, skip_serializing_if = "Vec::is_empty")]
695    pub fired_guards: Vec<String>,
696}
697
698impl PolicyDecision {
699    #[must_use]
700    pub fn selected(option_id: impl Into<String>, summary: impl Into<String>) -> Self {
701        Self {
702            outcome: DecisionOutcome::Selected {
703                option_id: option_id.into(),
704            },
705            summary: summary.into(),
706            evaluations: Vec::new(),
707            external: None,
708            random: None,
709            stage: None,
710            fired_guards: Vec::new(),
711        }
712    }
713
714    #[must_use]
715    pub fn pending(reason: impl Into<String>) -> Self {
716        let reason = reason.into();
717        Self {
718            outcome: DecisionOutcome::Pending {
719                reason: reason.clone(),
720            },
721            summary: reason,
722            evaluations: Vec::new(),
723            external: None,
724            random: None,
725            stage: None,
726            fired_guards: Vec::new(),
727        }
728    }
729
730    /// Returns whether this decision is a random tie-break among candidates
731    /// rather than a random-policy draw over every available option.
732    #[must_use]
733    pub fn is_random_tie_break(&self) -> bool {
734        self.stage == Some(DecisionStage::Random)
735    }
736
737    /// Validates this decision against the ticket it answers: a selection
738    /// names an available option, a pending tie-break names only available
739    /// candidates, evaluations reference ticket options, and draw evidence
740    /// selects the recorded option.
741    pub fn validate(&self, ticket: &DecisionTicket) -> Result<(), DecisionError> {
742        require_text(&self.summary, "policy decision summary")?;
743        match &self.outcome {
744            DecisionOutcome::Selected { option_id } => {
745                let option = ticket.option(option_id).ok_or_else(|| {
746                    DecisionError::new(
747                        DecisionErrorCode::InvalidOption,
748                        format!("policy selected unknown option {option_id}"),
749                    )
750                })?;
751                if !option.is_available() {
752                    return Err(DecisionError::new(
753                        DecisionErrorCode::InvalidOption,
754                        format!("policy selected blocked option {option_id}"),
755                    ));
756                }
757            }
758            DecisionOutcome::Deferred { reason } | DecisionOutcome::Pending { reason } => {
759                require_text(reason, "decision outcome reason")?;
760            }
761            DecisionOutcome::PendingRandom { candidates } => {
762                validate_candidate_weights(ticket, candidates)?;
763                validate_candidates_top_scored(ticket, candidates, &self.evaluations)?;
764                if !self.is_random_tie_break() || self.random.is_some() {
765                    return Err(DecisionError::new(
766                        DecisionErrorCode::InvalidDecision,
767                        "a pending random tie-break requires the random stage and no draw evidence",
768                    ));
769                }
770            }
771        }
772        for evaluation in &self.evaluations {
773            if ticket.option(&evaluation.option_id).is_none() {
774                return Err(DecisionError::new(
775                    DecisionErrorCode::InvalidDecision,
776                    "policy evaluation references an unknown option",
777                ));
778            }
779        }
780        for guard in &self.fired_guards {
781            require_identifier(guard, "fired guard ID")?;
782        }
783        if self.external.is_some() && self.random.is_some() {
784            return Err(DecisionError::new(
785                DecisionErrorCode::InvalidDecision,
786                "a decision cannot carry both external and random evidence",
787            ));
788        }
789        if let Some(random) = &self.random {
790            let DecisionOutcome::Selected { option_id } = &self.outcome else {
791                return Err(DecisionError::new(
792                    DecisionErrorCode::InvalidDecision,
793                    "random decision evidence requires a selected outcome",
794                ));
795            };
796            random.validate(ticket, option_id, self.is_random_tie_break())?;
797            if self.is_random_tie_break() {
798                validate_candidates_top_scored(ticket, &random.option_weights, &self.evaluations)?;
799            }
800        } else if self.is_random_tie_break()
801            && !matches!(self.outcome, DecisionOutcome::PendingRandom { .. })
802        {
803            return Err(DecisionError::new(
804                DecisionErrorCode::InvalidDecision,
805                "the random stage either awaits a tie-break draw or selects with draw evidence",
806            ));
807        }
808        Ok(())
809    }
810}
811
812#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
813pub struct DecisionTrace {
814    pub id: DecisionTraceId,
815    pub ticket_id: DecisionTicketId,
816    pub ticket_version: u64,
817    pub controller_id: String,
818    pub policy: DecisionPolicyIdentity,
819    pub decided_at: SimTime,
820    pub outcome: DecisionOutcome,
821    pub summary: String,
822    #[serde(default, skip_serializing_if = "Vec::is_empty")]
823    pub evaluations: Vec<DecisionOptionEvaluation>,
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub external: Option<DecisionExternalEvidence>,
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub random: Option<DecisionRandomEvidence>,
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub command_request_id: Option<CommandRequestId>,
830    /// Composite-policy stage that produced the outcome; `None` for
831    /// single-stage policies and historical traces.
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub stage: Option<DecisionStage>,
834    /// Guard IDs that fired, in evaluation order.
835    #[serde(default, skip_serializing_if = "Vec::is_empty")]
836    pub fired_guards: Vec<String>,
837    /// The resolved ticket's parent, copied from the ticket so a trace read
838    /// from history carries its decision lineage.
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub parent_ticket: Option<DecisionTicketId>,
841}
842
843#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
844#[serde(tag = "outcome", rename_all = "snake_case")]
845pub enum DecisionAttemptOutcome {
846    Accepted {
847        #[serde(default, skip_serializing_if = "Option::is_none")]
848        trace_id: Option<DecisionTraceId>,
849        #[serde(default, skip_serializing_if = "Option::is_none")]
850        command_request_id: Option<CommandRequestId>,
851    },
852    Rejected {
853        code: DecisionAttemptErrorCode,
854        message: String,
855    },
856}
857
858#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
859pub struct DecisionAttemptRecord {
860    pub request_id: DecisionRequestId,
861    /// Canonical commitment to the complete admitted decision ingress request.
862    #[serde(default, skip_serializing_if = "String::is_empty")]
863    pub request_commitment: String,
864    pub at: SimTime,
865    /// Authoritative revision immediately before decision admission.
866    pub revision_before: u64,
867    pub expected_revision: u64,
868    pub outcome: DecisionAttemptOutcome,
869}
870
871#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
872#[serde(tag = "type", rename_all = "snake_case")]
873pub enum DecisionMutation {
874    RegisterController {
875        controller: DecisionControllerBinding,
876    },
877    Open {
878        ticket: DecisionTicketDraft,
879    },
880    ReplaceOptions {
881        ticket_id: DecisionTicketId,
882        expected_version: u64,
883        context: DecisionContext,
884        options: Vec<DecisionOption>,
885    },
886    Resolve {
887        ticket_id: DecisionTicketId,
888        expected_version: u64,
889        controller_id: String,
890        policy: DecisionPolicyIdentity,
891        decision: PolicyDecision,
892        #[serde(default, skip_serializing_if = "Option::is_none")]
893        command_request_id: Option<CommandRequestId>,
894    },
895    Cancel {
896        ticket_id: DecisionTicketId,
897        expected_version: u64,
898        reason: String,
899    },
900}
901
902pub(crate) fn canonicalize_options(options: &mut Vec<DecisionOption>) -> Result<(), DecisionError> {
903    for option in &mut *options {
904        option.blockers.sort();
905        option.blockers.dedup();
906        option.validate()?;
907    }
908    options.sort_by(|left, right| left.id.cmp(&right.id));
909    if options.is_empty() || options.windows(2).any(|pair| pair[0].id == pair[1].id) {
910        return Err(DecisionError::new(
911            DecisionErrorCode::InvalidOption,
912            "decision options must contain at least one unique option",
913        ));
914    }
915    Ok(())
916}
917
918fn validate_option_weights(
919    ticket: &DecisionTicket,
920    option_weights: &[DecisionOptionWeight],
921) -> Result<(), DecisionError> {
922    checked_option_weight_total(option_weights)?;
923    let available = ticket
924        .options
925        .iter()
926        .filter(|option| option.is_available())
927        .map(|option| option.id.as_str())
928        .collect::<Vec<_>>();
929    if available.len() != option_weights.len()
930        || available
931            .iter()
932            .zip(option_weights)
933            .any(|(option_id, weight)| *option_id != weight.option_id)
934    {
935        return Err(DecisionError::new(
936            DecisionErrorCode::InvalidDecision,
937            "random decision weights must cover every available option exactly once",
938        ));
939    }
940    Ok(())
941}
942
943fn validate_candidate_weights(
944    ticket: &DecisionTicket,
945    candidates: &[DecisionOptionWeight],
946) -> Result<(), DecisionError> {
947    checked_option_weight_total(candidates)?;
948    if candidates.len() < 2 {
949        return Err(DecisionError::new(
950            DecisionErrorCode::InvalidDecision,
951            "a random tie-break requires at least two candidates",
952        ));
953    }
954    for candidate in candidates {
955        if candidate.weight == 0
956            || !ticket
957                .option(&candidate.option_id)
958                .is_some_and(DecisionOption::is_available)
959        {
960            return Err(DecisionError::new(
961                DecisionErrorCode::InvalidDecision,
962                format!(
963                    "random tie-break candidate {} must be an available option with a positive weight",
964                    candidate.option_id
965                ),
966            ));
967        }
968    }
969    Ok(())
970}
971
972/// A tie-break is evidence about scores: the evaluations cover every available
973/// ticket option exactly once, every candidate carries an available scored
974/// evaluation, and no scored non-candidate reaches the lowest candidate score.
975fn validate_candidates_top_scored(
976    ticket: &DecisionTicket,
977    candidates: &[DecisionOptionWeight],
978    evaluations: &[DecisionOptionEvaluation],
979) -> Result<(), DecisionError> {
980    let mut evaluated = std::collections::BTreeSet::new();
981    let covered = evaluations
982        .iter()
983        .all(|evaluation| evaluated.insert(evaluation.option_id.as_str()))
984        && ticket
985            .options
986            .iter()
987            .filter(|option| option.is_available())
988            .all(|option| evaluated.contains(option.id.as_str()));
989    if !covered {
990        return Err(DecisionError::new(
991            DecisionErrorCode::InvalidDecision,
992            "random tie-break evaluations must cover every available option exactly once",
993        ));
994    }
995    let score = |option_id: &str| {
996        evaluations
997            .iter()
998            .find(|evaluation| evaluation.option_id == option_id)
999            .filter(|evaluation| evaluation.available)
1000            .and_then(|evaluation| evaluation.score)
1001    };
1002    let lowest_candidate = candidates
1003        .iter()
1004        .map(|candidate| score(&candidate.option_id))
1005        .try_fold(i64::MAX, |lowest, score| {
1006            score.map(|score| lowest.min(score))
1007        });
1008    let valid = lowest_candidate.is_some_and(|lowest| {
1009        evaluations.iter().all(|evaluation| {
1010            candidates
1011                .iter()
1012                .any(|candidate| candidate.option_id == evaluation.option_id)
1013                || !evaluation.available
1014                || evaluation.score.is_none_or(|score| score < lowest)
1015        })
1016    });
1017    if !valid {
1018        return Err(DecisionError::new(
1019            DecisionErrorCode::InvalidDecision,
1020            "random tie-break candidates must be exactly the top-scored available evaluations",
1021        ));
1022    }
1023    Ok(())
1024}
1025
1026fn validate_parent_reference(
1027    id: DecisionTicketId,
1028    parent: Option<DecisionTicketId>,
1029) -> Result<(), DecisionError> {
1030    if parent.is_some_and(|parent| parent.get() == 0 || parent == id) {
1031        return Err(DecisionError::new(
1032            DecisionErrorCode::InvalidDecision,
1033            "a decision ticket parent must be a different nonzero ticket ID",
1034        ));
1035    }
1036    Ok(())
1037}
1038
1039fn checked_option_weight_total(
1040    option_weights: &[DecisionOptionWeight],
1041) -> Result<u64, DecisionError> {
1042    if option_weights
1043        .windows(2)
1044        .any(|pair| pair[0].option_id >= pair[1].option_id)
1045    {
1046        return Err(DecisionError::new(
1047            DecisionErrorCode::InvalidDecision,
1048            "random decision option weights must be in canonical option-ID order",
1049        ));
1050    }
1051    for option in option_weights {
1052        require_identifier(&option.option_id, "random decision option ID")?;
1053    }
1054    let total = option_weights
1055        .iter()
1056        .try_fold(0_u64, |sum, option| sum.checked_add(option.weight))
1057        .ok_or_else(|| {
1058            DecisionError::new(
1059                DecisionErrorCode::InvalidDecision,
1060                "random decision option weights overflow the supported range",
1061            )
1062        })?;
1063    if total == 0 {
1064        return Err(DecisionError::new(
1065            DecisionErrorCode::InvalidDecision,
1066            "random decision option weights require a positive total",
1067        ));
1068    }
1069    Ok(total)
1070}
1071
1072pub(crate) fn require_identifier(value: &str, label: &str) -> Result<(), DecisionError> {
1073    if !is_canonical_text(value) || value.chars().any(char::is_whitespace) {
1074        return Err(DecisionError::new(
1075            DecisionErrorCode::InvalidDecision,
1076            format!("{label} must be non-empty canonical text without whitespace"),
1077        ));
1078    }
1079    Ok(())
1080}
1081
1082pub(crate) fn require_text(value: &str, label: &str) -> Result<(), DecisionError> {
1083    if !is_canonical_text(value) {
1084        return Err(DecisionError::new(
1085            DecisionErrorCode::InvalidDecision,
1086            format!("{label} must be non-empty canonical text"),
1087        ));
1088    }
1089    Ok(())
1090}
1091
1092fn is_canonical_text(value: &str) -> bool {
1093    !value.is_empty() && value == value.trim()
1094}