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 IssuerUnavailable,
50 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 #[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 #[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 #[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 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 #[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 #[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 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 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 PendingRandom {
665 candidates: Vec<DecisionOptionWeight>,
666 },
667}
668
669impl DecisionOutcome {
670 #[must_use]
673 pub const fn is_pending(&self) -> bool {
674 matches!(self, Self::Pending { .. } | Self::PendingRandom { .. })
675 }
676}
677
678#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
681#[serde(rename_all = "snake_case")]
682pub enum DecisionStage {
683 Guard,
685 Utility,
687 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 #[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 #[must_use]
744 pub fn is_random_tie_break(&self) -> bool {
745 self.stage == Some(DecisionStage::Random)
746 }
747
748 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 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub stage: Option<DecisionStage>,
845 #[serde(default, skip_serializing_if = "Vec::is_empty")]
847 pub fired_guards: Vec<String>,
848 #[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 #[serde(default, skip_serializing_if = "String::is_empty")]
874 pub request_commitment: String,
875 pub at: SimTime,
876 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
983fn 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}