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")]
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 #[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 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 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 PendingRandom {
654 candidates: Vec<DecisionOptionWeight>,
655 },
656}
657
658impl DecisionOutcome {
659 #[must_use]
662 pub const fn is_pending(&self) -> bool {
663 matches!(self, Self::Pending { .. } | Self::PendingRandom { .. })
664 }
665}
666
667#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
670#[serde(rename_all = "snake_case")]
671pub enum DecisionStage {
672 Guard,
674 Utility,
676 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 #[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 #[must_use]
733 pub fn is_random_tie_break(&self) -> bool {
734 self.stage == Some(DecisionStage::Random)
735 }
736
737 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 #[serde(default, skip_serializing_if = "Option::is_none")]
833 pub stage: Option<DecisionStage>,
834 #[serde(default, skip_serializing_if = "Vec::is_empty")]
836 pub fired_guards: Vec<String>,
837 #[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 #[serde(default, skip_serializing_if = "String::is_empty")]
863 pub request_commitment: String,
864 pub at: SimTime,
865 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
972fn 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}