1use canwu_core::{
2 CommandRequestId, DecisionRequestId, DecisionTicketId, DecisionTraceId, EntityRef, PersonId,
3};
4use canwu_time::SimTime;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::error::Error;
9use std::fmt::{Display, Formatter};
10
11#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DecisionErrorCode {
14 ClosedTicket,
15 DuplicateController,
16 DuplicateTicket,
17 InvalidController,
18 InvalidDecision,
19 InvalidOption,
20 PolicyMismatch,
21 TicketNotFound,
22 VersionConflict,
23}
24
25#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum DecisionAttemptErrorCode {
28 SimulationRevisionConflict,
29 CommandRequestConflict,
30 EntityUnavailable,
31 ClosedTicket,
32 DuplicateController,
33 DuplicateTicket,
34 InvalidController,
35 InvalidDecision,
36 InvalidOption,
37 PolicyMismatch,
38 TicketNotFound,
39 VersionConflict,
40}
41
42impl From<DecisionErrorCode> for DecisionAttemptErrorCode {
43 fn from(value: DecisionErrorCode) -> Self {
44 match value {
45 DecisionErrorCode::ClosedTicket => Self::ClosedTicket,
46 DecisionErrorCode::DuplicateController => Self::DuplicateController,
47 DecisionErrorCode::DuplicateTicket => Self::DuplicateTicket,
48 DecisionErrorCode::InvalidController => Self::InvalidController,
49 DecisionErrorCode::InvalidDecision => Self::InvalidDecision,
50 DecisionErrorCode::InvalidOption => Self::InvalidOption,
51 DecisionErrorCode::PolicyMismatch => Self::PolicyMismatch,
52 DecisionErrorCode::TicketNotFound => Self::TicketNotFound,
53 DecisionErrorCode::VersionConflict => Self::VersionConflict,
54 }
55 }
56}
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59pub struct DecisionError {
60 pub code: DecisionErrorCode,
61 pub message: String,
62}
63
64impl DecisionError {
65 #[must_use]
66 pub fn new(code: DecisionErrorCode, message: impl Into<String>) -> Self {
67 Self {
68 code,
69 message: message.into(),
70 }
71 }
72}
73
74impl Display for DecisionError {
75 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
76 write!(formatter, "{:?}: {}", self.code, self.message)
77 }
78}
79
80impl Error for DecisionError {}
81
82#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum DecisionPolicyKind {
85 Utility,
86 Rule,
87 Human,
88 External,
89 Llm,
90}
91
92#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
93pub struct DecisionPolicyIdentity {
94 pub kind: DecisionPolicyKind,
95 pub id: String,
96 pub version: String,
97}
98
99impl DecisionPolicyIdentity {
100 #[must_use]
101 pub fn new(
102 kind: DecisionPolicyKind,
103 id: impl Into<String>,
104 version: impl Into<String>,
105 ) -> Self {
106 Self {
107 kind,
108 id: id.into(),
109 version: version.into(),
110 }
111 }
112
113 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
114 require_identifier(&self.id, "policy ID")?;
115 require_text(&self.version, "policy version")
116 }
117}
118
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
120#[serde(tag = "type", rename_all = "snake_case")]
121pub enum DecisionAuthority {
122 Actor {
123 actor: PersonId,
124 },
125 Institution {
126 institution: EntityRef,
127 responsible_actor: Option<PersonId>,
128 },
129 Council {
130 council_id: String,
131 },
132 NoResponsibleActor {
133 reason: String,
134 },
135}
136
137impl DecisionAuthority {
138 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
139 match self {
140 Self::Actor { .. } | Self::Institution { .. } => Ok(()),
141 Self::Council { council_id } => require_identifier(council_id, "council ID"),
142 Self::NoResponsibleActor { reason } => require_text(reason, "authority reason"),
143 }
144 }
145}
146
147#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
148pub struct DecisionControllerBinding {
149 pub id: String,
150 pub policy: DecisionPolicyIdentity,
151 pub authority: DecisionAuthority,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub seat_id: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub permission_profile_id: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub command_subject: Option<EntityRef>,
158}
159
160impl DecisionControllerBinding {
161 #[must_use]
162 pub fn new(
163 id: impl Into<String>,
164 policy: DecisionPolicyIdentity,
165 authority: DecisionAuthority,
166 ) -> Self {
167 Self {
168 id: id.into(),
169 policy,
170 authority,
171 seat_id: None,
172 permission_profile_id: None,
173 command_subject: None,
174 }
175 }
176
177 #[must_use]
178 pub fn with_seat(
179 mut self,
180 seat_id: impl Into<String>,
181 permission_profile_id: impl Into<String>,
182 ) -> Self {
183 self.seat_id = Some(seat_id.into());
184 self.permission_profile_id = Some(permission_profile_id.into());
185 self
186 }
187
188 #[must_use]
189 pub fn with_command_subject(mut self, command_subject: EntityRef) -> Self {
190 self.command_subject = Some(command_subject);
191 self
192 }
193
194 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
195 require_identifier(&self.id, "controller ID")?;
196 self.policy.validate()?;
197 self.authority.validate()?;
198 if self.seat_id.is_some() != self.permission_profile_id.is_some() {
199 return Err(DecisionError::new(
200 DecisionErrorCode::InvalidController,
201 "seat ID and permission-profile ID must be supplied together",
202 ));
203 }
204 if let Some(seat_id) = &self.seat_id {
205 require_identifier(seat_id, "seat ID")?;
206 }
207 if let Some(profile) = &self.permission_profile_id {
208 require_identifier(profile, "permission-profile ID")?;
209 }
210 Ok(())
211 }
212}
213
214#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
215pub struct DecisionContext {
216 pub schema: String,
217 pub payload: Value,
218}
219
220impl DecisionContext {
221 #[must_use]
222 pub fn new(schema: impl Into<String>, payload: Value) -> Self {
223 Self {
224 schema: schema.into(),
225 payload,
226 }
227 }
228
229 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
230 require_identifier(&self.schema, "decision context schema")
231 }
232}
233
234#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
235#[serde(tag = "type", rename_all = "snake_case")]
236pub enum DecisionAction {
237 #[default]
238 None,
239 Command { command: Value },
242}
243
244#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
245pub struct DecisionOption {
246 pub id: String,
247 pub label: String,
248 pub description: String,
249 #[serde(default)]
250 pub action: DecisionAction,
251 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
252 pub utility_inputs: BTreeMap<String, i64>,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub blockers: Vec<String>,
255 #[serde(default)]
256 pub metadata: Value,
257}
258
259impl DecisionOption {
260 #[must_use]
261 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
262 Self {
263 id: id.into(),
264 label: label.into(),
265 description: String::new(),
266 action: DecisionAction::None,
267 utility_inputs: BTreeMap::new(),
268 blockers: Vec::new(),
269 metadata: Value::Null,
270 }
271 }
272
273 #[must_use]
274 pub fn with_command(mut self, command: Value) -> Self {
275 self.action = DecisionAction::Command { command };
276 self
277 }
278
279 #[must_use]
280 pub fn is_available(&self) -> bool {
281 self.blockers.is_empty()
282 }
283
284 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
285 require_identifier(&self.id, "option ID")?;
286 require_text(&self.label, "option label")?;
287 for key in self.utility_inputs.keys() {
288 require_identifier(key, "utility factor")?;
289 }
290 if self.blockers.iter().any(|value| !is_canonical_text(value)) {
291 return Err(DecisionError::new(
292 DecisionErrorCode::InvalidOption,
293 "option blockers must be non-empty canonical text",
294 ));
295 }
296 Ok(())
297 }
298}
299
300#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
301pub struct DecisionTicketDraft {
302 pub id: DecisionTicketId,
303 pub definition: String,
304 pub decision_maker: EntityRef,
305 pub assigned_controller: String,
306 pub summary: String,
307 pub context: DecisionContext,
308 pub options: Vec<DecisionOption>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub deadline: Option<SimTime>,
311}
312
313impl DecisionTicketDraft {
314 pub(crate) fn validate(&mut self) -> Result<(), DecisionError> {
315 if self.id.get() == 0 {
316 return Err(DecisionError::new(
317 DecisionErrorCode::InvalidDecision,
318 "decision ticket IDs must be nonzero",
319 ));
320 }
321 require_identifier(&self.definition, "decision definition")?;
322 require_identifier(&self.assigned_controller, "assigned controller")?;
323 require_text(&self.summary, "decision summary")?;
324 self.context.validate()?;
325 canonicalize_options(&mut self.options)
326 }
327}
328
329#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
330#[serde(tag = "state", rename_all = "snake_case")]
331pub enum DecisionTicketState {
332 Open,
333 Resolved {
334 option_id: String,
335 trace_id: DecisionTraceId,
336 },
337 Cancelled {
338 reason: String,
339 },
340 Expired,
341}
342
343#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
344pub struct DecisionTicket {
345 pub id: DecisionTicketId,
346 pub definition: String,
347 pub decision_maker: EntityRef,
348 pub assigned_controller: String,
349 pub summary: String,
350 pub context: DecisionContext,
351 pub options: Vec<DecisionOption>,
352 pub opened_at: SimTime,
353 pub updated_at: SimTime,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub deadline: Option<SimTime>,
356 pub version: u64,
357 pub state: DecisionTicketState,
358}
359
360impl DecisionTicket {
361 #[must_use]
362 pub fn option(&self, id: &str) -> Option<&DecisionOption> {
363 self.options
364 .binary_search_by(|option| option.id.as_str().cmp(id))
365 .ok()
366 .and_then(|index| self.options.get(index))
367 }
368
369 #[must_use]
370 pub const fn is_open(&self) -> bool {
371 matches!(self.state, DecisionTicketState::Open)
372 }
373
374 pub(crate) fn validate(&self) -> Result<(), DecisionError> {
375 require_identifier(&self.definition, "decision definition")?;
376 require_identifier(&self.assigned_controller, "assigned controller")?;
377 require_text(&self.summary, "decision summary")?;
378 self.context.validate()?;
379 let mut options = self.options.clone();
380 canonicalize_options(&mut options)?;
381 if options != self.options || self.version == 0 || self.updated_at < self.opened_at {
382 return Err(DecisionError::new(
383 DecisionErrorCode::InvalidDecision,
384 "decision ticket ordering, version, or timestamps are invalid",
385 ));
386 }
387 if self
388 .deadline
389 .is_some_and(|deadline| deadline < self.opened_at)
390 {
391 return Err(DecisionError::new(
392 DecisionErrorCode::InvalidDecision,
393 "decision deadline precedes the ticket opening time",
394 ));
395 }
396 match &self.state {
397 DecisionTicketState::Resolved { option_id, .. } if self.option(option_id).is_none() => {
398 Err(DecisionError::new(
399 DecisionErrorCode::InvalidDecision,
400 "resolved decision references an unknown option",
401 ))
402 }
403 DecisionTicketState::Cancelled { reason } => {
404 require_text(reason, "decision cancellation reason")
405 }
406 DecisionTicketState::Open
407 | DecisionTicketState::Resolved { .. }
408 | DecisionTicketState::Expired => Ok(()),
409 }
410 }
411}
412
413#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
414pub struct DecisionFactorContribution {
415 pub factor: String,
416 pub value: i64,
417 pub weight: i64,
418 pub contribution: i64,
419}
420
421#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
422pub struct DecisionOptionEvaluation {
423 pub option_id: String,
424 pub available: bool,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub score: Option<i64>,
427 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 pub factors: Vec<DecisionFactorContribution>,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
430 pub blockers: Vec<String>,
431}
432
433#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
434pub struct DecisionExternalEvidence {
435 pub provider: String,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub model: Option<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub prompt_contract: Option<String>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub request_id: Option<String>,
442 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
443 pub metadata: BTreeMap<String, String>,
444}
445
446#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
447#[serde(tag = "type", rename_all = "snake_case")]
448pub enum DecisionOutcome {
449 Selected { option_id: String },
450 Deferred { reason: String },
451 Pending { reason: String },
452}
453
454#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
455pub struct PolicyDecision {
456 pub outcome: DecisionOutcome,
457 pub summary: String,
458 #[serde(default, skip_serializing_if = "Vec::is_empty")]
459 pub evaluations: Vec<DecisionOptionEvaluation>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub external: Option<DecisionExternalEvidence>,
462}
463
464impl PolicyDecision {
465 #[must_use]
466 pub fn selected(option_id: impl Into<String>, summary: impl Into<String>) -> Self {
467 Self {
468 outcome: DecisionOutcome::Selected {
469 option_id: option_id.into(),
470 },
471 summary: summary.into(),
472 evaluations: Vec::new(),
473 external: None,
474 }
475 }
476
477 #[must_use]
478 pub fn pending(reason: impl Into<String>) -> Self {
479 let reason = reason.into();
480 Self {
481 outcome: DecisionOutcome::Pending {
482 reason: reason.clone(),
483 },
484 summary: reason,
485 evaluations: Vec::new(),
486 external: None,
487 }
488 }
489
490 pub(crate) fn validate(&self, ticket: &DecisionTicket) -> Result<(), DecisionError> {
491 require_text(&self.summary, "policy decision summary")?;
492 match &self.outcome {
493 DecisionOutcome::Selected { option_id } => {
494 let option = ticket.option(option_id).ok_or_else(|| {
495 DecisionError::new(
496 DecisionErrorCode::InvalidOption,
497 format!("policy selected unknown option {option_id}"),
498 )
499 })?;
500 if !option.is_available() {
501 return Err(DecisionError::new(
502 DecisionErrorCode::InvalidOption,
503 format!("policy selected blocked option {option_id}"),
504 ));
505 }
506 }
507 DecisionOutcome::Deferred { reason } | DecisionOutcome::Pending { reason } => {
508 require_text(reason, "decision outcome reason")?;
509 }
510 }
511 for evaluation in &self.evaluations {
512 if ticket.option(&evaluation.option_id).is_none() {
513 return Err(DecisionError::new(
514 DecisionErrorCode::InvalidDecision,
515 "policy evaluation references an unknown option",
516 ));
517 }
518 }
519 Ok(())
520 }
521}
522
523#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
524pub struct DecisionTrace {
525 pub id: DecisionTraceId,
526 pub ticket_id: DecisionTicketId,
527 pub ticket_version: u64,
528 pub controller_id: String,
529 pub policy: DecisionPolicyIdentity,
530 pub decided_at: SimTime,
531 pub outcome: DecisionOutcome,
532 pub summary: String,
533 #[serde(default, skip_serializing_if = "Vec::is_empty")]
534 pub evaluations: Vec<DecisionOptionEvaluation>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub external: Option<DecisionExternalEvidence>,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub command_request_id: Option<CommandRequestId>,
539}
540
541#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
542#[serde(tag = "outcome", rename_all = "snake_case")]
543pub enum DecisionAttemptOutcome {
544 Accepted {
545 #[serde(default, skip_serializing_if = "Option::is_none")]
546 trace_id: Option<DecisionTraceId>,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
548 command_request_id: Option<CommandRequestId>,
549 },
550 Rejected {
551 code: DecisionAttemptErrorCode,
552 message: String,
553 },
554}
555
556#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
557pub struct DecisionAttemptRecord {
558 pub request_id: DecisionRequestId,
559 pub at: SimTime,
560 pub revision_before: u64,
562 pub expected_revision: u64,
563 pub outcome: DecisionAttemptOutcome,
564}
565
566#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
567#[serde(tag = "type", rename_all = "snake_case")]
568pub enum DecisionMutation {
569 RegisterController {
570 controller: DecisionControllerBinding,
571 },
572 Open {
573 ticket: DecisionTicketDraft,
574 },
575 ReplaceOptions {
576 ticket_id: DecisionTicketId,
577 expected_version: u64,
578 context: DecisionContext,
579 options: Vec<DecisionOption>,
580 },
581 Resolve {
582 ticket_id: DecisionTicketId,
583 expected_version: u64,
584 controller_id: String,
585 policy: DecisionPolicyIdentity,
586 decision: PolicyDecision,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 command_request_id: Option<CommandRequestId>,
589 },
590 Cancel {
591 ticket_id: DecisionTicketId,
592 expected_version: u64,
593 reason: String,
594 },
595}
596
597pub(crate) fn canonicalize_options(options: &mut Vec<DecisionOption>) -> Result<(), DecisionError> {
598 for option in &mut *options {
599 option.blockers.sort();
600 option.blockers.dedup();
601 option.validate()?;
602 }
603 options.sort_by(|left, right| left.id.cmp(&right.id));
604 if options.is_empty() || options.windows(2).any(|pair| pair[0].id == pair[1].id) {
605 return Err(DecisionError::new(
606 DecisionErrorCode::InvalidOption,
607 "decision options must contain at least one unique option",
608 ));
609 }
610 Ok(())
611}
612
613pub(crate) fn require_identifier(value: &str, label: &str) -> Result<(), DecisionError> {
614 if !is_canonical_text(value) || value.chars().any(char::is_whitespace) {
615 return Err(DecisionError::new(
616 DecisionErrorCode::InvalidDecision,
617 format!("{label} must be non-empty canonical text without whitespace"),
618 ));
619 }
620 Ok(())
621}
622
623pub(crate) fn require_text(value: &str, label: &str) -> Result<(), DecisionError> {
624 if !is_canonical_text(value) {
625 return Err(DecisionError::new(
626 DecisionErrorCode::InvalidDecision,
627 format!("{label} must be non-empty canonical text"),
628 ));
629 }
630 Ok(())
631}
632
633fn is_canonical_text(value: &str) -> bool {
634 !value.is_empty() && value == value.trim()
635}