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, and either have exactly
375    /// the same `decision_maker` or be a seat succession: the parent's and
376    /// this ticket's assigned controllers are both bound to the same
377    /// [`DecisionControllerBinding::seat_id`]. A successor holder therefore
378    /// registers a new controller bound to the seat and links its ticket to
379    /// the previous holder's cancelled one. Controller bindings are immutable
380    /// and never removed, so the rule reads the parent's binding directly and
381    /// snapshot validation re-checks it. Seat IDs are host content declared
382    /// when a controller is registered; the engine does not tie them to the
383    /// run's seat binding. An archived or absent parent is rejected as
384    /// `TicketNotFound`.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub parent_ticket: Option<DecisionTicketId>,
387}
388
389impl DecisionTicketDraft {
390    pub(crate) fn validate(&mut self) -> Result<(), DecisionError> {
391        if self.id.get() == 0 {
392            return Err(DecisionError::new(
393                DecisionErrorCode::InvalidDecision,
394                "decision ticket IDs must be nonzero",
395            ));
396        }
397        validate_parent_reference(self.id, self.parent_ticket)?;
398        require_identifier(&self.definition, "decision definition")?;
399        require_identifier(&self.assigned_controller, "assigned controller")?;
400        require_text(&self.summary, "decision summary")?;
401        self.context.validate()?;
402        canonicalize_options(&mut self.options)
403    }
404}
405
406#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
407#[serde(tag = "state", rename_all = "snake_case")]
408pub enum DecisionTicketState {
409    Open,
410    Resolved {
411        option_id: String,
412        trace_id: DecisionTraceId,
413    },
414    Cancelled {
415        reason: String,
416    },
417    Expired,
418}
419
420#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
421pub struct DecisionTicket {
422    pub id: DecisionTicketId,
423    pub definition: String,
424    pub decision_maker: EntityRef,
425    pub assigned_controller: String,
426    pub summary: String,
427    pub context: DecisionContext,
428    pub options: Vec<DecisionOption>,
429    pub opened_at: SimTime,
430    pub updated_at: SimTime,
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub deadline: Option<SimTime>,
433    pub version: u64,
434    pub state: DecisionTicketState,
435    /// Terminal ticket that this ticket follows, of the same decision maker
436    /// or of a controller bound to the same seat
437    /// (see [`DecisionTicketDraft::parent_ticket`]).
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub parent_ticket: Option<DecisionTicketId>,
440}
441
442impl DecisionTicket {
443    #[must_use]
444    pub fn option(&self, id: &str) -> Option<&DecisionOption> {
445        self.options
446            .binary_search_by(|option| option.id.as_str().cmp(id))
447            .ok()
448            .and_then(|index| self.options.get(index))
449    }
450
451    #[must_use]
452    pub const fn is_open(&self) -> bool {
453        matches!(self.state, DecisionTicketState::Open)
454    }
455
456    pub(crate) fn validate(&self) -> Result<(), DecisionError> {
457        validate_parent_reference(self.id, self.parent_ticket)?;
458        require_identifier(&self.definition, "decision definition")?;
459        require_identifier(&self.assigned_controller, "assigned controller")?;
460        require_text(&self.summary, "decision summary")?;
461        self.context.validate()?;
462        let mut options = self.options.clone();
463        canonicalize_options(&mut options)?;
464        if options != self.options || self.version == 0 || self.updated_at < self.opened_at {
465            return Err(DecisionError::new(
466                DecisionErrorCode::InvalidDecision,
467                "decision ticket ordering, version, or timestamps are invalid",
468            ));
469        }
470        if self
471            .deadline
472            .is_some_and(|deadline| deadline < self.opened_at)
473        {
474            return Err(DecisionError::new(
475                DecisionErrorCode::InvalidDecision,
476                "decision deadline precedes the ticket opening time",
477            ));
478        }
479        match &self.state {
480            DecisionTicketState::Resolved { option_id, .. } if self.option(option_id).is_none() => {
481                Err(DecisionError::new(
482                    DecisionErrorCode::InvalidDecision,
483                    "resolved decision references an unknown option",
484                ))
485            }
486            DecisionTicketState::Cancelled { reason } => {
487                require_text(reason, "decision cancellation reason")
488            }
489            DecisionTicketState::Open
490            | DecisionTicketState::Resolved { .. }
491            | DecisionTicketState::Expired => Ok(()),
492        }
493    }
494}
495
496#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
497pub struct DecisionFactorContribution {
498    pub factor: String,
499    pub value: i64,
500    pub weight: i64,
501    pub contribution: i64,
502}
503
504#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
505pub struct DecisionOptionEvaluation {
506    pub option_id: String,
507    pub available: bool,
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub score: Option<i64>,
510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
511    pub factors: Vec<DecisionFactorContribution>,
512    #[serde(default, skip_serializing_if = "Vec::is_empty")]
513    pub blockers: Vec<String>,
514}
515
516#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
517pub struct DecisionExternalEvidence {
518    pub provider: String,
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub model: Option<String>,
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub prompt_contract: Option<String>,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub request_id: Option<String>,
525    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
526    pub metadata: BTreeMap<String, String>,
527}
528
529#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
530pub struct DecisionOptionWeight {
531    pub option_id: String,
532    pub weight: u64,
533}
534
535impl DecisionOptionWeight {
536    #[must_use]
537    pub fn new(option_id: impl Into<String>, weight: u64) -> Self {
538        Self {
539            option_id: option_id.into(),
540            weight,
541        }
542    }
543}
544
545#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
546pub struct DecisionRandomEvidence {
547    pub draw_id: RandomDrawId,
548    pub value: u64,
549    pub upper_exclusive: u64,
550    pub option_weights: Vec<DecisionOptionWeight>,
551}
552
553impl DecisionRandomEvidence {
554    /// Selects the option of a random-policy draw. The weights must cover
555    /// every available ticket option exactly once, in canonical order.
556    pub fn selected_option(
557        ticket: &DecisionTicket,
558        option_weights: &[DecisionOptionWeight],
559        value: u64,
560    ) -> Result<String, DecisionError> {
561        validate_option_weights(ticket, option_weights)?;
562        let upper_exclusive = checked_option_weight_total(option_weights)?;
563        Self::selected_option_from_weights(option_weights, value, upper_exclusive)
564    }
565
566    /// Selects the option of a random tie-break draw. The weights name only
567    /// the near-equivalent candidates a policy left pending: at least two
568    /// distinct available ticket options, each with a positive weight, in
569    /// canonical order.
570    pub fn selected_candidate(
571        ticket: &DecisionTicket,
572        candidates: &[DecisionOptionWeight],
573        value: u64,
574    ) -> Result<String, DecisionError> {
575        validate_candidate_weights(ticket, candidates)?;
576        let upper_exclusive = checked_option_weight_total(candidates)?;
577        Self::selected_option_from_weights(candidates, value, upper_exclusive)
578    }
579
580    pub fn selected_option_from_weights(
581        option_weights: &[DecisionOptionWeight],
582        value: u64,
583        upper_exclusive: u64,
584    ) -> Result<String, DecisionError> {
585        let observed_total = checked_option_weight_total(option_weights)?;
586        if observed_total != upper_exclusive {
587            return Err(DecisionError::new(
588                DecisionErrorCode::InvalidDecision,
589                "random decision option weights disagree with the draw bound",
590            ));
591        }
592        if value >= upper_exclusive {
593            return Err(DecisionError::new(
594                DecisionErrorCode::InvalidDecision,
595                "random decision value is outside its positive total weight",
596            ));
597        }
598        let mut cursor = 0_u64;
599        for option in option_weights {
600            cursor = cursor.checked_add(option.weight).ok_or_else(|| {
601                DecisionError::new(
602                    DecisionErrorCode::InvalidDecision,
603                    "random decision option weights overflow the supported range",
604                )
605            })?;
606            if value < cursor {
607                return Ok(option.option_id.clone());
608            }
609        }
610        Err(DecisionError::new(
611            DecisionErrorCode::InvalidDecision,
612            "random decision weights did not select an option",
613        ))
614    }
615
616    fn validate(
617        &self,
618        ticket: &DecisionTicket,
619        selected_option: &str,
620        tie_break: bool,
621    ) -> Result<(), DecisionError> {
622        if self.draw_id.get() == 0 {
623            return Err(DecisionError::new(
624                DecisionErrorCode::InvalidDecision,
625                "random decision evidence requires a nonzero draw ID",
626            ));
627        }
628        let observed = if tie_break {
629            Self::selected_candidate(ticket, &self.option_weights, self.value)?
630        } else {
631            Self::selected_option(ticket, &self.option_weights, self.value)?
632        };
633        if checked_option_weight_total(&self.option_weights)? != self.upper_exclusive {
634            return Err(DecisionError::new(
635                DecisionErrorCode::InvalidDecision,
636                "random decision total weight disagrees with its draw bound",
637            ));
638        }
639        if observed != selected_option {
640            return Err(DecisionError::new(
641                DecisionErrorCode::InvalidDecision,
642                "random decision evidence does not select the recorded option",
643            ));
644        }
645        Ok(())
646    }
647}
648
649#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
650#[serde(tag = "type", rename_all = "snake_case")]
651pub enum DecisionOutcome {
652    Selected {
653        option_id: String,
654    },
655    Deferred {
656        reason: String,
657    },
658    Pending {
659        reason: String,
660    },
661    /// A non-authoritative outcome: the policy found near-equivalent
662    /// candidates and asks the boundary to choose among only these options
663    /// with `ResolveDecisionRandomly`. It is never persisted as a resolution.
664    PendingRandom {
665        candidates: Vec<DecisionOptionWeight>,
666    },
667}
668
669impl DecisionOutcome {
670    /// Returns whether the outcome waits for later input instead of resolving
671    /// or deferring the ticket.
672    #[must_use]
673    pub const fn is_pending(&self) -> bool {
674        matches!(self, Self::Pending { .. } | Self::PendingRandom { .. })
675    }
676}
677
678/// The stage of a composite policy that produced a decision. Decisions from
679/// single-stage policies, and every historical trace, carry no stage.
680#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
681#[serde(rename_all = "snake_case")]
682pub enum DecisionStage {
683    /// An ordered guard selected or deferred before utility scoring.
684    Guard,
685    /// Weighted utility scoring selected or deferred.
686    Utility,
687    /// A bounded random tie-break among near-equivalent utility candidates.
688    Random,
689}
690
691#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
692pub struct PolicyDecision {
693    pub outcome: DecisionOutcome,
694    pub summary: String,
695    #[serde(default, skip_serializing_if = "Vec::is_empty")]
696    pub evaluations: Vec<DecisionOptionEvaluation>,
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub external: Option<DecisionExternalEvidence>,
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub random: Option<DecisionRandomEvidence>,
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub stage: Option<DecisionStage>,
703    /// Guard IDs that returned a choice other than no-match, in evaluation
704    /// order.
705    #[serde(default, skip_serializing_if = "Vec::is_empty")]
706    pub fired_guards: Vec<String>,
707}
708
709impl PolicyDecision {
710    #[must_use]
711    pub fn selected(option_id: impl Into<String>, summary: impl Into<String>) -> Self {
712        Self {
713            outcome: DecisionOutcome::Selected {
714                option_id: option_id.into(),
715            },
716            summary: summary.into(),
717            evaluations: Vec::new(),
718            external: None,
719            random: None,
720            stage: None,
721            fired_guards: Vec::new(),
722        }
723    }
724
725    #[must_use]
726    pub fn pending(reason: impl Into<String>) -> Self {
727        let reason = reason.into();
728        Self {
729            outcome: DecisionOutcome::Pending {
730                reason: reason.clone(),
731            },
732            summary: reason,
733            evaluations: Vec::new(),
734            external: None,
735            random: None,
736            stage: None,
737            fired_guards: Vec::new(),
738        }
739    }
740
741    /// Returns whether this decision is a random tie-break among candidates
742    /// rather than a random-policy draw over every available option.
743    #[must_use]
744    pub fn is_random_tie_break(&self) -> bool {
745        self.stage == Some(DecisionStage::Random)
746    }
747
748    /// Validates this decision against the ticket it answers: a selection
749    /// names an available option, a pending tie-break names only available
750    /// candidates, evaluations reference ticket options, and draw evidence
751    /// selects the recorded option.
752    pub fn validate(&self, ticket: &DecisionTicket) -> Result<(), DecisionError> {
753        require_text(&self.summary, "policy decision summary")?;
754        match &self.outcome {
755            DecisionOutcome::Selected { option_id } => {
756                let option = ticket.option(option_id).ok_or_else(|| {
757                    DecisionError::new(
758                        DecisionErrorCode::InvalidOption,
759                        format!("policy selected unknown option {option_id}"),
760                    )
761                })?;
762                if !option.is_available() {
763                    return Err(DecisionError::new(
764                        DecisionErrorCode::InvalidOption,
765                        format!("policy selected blocked option {option_id}"),
766                    ));
767                }
768            }
769            DecisionOutcome::Deferred { reason } | DecisionOutcome::Pending { reason } => {
770                require_text(reason, "decision outcome reason")?;
771            }
772            DecisionOutcome::PendingRandom { candidates } => {
773                validate_candidate_weights(ticket, candidates)?;
774                validate_candidates_top_scored(ticket, candidates, &self.evaluations)?;
775                if !self.is_random_tie_break() || self.random.is_some() {
776                    return Err(DecisionError::new(
777                        DecisionErrorCode::InvalidDecision,
778                        "a pending random tie-break requires the random stage and no draw evidence",
779                    ));
780                }
781            }
782        }
783        for evaluation in &self.evaluations {
784            if ticket.option(&evaluation.option_id).is_none() {
785                return Err(DecisionError::new(
786                    DecisionErrorCode::InvalidDecision,
787                    "policy evaluation references an unknown option",
788                ));
789            }
790        }
791        for guard in &self.fired_guards {
792            require_identifier(guard, "fired guard ID")?;
793        }
794        if self.external.is_some() && self.random.is_some() {
795            return Err(DecisionError::new(
796                DecisionErrorCode::InvalidDecision,
797                "a decision cannot carry both external and random evidence",
798            ));
799        }
800        if let Some(random) = &self.random {
801            let DecisionOutcome::Selected { option_id } = &self.outcome else {
802                return Err(DecisionError::new(
803                    DecisionErrorCode::InvalidDecision,
804                    "random decision evidence requires a selected outcome",
805                ));
806            };
807            random.validate(ticket, option_id, self.is_random_tie_break())?;
808            if self.is_random_tie_break() {
809                validate_candidates_top_scored(ticket, &random.option_weights, &self.evaluations)?;
810            }
811        } else if self.is_random_tie_break()
812            && !matches!(self.outcome, DecisionOutcome::PendingRandom { .. })
813        {
814            return Err(DecisionError::new(
815                DecisionErrorCode::InvalidDecision,
816                "the random stage either awaits a tie-break draw or selects with draw evidence",
817            ));
818        }
819        Ok(())
820    }
821}
822
823#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
824pub struct DecisionTrace {
825    pub id: DecisionTraceId,
826    pub ticket_id: DecisionTicketId,
827    pub ticket_version: u64,
828    pub controller_id: String,
829    pub policy: DecisionPolicyIdentity,
830    pub decided_at: SimTime,
831    pub outcome: DecisionOutcome,
832    pub summary: String,
833    #[serde(default, skip_serializing_if = "Vec::is_empty")]
834    pub evaluations: Vec<DecisionOptionEvaluation>,
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub external: Option<DecisionExternalEvidence>,
837    #[serde(default, skip_serializing_if = "Option::is_none")]
838    pub random: Option<DecisionRandomEvidence>,
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub command_request_id: Option<CommandRequestId>,
841    /// Composite-policy stage that produced the outcome; `None` for
842    /// single-stage policies and historical traces.
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub stage: Option<DecisionStage>,
845    /// Guard IDs that fired, in evaluation order.
846    #[serde(default, skip_serializing_if = "Vec::is_empty")]
847    pub fired_guards: Vec<String>,
848    /// The resolved ticket's parent, copied from the ticket so a trace read
849    /// from history carries its decision lineage.
850    #[serde(default, skip_serializing_if = "Option::is_none")]
851    pub parent_ticket: Option<DecisionTicketId>,
852}
853
854#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
855#[serde(tag = "outcome", rename_all = "snake_case")]
856pub enum DecisionAttemptOutcome {
857    Accepted {
858        #[serde(default, skip_serializing_if = "Option::is_none")]
859        trace_id: Option<DecisionTraceId>,
860        #[serde(default, skip_serializing_if = "Option::is_none")]
861        command_request_id: Option<CommandRequestId>,
862    },
863    Rejected {
864        code: DecisionAttemptErrorCode,
865        message: String,
866    },
867}
868
869#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
870pub struct DecisionAttemptRecord {
871    pub request_id: DecisionRequestId,
872    /// Canonical commitment to the complete admitted decision ingress request.
873    #[serde(default, skip_serializing_if = "String::is_empty")]
874    pub request_commitment: String,
875    pub at: SimTime,
876    /// Authoritative revision immediately before decision admission.
877    pub revision_before: u64,
878    pub expected_revision: u64,
879    pub outcome: DecisionAttemptOutcome,
880}
881
882#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
883#[serde(tag = "type", rename_all = "snake_case")]
884pub enum DecisionMutation {
885    RegisterController {
886        controller: DecisionControllerBinding,
887    },
888    Open {
889        ticket: DecisionTicketDraft,
890    },
891    ReplaceOptions {
892        ticket_id: DecisionTicketId,
893        expected_version: u64,
894        context: DecisionContext,
895        options: Vec<DecisionOption>,
896    },
897    Resolve {
898        ticket_id: DecisionTicketId,
899        expected_version: u64,
900        controller_id: String,
901        policy: DecisionPolicyIdentity,
902        decision: PolicyDecision,
903        #[serde(default, skip_serializing_if = "Option::is_none")]
904        command_request_id: Option<CommandRequestId>,
905    },
906    Cancel {
907        ticket_id: DecisionTicketId,
908        expected_version: u64,
909        reason: String,
910    },
911}
912
913pub(crate) fn canonicalize_options(options: &mut Vec<DecisionOption>) -> Result<(), DecisionError> {
914    for option in &mut *options {
915        option.blockers.sort();
916        option.blockers.dedup();
917        option.validate()?;
918    }
919    options.sort_by(|left, right| left.id.cmp(&right.id));
920    if options.is_empty() || options.windows(2).any(|pair| pair[0].id == pair[1].id) {
921        return Err(DecisionError::new(
922            DecisionErrorCode::InvalidOption,
923            "decision options must contain at least one unique option",
924        ));
925    }
926    Ok(())
927}
928
929fn validate_option_weights(
930    ticket: &DecisionTicket,
931    option_weights: &[DecisionOptionWeight],
932) -> Result<(), DecisionError> {
933    checked_option_weight_total(option_weights)?;
934    let available = ticket
935        .options
936        .iter()
937        .filter(|option| option.is_available())
938        .map(|option| option.id.as_str())
939        .collect::<Vec<_>>();
940    if available.len() != option_weights.len()
941        || available
942            .iter()
943            .zip(option_weights)
944            .any(|(option_id, weight)| *option_id != weight.option_id)
945    {
946        return Err(DecisionError::new(
947            DecisionErrorCode::InvalidDecision,
948            "random decision weights must cover every available option exactly once",
949        ));
950    }
951    Ok(())
952}
953
954fn validate_candidate_weights(
955    ticket: &DecisionTicket,
956    candidates: &[DecisionOptionWeight],
957) -> Result<(), DecisionError> {
958    checked_option_weight_total(candidates)?;
959    if candidates.len() < 2 {
960        return Err(DecisionError::new(
961            DecisionErrorCode::InvalidDecision,
962            "a random tie-break requires at least two candidates",
963        ));
964    }
965    for candidate in candidates {
966        if candidate.weight == 0
967            || !ticket
968                .option(&candidate.option_id)
969                .is_some_and(DecisionOption::is_available)
970        {
971            return Err(DecisionError::new(
972                DecisionErrorCode::InvalidDecision,
973                format!(
974                    "random tie-break candidate {} must be an available option with a positive weight",
975                    candidate.option_id
976                ),
977            ));
978        }
979    }
980    Ok(())
981}
982
983/// A tie-break is evidence about scores: the evaluations cover every available
984/// ticket option exactly once, every candidate carries an available scored
985/// evaluation, and no scored non-candidate reaches the lowest candidate score.
986fn validate_candidates_top_scored(
987    ticket: &DecisionTicket,
988    candidates: &[DecisionOptionWeight],
989    evaluations: &[DecisionOptionEvaluation],
990) -> Result<(), DecisionError> {
991    let mut evaluated = std::collections::BTreeSet::new();
992    let covered = evaluations
993        .iter()
994        .all(|evaluation| evaluated.insert(evaluation.option_id.as_str()))
995        && ticket
996            .options
997            .iter()
998            .filter(|option| option.is_available())
999            .all(|option| evaluated.contains(option.id.as_str()));
1000    if !covered {
1001        return Err(DecisionError::new(
1002            DecisionErrorCode::InvalidDecision,
1003            "random tie-break evaluations must cover every available option exactly once",
1004        ));
1005    }
1006    let score = |option_id: &str| {
1007        evaluations
1008            .iter()
1009            .find(|evaluation| evaluation.option_id == option_id)
1010            .filter(|evaluation| evaluation.available)
1011            .and_then(|evaluation| evaluation.score)
1012    };
1013    let lowest_candidate = candidates
1014        .iter()
1015        .map(|candidate| score(&candidate.option_id))
1016        .try_fold(i64::MAX, |lowest, score| {
1017            score.map(|score| lowest.min(score))
1018        });
1019    let valid = lowest_candidate.is_some_and(|lowest| {
1020        evaluations.iter().all(|evaluation| {
1021            candidates
1022                .iter()
1023                .any(|candidate| candidate.option_id == evaluation.option_id)
1024                || !evaluation.available
1025                || evaluation.score.is_none_or(|score| score < lowest)
1026        })
1027    });
1028    if !valid {
1029        return Err(DecisionError::new(
1030            DecisionErrorCode::InvalidDecision,
1031            "random tie-break candidates must be exactly the top-scored available evaluations",
1032        ));
1033    }
1034    Ok(())
1035}
1036
1037fn validate_parent_reference(
1038    id: DecisionTicketId,
1039    parent: Option<DecisionTicketId>,
1040) -> Result<(), DecisionError> {
1041    if parent.is_some_and(|parent| parent.get() == 0 || parent == id) {
1042        return Err(DecisionError::new(
1043            DecisionErrorCode::InvalidDecision,
1044            "a decision ticket parent must be a different nonzero ticket ID",
1045        ));
1046    }
1047    Ok(())
1048}
1049
1050fn checked_option_weight_total(
1051    option_weights: &[DecisionOptionWeight],
1052) -> Result<u64, DecisionError> {
1053    if option_weights
1054        .windows(2)
1055        .any(|pair| pair[0].option_id >= pair[1].option_id)
1056    {
1057        return Err(DecisionError::new(
1058            DecisionErrorCode::InvalidDecision,
1059            "random decision option weights must be in canonical option-ID order",
1060        ));
1061    }
1062    for option in option_weights {
1063        require_identifier(&option.option_id, "random decision option ID")?;
1064    }
1065    let total = option_weights
1066        .iter()
1067        .try_fold(0_u64, |sum, option| sum.checked_add(option.weight))
1068        .ok_or_else(|| {
1069            DecisionError::new(
1070                DecisionErrorCode::InvalidDecision,
1071                "random decision option weights overflow the supported range",
1072            )
1073        })?;
1074    if total == 0 {
1075        return Err(DecisionError::new(
1076            DecisionErrorCode::InvalidDecision,
1077            "random decision option weights require a positive total",
1078        ));
1079    }
1080    Ok(total)
1081}
1082
1083pub(crate) fn require_identifier(value: &str, label: &str) -> Result<(), DecisionError> {
1084    if !is_canonical_text(value) || value.chars().any(char::is_whitespace) {
1085        return Err(DecisionError::new(
1086            DecisionErrorCode::InvalidDecision,
1087            format!("{label} must be non-empty canonical text without whitespace"),
1088        ));
1089    }
1090    Ok(())
1091}
1092
1093pub(crate) fn require_text(value: &str, label: &str) -> Result<(), DecisionError> {
1094    if !is_canonical_text(value) {
1095        return Err(DecisionError::new(
1096            DecisionErrorCode::InvalidDecision,
1097            format!("{label} must be non-empty canonical text"),
1098        ));
1099    }
1100    Ok(())
1101}
1102
1103fn is_canonical_text(value: &str) -> bool {
1104    !value.is_empty() && value == value.trim()
1105}