Skip to main content

canwu_decision/
model.rs

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    /// A serialized `canwu_sim::Command`. The controller supplies issuer and
240    /// authority; a policy can only select this existing envelope.
241    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    /// Authoritative revision immediately before decision admission.
561    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}