1use crate::authorization::{AuthorizationRequest, Subject};
2use crate::{FeltDb, FlowError};
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Map, Value};
5use sha2::{Digest, Sha256};
6use std::collections::{BTreeMap, BTreeSet};
7use std::sync::{Arc, Mutex};
8
9pub const DEFAULT_DECISION_COLLECTION: &str = "semantic_decisions";
10
11#[cfg(target_arch = "wasm32")]
12fn semantic_now_ms() -> u128 {
13 0
14}
15
16#[cfg(not(target_arch = "wasm32"))]
17fn semantic_now_ms() -> u128 {
18 crate::now_ms()
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "snake_case")]
23pub enum DecisionStatus {
24 Active,
25 Superseded,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum DecisionKind {
31 Binary,
32 Choice,
33 Score,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct BinaryDecisionResult {
38 pub kind: DecisionKind,
39 pub decision: bool,
40 pub option_mass: f64,
41 #[serde(default)]
42 pub supporting_fields: Vec<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct WeightedOption {
47 pub value: String,
48 pub probability: f64,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct ChoiceDecisionResult {
53 pub kind: DecisionKind,
54 pub selected: String,
55 pub options: Vec<WeightedOption>,
56 pub option_mass: f64,
57 #[serde(default)]
58 pub supporting_fields: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct ScoreDecisionResult {
63 pub kind: DecisionKind,
64 pub selected: String,
65 pub levels: Vec<WeightedOption>,
66 pub option_mass: f64,
67 #[serde(default)]
68 pub supporting_fields: Vec<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72#[serde(tag = "kind", rename_all = "snake_case")]
73pub enum DecisionResult {
74 Binary {
75 decision: bool,
76 option_mass: f64,
77 #[serde(default)]
78 supporting_fields: Vec<String>,
79 },
80 Choice {
81 selected: String,
82 options: Vec<WeightedOption>,
83 option_mass: f64,
84 #[serde(default)]
85 supporting_fields: Vec<String>,
86 },
87 Score {
88 selected: String,
89 levels: Vec<WeightedOption>,
90 option_mass: f64,
91 #[serde(default)]
92 supporting_fields: Vec<String>,
93 },
94}
95
96impl DecisionResult {
97 pub fn kind(&self) -> DecisionKind {
98 match self {
99 Self::Binary { .. } => DecisionKind::Binary,
100 Self::Choice { .. } => DecisionKind::Choice,
101 Self::Score { .. } => DecisionKind::Score,
102 }
103 }
104
105 pub fn option_mass(&self) -> f64 {
106 match self {
107 Self::Binary { option_mass, .. }
108 | Self::Choice { option_mass, .. }
109 | Self::Score { option_mass, .. } => *option_mass,
110 }
111 }
112
113 pub fn supporting_fields(&self) -> &[String] {
114 match self {
115 Self::Binary {
116 supporting_fields, ..
117 }
118 | Self::Choice {
119 supporting_fields, ..
120 }
121 | Self::Score {
122 supporting_fields, ..
123 } => supporting_fields.as_slice(),
124 }
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
129#[serde(tag = "kind", rename_all = "snake_case")]
130pub enum DecisionDefinition {
131 Binary {
132 predicate: String,
133 },
134 Choice {
135 question: String,
136 options: Vec<String>,
137 },
138 Score {
139 question: String,
140 levels: Vec<String>,
141 },
142}
143
144impl DecisionDefinition {
145 pub fn kind(&self) -> DecisionKind {
146 match self {
147 Self::Binary { .. } => DecisionKind::Binary,
148 Self::Choice { .. } => DecisionKind::Choice,
149 Self::Score { .. } => DecisionKind::Score,
150 }
151 }
152
153 pub fn prompt(&self) -> &str {
154 match self {
155 Self::Binary { predicate } => predicate,
156 Self::Choice { question, .. } | Self::Score { question, .. } => question,
157 }
158 }
159
160 pub fn schema_values(&self) -> &[String] {
161 match self {
162 Self::Binary { .. } => &[],
163 Self::Choice { options, .. } => options,
164 Self::Score { levels, .. } => levels,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170#[serde(tag = "kind", rename_all = "snake_case")]
171pub enum DecisionRequest {
172 Binary {
173 predicate: String,
174 context: Value,
175 },
176 Choice {
177 question: String,
178 options: Vec<String>,
179 context: Value,
180 },
181 Score {
182 question: String,
183 levels: Vec<String>,
184 context: Value,
185 },
186}
187
188impl DecisionRequest {
189 pub fn definition(&self) -> DecisionDefinition {
190 match self {
191 Self::Binary { predicate, .. } => DecisionDefinition::Binary {
192 predicate: predicate.clone(),
193 },
194 Self::Choice {
195 question, options, ..
196 } => DecisionDefinition::Choice {
197 question: question.clone(),
198 options: options.clone(),
199 },
200 Self::Score {
201 question, levels, ..
202 } => DecisionDefinition::Score {
203 question: question.clone(),
204 levels: levels.clone(),
205 },
206 }
207 }
208
209 pub fn context(&self) -> &Value {
210 match self {
211 Self::Binary { context, .. }
212 | Self::Choice { context, .. }
213 | Self::Score { context, .. } => context,
214 }
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
219pub struct DecisionRecordRef {
220 pub collection: String,
221 pub record_id: String,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct DecisionPrincipal {
226 pub subject: Subject,
227 pub tenant_id: String,
228 pub application_id: String,
229 #[serde(default = "default_read_capability")]
230 pub capability: String,
231}
232
233fn default_read_capability() -> String {
234 "state:read".into()
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
238pub struct DecisionAuthorizationDecision {
239 pub allowed: bool,
240 pub decision_id: Option<String>,
241}
242
243pub trait DecisionAuthorizationGate {
244 fn check(
245 &self,
246 request: &AuthorizationRequest,
247 ) -> Result<DecisionAuthorizationDecision, DecisionError>;
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
251pub struct DecisionAuthorizationConfig {
252 pub principal: Option<DecisionPrincipal>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256pub struct DecisionRuntimeMetadata {
257 pub runtime: String,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub model: Option<String>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub model_revision: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub prompt_revision: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub execution_method: Option<DecisionExecutionMethod>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub decision_schema_revision: Option<String>,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
271#[serde(rename_all = "snake_case")]
272pub enum DecisionExecutionMethod {
273 Logit,
274 Structured,
275 Generated,
276 Head,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
280pub struct DecisionRuntimeCapabilities {
281 pub binary: bool,
282 pub choice: bool,
283 pub score: bool,
284 pub batch: bool,
285 pub probability_distribution: bool,
286 pub execution_methods: BTreeSet<DecisionExecutionMethod>,
287}
288
289impl Default for DecisionRuntimeCapabilities {
290 fn default() -> Self {
291 Self {
292 binary: true,
293 choice: true,
294 score: true,
295 batch: true,
296 probability_distribution: true,
297 execution_methods: BTreeSet::new(),
298 }
299 }
300}
301
302pub trait DecisionRuntime {
303 fn metadata(&self) -> DecisionRuntimeMetadata;
304 fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError>;
305
306 fn capabilities(&self) -> DecisionRuntimeCapabilities {
307 DecisionRuntimeCapabilities::default()
308 }
309
310 fn execute(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
311 self.decide(request)
312 }
313
314 fn execute_batch(
315 &self,
316 requests: &[DecisionRequest],
317 ) -> Result<Vec<DecisionResult>, DecisionError> {
318 requests
319 .iter()
320 .map(|request| self.execute(request))
321 .collect()
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326pub struct DecisionProvenance {
327 pub runtime: String,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub model: Option<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub model_revision: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub prompt_revision: Option<String>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub execution_method: Option<DecisionExecutionMethod>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub decision_schema_revision: Option<String>,
338 pub context_hash: String,
339 pub relevant_state_version: String,
340 pub cache_key: String,
341 pub source: String,
342 pub evaluated_at: u128,
343 pub served_at: u128,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
347pub struct DecisionEvidenceField {
348 pub field: String,
349 pub value: Value,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
353pub struct DecisionEvidence {
354 pub decision_id: String,
355 pub kind: DecisionKind,
356 pub input: DecisionDefinition,
357 pub result: DecisionResult,
358 pub supporting_context: Vec<DecisionEvidenceField>,
359 pub runtime: DecisionRuntimeMetadata,
360 pub state: DecisionStateReference,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
364pub struct DecisionStateReference {
365 pub collection: String,
366 pub record_id: String,
367 pub record_version: Option<u64>,
368 pub reference: String,
369 pub relevant_state_version: String,
370 pub withheld_fields: Vec<String>,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
374pub struct DecisionEvaluation {
375 pub result: DecisionResult,
376 pub cached: bool,
377 pub evidence: Vec<DecisionEvidence>,
378 pub provenance: DecisionProvenance,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
382pub struct DecisionJudgmentRecord {
383 pub id: String,
384 pub lineage_key: String,
385 pub kind: DecisionKind,
386 pub collection: String,
387 pub record_id: String,
388 pub input: DecisionDefinition,
389 pub schema_version: String,
390 pub relevant_state_version: String,
391 pub runtime: String,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub model: Option<String>,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub model_revision: Option<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub prompt_revision: Option<String>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub execution_method: Option<DecisionExecutionMethod>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub decision_schema_revision: Option<String>,
402 pub result: DecisionResult,
403 pub option_mass: f64,
404 pub context_fields: Vec<String>,
405 pub supporting_fields: Vec<String>,
406 pub status: DecisionStatus,
407 pub state: DecisionStateReference,
408 pub evidence: DecisionEvidence,
409 pub evaluated_at: u128,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub superseded_at: Option<u128>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
415pub struct DecisionOptions {
416 #[serde(default)]
417 pub fields: Option<Vec<String>>,
418 #[serde(default)]
419 pub application_id: Option<String>,
420 #[serde(default)]
421 pub decision_collection: Option<String>,
422 #[serde(default)]
423 pub schema_version: Option<String>,
424}
425
426impl Default for DecisionOptions {
427 fn default() -> Self {
428 Self {
429 fields: None,
430 application_id: None,
431 decision_collection: None,
432 schema_version: Some("semantic-decision-v1".into()),
433 }
434 }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
438pub struct DecisionTransportAuthorization {
439 #[serde(default)]
440 pub allowed_fields: Vec<String>,
441 #[serde(default)]
442 pub principal: Option<DecisionPrincipal>,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
446#[serde(tag = "kind", rename_all = "snake_case")]
447pub enum DecisionTransportRuntime {
448 Deterministic {
449 metadata: DecisionRuntimeMetadata,
450 responses: BTreeMap<String, DecisionResult>,
451 },
452 Recording {
453 metadata: DecisionRuntimeMetadata,
454 result: DecisionResult,
455 },
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
459pub struct DecisionTransportExecutionRequest {
460 #[serde(default)]
461 pub target: Option<DecisionRecordRef>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub context: Option<Value>,
464 pub definition: DecisionDefinition,
465 #[serde(default)]
466 pub options: DecisionOptions,
467 #[serde(default)]
468 pub authorization: Option<DecisionTransportAuthorization>,
469 pub runtime: DecisionTransportRuntime,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
473pub struct DecisionTransportExecutionResponse {
474 pub evaluation: DecisionEvaluation,
475 #[serde(default)]
476 pub runtime_requests: Vec<DecisionRequest>,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
480pub struct DecisionBatchDecision {
481 pub id: String,
482 pub definition: DecisionDefinition,
483 #[serde(default = "empty_object")]
484 pub context: Value,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
488pub struct DecisionBatch {
489 #[serde(default = "empty_object")]
490 pub context: Value,
491 pub decisions: Vec<DecisionBatchDecision>,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
495pub struct DecisionBatchEvaluation {
496 pub id: String,
497 pub result: DecisionResult,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
501pub struct ChoicePermutationObservation {
502 pub order: Vec<String>,
503 pub selected: String,
504 pub distribution: Vec<WeightedOption>,
505 pub rank: usize,
506 pub probability: f64,
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
510pub struct ChoiceExpansionObservation {
511 pub baseline: ChoicePermutationObservation,
512 pub expanded: ChoicePermutationObservation,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
516pub struct ChoiceDiagnosticsReport {
517 pub permutations: Vec<ChoicePermutationObservation>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub expansion: Option<ChoiceExpansionObservation>,
520}
521
522fn empty_object() -> Value {
523 Value::Object(Map::new())
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
527pub struct DecisionContextPacket {
528 pub collection: String,
529 pub record_id: String,
530 pub record_version: Option<u64>,
531 pub reference: String,
532 pub fields: Vec<String>,
533 pub values: BTreeMap<String, Value>,
534 pub withheld_fields: Vec<String>,
535 pub relevant_state_version: String,
536 pub context_hash: String,
537 pub authorization_decisions: Vec<String>,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
541pub struct DecisionBenchmarkCase {
542 pub collection: String,
543 pub record_id: String,
544 pub input: DecisionDefinition,
545 pub mutate: Option<Map<String, Value>>,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
549pub struct DecisionBenchmarkReport {
550 pub binary: usize,
551 pub choice: usize,
552 pub score: usize,
553 pub runtime_invocations_first_pass: usize,
554 pub runtime_invocations_second_pass: usize,
555 pub cache_hits_second_pass: usize,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
559pub struct DecisionError {
560 pub code: String,
561 pub message: String,
562}
563
564impl DecisionError {
565 pub fn new(code: &str, message: impl Into<String>) -> Self {
566 Self {
567 code: code.into(),
568 message: message.into(),
569 }
570 }
571}
572
573impl std::fmt::Display for DecisionError {
574 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575 write!(f, "{}: {}", self.code, self.message)
576 }
577}
578
579impl std::error::Error for DecisionError {}
580
581pub(crate) struct TransportAuthorizationGate {
582 allowed_fields: BTreeSet<String>,
583}
584
585impl TransportAuthorizationGate {
586 pub(crate) fn new(allowed_fields: &[String]) -> Self {
587 Self {
588 allowed_fields: allowed_fields.iter().cloned().collect(),
589 }
590 }
591}
592
593impl DecisionAuthorizationGate for TransportAuthorizationGate {
594 fn check(
595 &self,
596 request: &AuthorizationRequest,
597 ) -> Result<DecisionAuthorizationDecision, DecisionError> {
598 let field = request
599 .resource
600 .uri
601 .split('/')
602 .next_back()
603 .unwrap_or_default()
604 .to_string();
605 Ok(DecisionAuthorizationDecision {
606 allowed: self.allowed_fields.contains(&field),
607 decision_id: Some(digest(&[
608 "semantic-decision-authorization-v1".into(),
609 request.application_id.clone(),
610 request.resource.uri.clone(),
611 field,
612 ])),
613 })
614 }
615}
616
617struct RecordingDecisionRuntime {
618 metadata: DecisionRuntimeMetadata,
619 result: DecisionResult,
620 requests: Mutex<Vec<DecisionRequest>>,
621}
622
623impl RecordingDecisionRuntime {
624 fn new(metadata: DecisionRuntimeMetadata, result: DecisionResult) -> Self {
625 Self {
626 metadata,
627 result,
628 requests: Mutex::new(Vec::new()),
629 }
630 }
631
632 fn recorded_requests(&self) -> Vec<DecisionRequest> {
633 self.requests
634 .lock()
635 .expect("recording runtime mutex")
636 .clone()
637 }
638}
639
640impl DecisionRuntime for RecordingDecisionRuntime {
641 fn metadata(&self) -> DecisionRuntimeMetadata {
642 self.metadata.clone()
643 }
644
645 fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
646 self.requests
647 .lock()
648 .expect("recording runtime mutex")
649 .push(request.clone());
650 Ok(self.result.clone())
651 }
652}
653
654enum TransportRuntimeHandle {
655 Deterministic(DeterministicDecisionRuntime),
656 Recording(RecordingDecisionRuntime),
657}
658
659impl TransportRuntimeHandle {
660 fn new(runtime: &DecisionTransportRuntime) -> Self {
661 match runtime {
662 DecisionTransportRuntime::Deterministic {
663 metadata,
664 responses,
665 } => Self::Deterministic(DeterministicDecisionRuntime::new(
666 metadata.clone(),
667 responses.clone(),
668 )),
669 DecisionTransportRuntime::Recording { metadata, result } => Self::Recording(
670 RecordingDecisionRuntime::new(metadata.clone(), result.clone()),
671 ),
672 }
673 }
674
675 fn recorded_requests(&self) -> Vec<DecisionRequest> {
676 match self {
677 Self::Deterministic(_) => Vec::new(),
678 Self::Recording(runtime) => runtime.recorded_requests(),
679 }
680 }
681}
682
683impl DecisionRuntime for TransportRuntimeHandle {
684 fn metadata(&self) -> DecisionRuntimeMetadata {
685 match self {
686 Self::Deterministic(runtime) => runtime.metadata(),
687 Self::Recording(runtime) => runtime.metadata(),
688 }
689 }
690
691 fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
692 match self {
693 Self::Deterministic(runtime) => runtime.execute(request),
694 Self::Recording(runtime) => runtime.execute(request),
695 }
696 }
697}
698
699fn digest(parts: &[String]) -> String {
700 let mut hasher = Sha256::new();
701 for part in parts {
702 hasher.update(part.as_bytes());
703 hasher.update([0x1f]);
704 }
705 format!("sha256:{:x}", hasher.finalize())
706}
707
708fn canonical_text(text: &str) -> String {
709 text.split_whitespace().collect::<Vec<_>>().join(" ")
710}
711
712fn require_object(value: &Value, message: &str) -> Result<Map<String, Value>, DecisionError> {
713 value
714 .as_object()
715 .cloned()
716 .ok_or_else(|| DecisionError::new("DECISION_INVALID_RESULT", message))
717}
718
719fn canonical_values(values: &[String]) -> Result<Vec<String>, DecisionError> {
720 if values.is_empty() {
721 return Err(DecisionError::new(
722 "DECISION_INVALID_OPTION",
723 "closed-set decisions must declare at least one value",
724 ));
725 }
726 let mut seen = BTreeSet::new();
727 let mut normalized = Vec::with_capacity(values.len());
728 for value in values {
729 let canonical = canonical_text(value);
730 if canonical.is_empty() {
731 return Err(DecisionError::new(
732 "DECISION_INVALID_OPTION",
733 "closed-set decisions cannot declare an empty value",
734 ));
735 }
736 if !seen.insert(canonical.clone()) {
737 return Err(DecisionError::new(
738 "DECISION_INVALID_OPTION",
739 format!("closed-set decision declared \"{canonical}\" more than once"),
740 ));
741 }
742 normalized.push(canonical);
743 }
744 Ok(normalized)
745}
746
747pub fn validate_decision_definition(
748 input: &DecisionDefinition,
749) -> Result<DecisionDefinition, DecisionError> {
750 Ok(match input {
751 DecisionDefinition::Binary { predicate } => {
752 let predicate = canonical_text(predicate);
753 if predicate.is_empty() {
754 return Err(DecisionError::new(
755 "DECISION_INVALID_RESULT",
756 "binary decisions require a non-empty predicate",
757 ));
758 }
759 DecisionDefinition::Binary { predicate }
760 }
761 DecisionDefinition::Choice { question, options } => DecisionDefinition::Choice {
762 question: {
763 let question = canonical_text(question);
764 if question.is_empty() {
765 return Err(DecisionError::new(
766 "DECISION_INVALID_RESULT",
767 "choice decisions require a non-empty question",
768 ));
769 }
770 question
771 },
772 options: canonical_values(options)?,
773 },
774 DecisionDefinition::Score { question, levels } => DecisionDefinition::Score {
775 question: {
776 let question = canonical_text(question);
777 if question.is_empty() {
778 return Err(DecisionError::new(
779 "DECISION_INVALID_RESULT",
780 "score decisions require a non-empty question",
781 ));
782 }
783 question
784 },
785 levels: canonical_values(levels)?,
786 },
787 })
788}
789
790fn distribution_by_values(
791 values: &[String],
792 observed: &[WeightedOption],
793) -> Result<Vec<WeightedOption>, DecisionError> {
794 let mut probabilities = BTreeMap::new();
795 let mut total = 0.0;
796 for entry in observed {
797 if !values.contains(&entry.value) {
798 return Err(DecisionError::new(
799 "DECISION_INVALID_OPTION",
800 format!("runtime returned undeclared option \"{}\"", entry.value),
801 ));
802 }
803 if entry.probability.is_nan()
804 || !entry.probability.is_finite()
805 || entry.probability < 0.0
806 || entry.probability > 1.0
807 {
808 return Err(DecisionError::new(
809 "DECISION_INVALID_DISTRIBUTION",
810 format!(
811 "runtime returned an invalid probability for \"{}\"",
812 entry.value
813 ),
814 ));
815 }
816 if probabilities
817 .insert(entry.value.clone(), entry.probability)
818 .is_some()
819 {
820 return Err(DecisionError::new(
821 "DECISION_INVALID_DISTRIBUTION",
822 format!(
823 "runtime returned duplicate probability for \"{}\"",
824 entry.value
825 ),
826 ));
827 }
828 total += entry.probability;
829 }
830 if probabilities.len() != values.len() {
831 return Err(DecisionError::new(
832 "DECISION_INVALID_DISTRIBUTION",
833 "runtime did not return probabilities for every declared value",
834 ));
835 }
836 if (total - 1.0).abs() > 1e-6 {
837 return Err(DecisionError::new(
838 "DECISION_INVALID_DISTRIBUTION",
839 "runtime returned a distribution that does not sum to 1",
840 ));
841 }
842 Ok(values
843 .iter()
844 .map(|value| WeightedOption {
845 value: value.clone(),
846 probability: *probabilities.get(value).unwrap_or(&0.0),
847 })
848 .collect())
849}
850
851pub fn validate_decision_result(
852 request: &DecisionRequest,
853 result: &DecisionResult,
854) -> Result<DecisionResult, DecisionError> {
855 let supporting_fields = result.supporting_fields().to_vec();
856 let option_mass = result.option_mass();
857 if option_mass.is_nan() || !option_mass.is_finite() || !(0.0..=1.0).contains(&option_mass) {
858 return Err(DecisionError::new(
859 "DECISION_INVALID_RESULT",
860 "runtime returned an optionMass outside [0, 1]",
861 ));
862 }
863
864 Ok(match (request, result) {
865 (DecisionRequest::Binary { .. }, DecisionResult::Binary { decision, .. }) => {
866 DecisionResult::Binary {
867 decision: *decision,
868 option_mass,
869 supporting_fields,
870 }
871 }
872 (
873 DecisionRequest::Choice { options, .. },
874 DecisionResult::Choice {
875 selected,
876 options: returned,
877 ..
878 },
879 ) => {
880 if !options.contains(selected) {
881 return Err(DecisionError::new(
882 "DECISION_INVALID_OPTION",
883 format!("runtime selected undeclared option \"{selected}\""),
884 ));
885 }
886 DecisionResult::Choice {
887 selected: selected.clone(),
888 options: distribution_by_values(options, returned)?,
889 option_mass,
890 supporting_fields,
891 }
892 }
893 (
894 DecisionRequest::Score { levels, .. },
895 DecisionResult::Score {
896 selected,
897 levels: returned,
898 ..
899 },
900 ) => {
901 if !levels.contains(selected) {
902 return Err(DecisionError::new(
903 "DECISION_INVALID_OPTION",
904 format!("runtime selected undeclared level \"{selected}\""),
905 ));
906 }
907 DecisionResult::Score {
908 selected: selected.clone(),
909 levels: distribution_by_values(levels, returned)?,
910 option_mass,
911 supporting_fields,
912 }
913 }
914 _ => {
915 return Err(DecisionError::new(
916 "DECISION_INVALID_RESULT",
917 "runtime returned a result for the wrong decision kind",
918 ))
919 }
920 })
921}
922
923fn selected_fields(record: &Map<String, Value>, fields: &Option<Vec<String>>) -> Vec<String> {
924 let mut readable: Vec<String> = record
925 .keys()
926 .filter(|key| !matches!(key.as_str(), "id" | "__version"))
927 .cloned()
928 .collect();
929 readable.sort();
930 match fields {
931 None => readable,
932 Some(fields) => {
933 let selected: BTreeSet<_> = fields.iter().cloned().collect();
934 readable
935 .into_iter()
936 .filter(|field| selected.contains(field))
937 .collect()
938 }
939 }
940}
941
942fn state_reference(application_id: Option<&str>, collection: &str, record_id: &str) -> String {
943 match application_id {
944 Some(application_id) if !application_id.is_empty() => {
945 format!("flow://{application_id}/{collection}/{record_id}")
946 }
947 _ => format!("{collection}:{record_id}"),
948 }
949}
950
951pub fn authorize_context(
952 collection: &str,
953 record_id: &str,
954 record: &Value,
955 options: &DecisionOptions,
956 authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
957) -> Result<DecisionContextPacket, DecisionError> {
958 let object = record.as_object().ok_or_else(|| {
959 DecisionError::new(
960 "DECISION_INVALID_RESULT",
961 "semantic decisions require object-shaped records",
962 )
963 })?;
964 let record_version = object.get("__version").and_then(Value::as_u64);
965 let selected = selected_fields(object, &options.fields);
966 let deselected: BTreeSet<_> = object
967 .keys()
968 .filter(|field| !matches!(field.as_str(), "id" | "__version"))
969 .filter(|field| !selected.contains(field))
970 .cloned()
971 .collect();
972 let application_id = options.application_id.as_deref().or_else(|| {
973 authorization
974 .and_then(|(_, config)| config.principal.as_ref())
975 .map(|principal| principal.application_id.as_str())
976 });
977 let mut authorized = Vec::new();
978 let mut withheld: BTreeSet<String> = deselected;
979 let mut decisions = Vec::new();
980
981 for field in &selected {
982 let allowed = if let Some((gate, config)) = authorization {
983 let principal = config.principal.as_ref().ok_or_else(|| {
984 DecisionError::new(
985 "DECISION_INVALID_RESULT",
986 "authorization config requires a principal",
987 )
988 })?;
989 let uri = format!(
990 "flow://{}/{}/{}/{}",
991 principal.application_id, collection, record_id, field
992 );
993 let decision = gate.check(&AuthorizationRequest {
994 subject: principal.subject.clone(),
995 tenant_id: principal.tenant_id.clone(),
996 application_id: principal.application_id.clone(),
997 resource: crate::authorization::Resource {
998 uri,
999 attributes: BTreeMap::new(),
1000 },
1001 capability: principal.capability.clone(),
1002 context: crate::authorization::AuthorizationContext::default(),
1003 grant_id: None,
1004 subject_attributes: BTreeMap::new(),
1005 })?;
1006 if let Some(decision_id) = decision.decision_id {
1007 decisions.push(decision_id);
1008 }
1009 decision.allowed
1010 } else {
1011 true
1012 };
1013
1014 if allowed {
1015 authorized.push(field.clone());
1016 } else {
1017 withheld.insert(field.clone());
1018 }
1019 }
1020
1021 let mut values = BTreeMap::new();
1022 for field in &authorized {
1023 if let Some(value) = object.get(field) {
1024 values.insert(field.clone(), value.clone());
1025 }
1026 }
1027 let projection = json!({ "fields": authorized, "values": values });
1028 let projection_json = serde_json::to_string(&projection)
1029 .map_err(|error| DecisionError::new("DECISION_INVALID_RESULT", error.to_string()))?;
1030 Ok(DecisionContextPacket {
1031 collection: collection.into(),
1032 record_id: record_id.into(),
1033 record_version,
1034 reference: state_reference(application_id, collection, record_id),
1035 fields: projection["fields"]
1036 .as_array()
1037 .into_iter()
1038 .flatten()
1039 .filter_map(|value| value.as_str().map(str::to_string))
1040 .collect(),
1041 values,
1042 withheld_fields: withheld.into_iter().collect(),
1043 relevant_state_version: digest(&[
1044 "semantic-decision-state-v1".into(),
1045 collection.into(),
1046 record_id.into(),
1047 projection_json.clone(),
1048 ]),
1049 context_hash: digest(&["semantic-decision-context-v1".into(), projection_json]),
1050 authorization_decisions: decisions,
1051 })
1052}
1053
1054fn request_with_context(
1055 definition: &DecisionDefinition,
1056 context: &DecisionContextPacket,
1057) -> DecisionRequest {
1058 request_with_values(definition, &context.values)
1059}
1060
1061fn request_with_values(
1062 definition: &DecisionDefinition,
1063 values: &BTreeMap<String, Value>,
1064) -> DecisionRequest {
1065 let context_value = Value::Object(
1066 values
1067 .iter()
1068 .map(|(field, value)| (field.clone(), value.clone()))
1069 .collect(),
1070 );
1071 match definition {
1072 DecisionDefinition::Binary { predicate } => DecisionRequest::Binary {
1073 predicate: predicate.clone(),
1074 context: context_value,
1075 },
1076 DecisionDefinition::Choice { question, options } => DecisionRequest::Choice {
1077 question: question.clone(),
1078 options: options.clone(),
1079 context: context_value,
1080 },
1081 DecisionDefinition::Score { question, levels } => DecisionRequest::Score {
1082 question: question.clone(),
1083 levels: levels.clone(),
1084 context: context_value,
1085 },
1086 }
1087}
1088
1089fn require_runtime_capability(
1090 runtime: &dyn DecisionRuntime,
1091 kind: &DecisionKind,
1092) -> Result<(), DecisionError> {
1093 let capabilities = runtime.capabilities();
1094 let supported = match kind {
1095 DecisionKind::Binary => capabilities.binary,
1096 DecisionKind::Choice => capabilities.choice,
1097 DecisionKind::Score => capabilities.score,
1098 };
1099 if supported {
1100 Ok(())
1101 } else {
1102 Err(DecisionError::new(
1103 "DECISION_UNSUPPORTED",
1104 format!("runtime does not support {:?} decisions", kind),
1105 ))
1106 }
1107}
1108
1109fn decision_collection<'a>(options: &'a DecisionOptions) -> &'a str {
1110 options
1111 .decision_collection
1112 .as_deref()
1113 .unwrap_or(DEFAULT_DECISION_COLLECTION)
1114}
1115
1116fn schema_version(options: &DecisionOptions) -> &str {
1117 options
1118 .schema_version
1119 .as_deref()
1120 .unwrap_or("semantic-decision-v1")
1121}
1122
1123fn lineage_key(
1124 collection: &str,
1125 record_id: &str,
1126 definition: &DecisionDefinition,
1127 runtime: &DecisionRuntimeMetadata,
1128 schema_version: &str,
1129) -> String {
1130 digest(&[
1131 "semantic-decision-lineage-v1".into(),
1132 format!("{:?}", definition.kind()),
1133 collection.into(),
1134 record_id.into(),
1135 canonical_text(definition.prompt()),
1136 serde_json::to_string(definition.schema_values()).unwrap_or_default(),
1137 runtime.runtime.clone(),
1138 runtime
1139 .model_revision
1140 .clone()
1141 .or(runtime.model.clone())
1142 .unwrap_or_default(),
1143 runtime.prompt_revision.clone().unwrap_or_default(),
1144 serde_json::to_string(&runtime.execution_method).unwrap_or_default(),
1145 runtime.decision_schema_revision.clone().unwrap_or_default(),
1146 schema_version.into(),
1147 ])
1148}
1149
1150pub fn decision_cache_key(
1151 collection: &str,
1152 record_id: &str,
1153 definition: &DecisionDefinition,
1154 context: &DecisionContextPacket,
1155 runtime: &DecisionRuntimeMetadata,
1156 schema_version: &str,
1157) -> String {
1158 digest(&[
1159 "semantic-decision-cache-v1".into(),
1160 format!("{:?}", definition.kind()),
1161 collection.into(),
1162 record_id.into(),
1163 canonical_text(definition.prompt()),
1164 serde_json::to_string(definition.schema_values()).unwrap_or_default(),
1165 context.relevant_state_version.clone(),
1166 runtime.runtime.clone(),
1167 runtime
1168 .model_revision
1169 .clone()
1170 .or(runtime.model.clone())
1171 .unwrap_or_default(),
1172 runtime.prompt_revision.clone().unwrap_or_default(),
1173 serde_json::to_string(&runtime.execution_method).unwrap_or_default(),
1174 runtime.decision_schema_revision.clone().unwrap_or_default(),
1175 schema_version.into(),
1176 ])
1177}
1178
1179fn valid_supporting_fields(
1180 context: &DecisionContextPacket,
1181 result: &DecisionResult,
1182) -> Vec<String> {
1183 result
1184 .supporting_fields()
1185 .iter()
1186 .filter(|field| context.fields.contains(field))
1187 .cloned()
1188 .collect()
1189}
1190
1191fn supporting_context(
1192 context: &DecisionContextPacket,
1193 supporting_fields: &[String],
1194) -> Vec<DecisionEvidenceField> {
1195 let fields = if supporting_fields.is_empty() {
1196 &context.fields
1197 } else {
1198 supporting_fields
1199 };
1200 fields
1201 .iter()
1202 .filter_map(|field| {
1203 context
1204 .values
1205 .get(field)
1206 .map(|value| DecisionEvidenceField {
1207 field: field.clone(),
1208 value: value.clone(),
1209 })
1210 })
1211 .collect()
1212}
1213
1214fn load_judgment(
1215 db: &FeltDb,
1216 collection: &str,
1217 key: &str,
1218) -> Result<Option<DecisionJudgmentRecord>, DecisionError> {
1219 let storage_key = format!("{collection}:{key}");
1220 db.capability(collection)
1221 .get(&storage_key)
1222 .map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))
1223}
1224
1225fn update_judgment(
1226 db: &FeltDb,
1227 collection: &str,
1228 key: &str,
1229 record: &DecisionJudgmentRecord,
1230) -> Result<(), DecisionError> {
1231 let storage_key = format!("{collection}:{key}");
1232 db.capability(collection)
1233 .update(&storage_key, record.clone())
1234 .map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))
1235}
1236
1237fn insert_judgment(
1238 db: &FeltDb,
1239 collection: &str,
1240 key: &str,
1241 record: &DecisionJudgmentRecord,
1242) -> Result<(), DecisionError> {
1243 let storage_key = format!("{collection}:{key}");
1244 db.capability(collection)
1245 .insert_with_key(&storage_key, record.clone())
1246 .map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))
1247}
1248
1249fn supersede_lineage(
1250 db: &FeltDb,
1251 decision_collection: &str,
1252 lineage_key: &str,
1253 keep: &str,
1254) -> Result<(), DecisionError> {
1255 let stale: Vec<DecisionJudgmentRecord> = db
1256 .capability(decision_collection)
1257 .find(|row: &DecisionJudgmentRecord| {
1258 row.lineage_key == lineage_key && row.status == DecisionStatus::Active && row.id != keep
1259 })
1260 .map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))?;
1261 let superseded_at = semantic_now_ms();
1262 for mut row in stale {
1263 row.status = DecisionStatus::Superseded;
1264 row.superseded_at = Some(superseded_at);
1265 update_judgment(db, decision_collection, &row.id, &row)?;
1266 }
1267 Ok(())
1268}
1269
1270fn stored_to_evaluation(
1271 stored: DecisionJudgmentRecord,
1272 context: &DecisionContextPacket,
1273 source: &str,
1274) -> DecisionEvaluation {
1275 DecisionEvaluation {
1276 result: stored.result.clone(),
1277 cached: source == "cache",
1278 evidence: vec![DecisionEvidence {
1279 decision_id: stored.id.clone(),
1280 kind: stored.kind.clone(),
1281 input: stored.input.clone(),
1282 result: stored.result.clone(),
1283 supporting_context: stored
1284 .supporting_fields
1285 .iter()
1286 .filter_map(|field| {
1287 context
1288 .values
1289 .get(field)
1290 .map(|value| DecisionEvidenceField {
1291 field: field.clone(),
1292 value: value.clone(),
1293 })
1294 })
1295 .collect(),
1296 runtime: DecisionRuntimeMetadata {
1297 runtime: stored.runtime.clone(),
1298 model: stored.model.clone(),
1299 model_revision: stored.model_revision.clone(),
1300 prompt_revision: stored.prompt_revision.clone(),
1301 execution_method: stored.execution_method.clone(),
1302 decision_schema_revision: stored.decision_schema_revision.clone(),
1303 },
1304 state: stored.state.clone(),
1305 }],
1306 provenance: DecisionProvenance {
1307 runtime: stored.runtime,
1308 model: stored.model,
1309 model_revision: stored.model_revision,
1310 prompt_revision: stored.prompt_revision,
1311 execution_method: stored.execution_method,
1312 decision_schema_revision: stored.decision_schema_revision,
1313 context_hash: context.context_hash.clone(),
1314 relevant_state_version: context.relevant_state_version.clone(),
1315 cache_key: stored.id,
1316 source: source.into(),
1317 evaluated_at: stored.evaluated_at,
1318 served_at: semantic_now_ms(),
1319 },
1320 }
1321}
1322
1323fn reusable_judgment(
1330 db: &FeltDb,
1331 storage: &str,
1332 cache_key: &str,
1333 lineage: &str,
1334) -> Result<Option<DecisionJudgmentRecord>, DecisionError> {
1335 let Some(mut stored) = load_judgment(db, storage, cache_key)? else {
1336 return Ok(None);
1337 };
1338 if stored.status != DecisionStatus::Active {
1339 supersede_lineage(db, storage, lineage, &stored.id)?;
1340 stored.status = DecisionStatus::Active;
1341 stored.superseded_at = None;
1342 update_judgment(db, storage, &stored.id, &stored)?;
1343 }
1344 Ok(Some(stored))
1345}
1346
1347pub fn reuse_record_judgment(
1357 db: &FeltDb,
1358 target: &DecisionRecordRef,
1359 definition: &DecisionDefinition,
1360 runtime_metadata: &DecisionRuntimeMetadata,
1361 options: &DecisionOptions,
1362 authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
1363) -> Result<Option<DecisionEvaluation>, DecisionError> {
1364 let definition = validate_decision_definition(definition)?;
1365 let record: Value = db
1366 .capability(&target.collection)
1367 .get(&target.record_id)
1368 .map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))?
1369 .ok_or_else(|| {
1370 DecisionError::new(
1371 "DECISION_RECORD_NOT_FOUND",
1372 "decision target record not found",
1373 )
1374 })?;
1375 let context = authorize_context(
1376 &target.collection,
1377 &target.record_id,
1378 &record,
1379 options,
1380 authorization,
1381 )?;
1382 if context.fields.is_empty() {
1383 return Err(DecisionError::new(
1384 "DECISION_CONTEXT_EMPTY",
1385 "decision refuses to evaluate an empty authorized context",
1386 ));
1387 }
1388 let cache_key = decision_cache_key(
1389 &target.collection,
1390 &target.record_id,
1391 &definition,
1392 &context,
1393 runtime_metadata,
1394 schema_version(options),
1395 );
1396 let lineage = lineage_key(
1397 &target.collection,
1398 &target.record_id,
1399 &definition,
1400 runtime_metadata,
1401 schema_version(options),
1402 );
1403 Ok(
1404 reusable_judgment(db, decision_collection(options), &cache_key, &lineage)?
1405 .map(|stored| stored_to_evaluation(stored, &context, "cache")),
1406 )
1407}
1408
1409pub fn configured_decision_runtime(
1418 runtime: &DecisionTransportRuntime,
1419) -> Result<Arc<dyn DecisionRuntime + Send + Sync>, DecisionError> {
1420 match runtime {
1421 DecisionTransportRuntime::Deterministic {
1422 metadata,
1423 responses,
1424 } => Ok(Arc::new(DeterministicDecisionRuntime::new(
1425 metadata.clone(),
1426 responses.clone(),
1427 ))),
1428 DecisionTransportRuntime::Recording { .. } => Err(DecisionError::new(
1429 "DECISION_UNSUPPORTED",
1430 "the recording runtime is a transport test double, not a configurable runtime binding",
1431 )),
1432 }
1433}
1434
1435pub fn decide_record(
1436 db: &FeltDb,
1437 target: &DecisionRecordRef,
1438 definition: &DecisionDefinition,
1439 runtime: &dyn DecisionRuntime,
1440 options: &DecisionOptions,
1441 authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
1442) -> Result<DecisionEvaluation, DecisionError> {
1443 let definition = validate_decision_definition(definition)?;
1444 require_runtime_capability(runtime, &definition.kind())?;
1445 let record: Value = db
1446 .capability(&target.collection)
1447 .get(&target.record_id)
1448 .map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))?
1449 .ok_or_else(|| {
1450 DecisionError::new(
1451 "DECISION_RECORD_NOT_FOUND",
1452 "decision target record not found",
1453 )
1454 })?;
1455 let context = authorize_context(
1456 &target.collection,
1457 &target.record_id,
1458 &record,
1459 options,
1460 authorization,
1461 )?;
1462 if context.fields.is_empty() {
1463 return Err(DecisionError::new(
1464 "DECISION_CONTEXT_EMPTY",
1465 "decision refuses to evaluate an empty authorized context",
1466 ));
1467 }
1468 let runtime_metadata = runtime.metadata();
1469 let cache_key = decision_cache_key(
1470 &target.collection,
1471 &target.record_id,
1472 &definition,
1473 &context,
1474 &runtime_metadata,
1475 schema_version(options),
1476 );
1477 let lineage = lineage_key(
1478 &target.collection,
1479 &target.record_id,
1480 &definition,
1481 &runtime_metadata,
1482 schema_version(options),
1483 );
1484 let storage = decision_collection(options);
1485 if let Some(stored) = reusable_judgment(db, storage, &cache_key, &lineage)? {
1486 return Ok(stored_to_evaluation(stored, &context, "cache"));
1487 }
1488
1489 let request = request_with_context(&definition, &context);
1490 let result = validate_decision_result(&request, &runtime.execute(&request)?)?;
1491 let evaluated_at = semantic_now_ms();
1492 let supporting_fields = valid_supporting_fields(&context, &result);
1493 let supporting_context = supporting_context(&context, &supporting_fields);
1494 let state = DecisionStateReference {
1495 collection: target.collection.clone(),
1496 record_id: target.record_id.clone(),
1497 record_version: context.record_version,
1498 reference: context.reference.clone(),
1499 relevant_state_version: context.relevant_state_version.clone(),
1500 withheld_fields: context.withheld_fields.clone(),
1501 };
1502 let evidence = DecisionEvidence {
1503 decision_id: cache_key.clone(),
1504 kind: definition.kind(),
1505 input: definition.clone(),
1506 result: result.clone(),
1507 supporting_context,
1508 runtime: runtime_metadata.clone(),
1509 state: state.clone(),
1510 };
1511 let record = DecisionJudgmentRecord {
1512 id: cache_key.clone(),
1513 lineage_key: lineage.clone(),
1514 kind: definition.kind(),
1515 collection: target.collection.clone(),
1516 record_id: target.record_id.clone(),
1517 input: definition.clone(),
1518 schema_version: schema_version(options).into(),
1519 relevant_state_version: context.relevant_state_version.clone(),
1520 runtime: runtime_metadata.runtime.clone(),
1521 model: runtime_metadata.model.clone(),
1522 model_revision: runtime_metadata.model_revision.clone(),
1523 prompt_revision: runtime_metadata.prompt_revision.clone(),
1524 execution_method: runtime_metadata.execution_method.clone(),
1525 decision_schema_revision: runtime_metadata.decision_schema_revision.clone(),
1526 option_mass: result.option_mass(),
1527 context_fields: context.fields.clone(),
1528 supporting_fields: if result.supporting_fields().is_empty() {
1529 context.fields.clone()
1530 } else {
1531 supporting_fields.clone()
1532 },
1533 status: DecisionStatus::Active,
1534 state,
1535 evidence: evidence.clone(),
1536 result: result.clone(),
1537 evaluated_at,
1538 superseded_at: None,
1539 };
1540 supersede_lineage(db, storage, &lineage, &cache_key)?;
1541 insert_judgment(db, storage, &cache_key, &record)?;
1542 Ok(DecisionEvaluation {
1543 result,
1544 cached: false,
1545 evidence: vec![evidence],
1546 provenance: DecisionProvenance {
1547 runtime: runtime_metadata.runtime,
1548 model: runtime_metadata.model,
1549 model_revision: runtime_metadata.model_revision,
1550 prompt_revision: runtime_metadata.prompt_revision,
1551 execution_method: runtime_metadata.execution_method,
1552 decision_schema_revision: runtime_metadata.decision_schema_revision,
1553 context_hash: context.context_hash,
1554 relevant_state_version: context.relevant_state_version,
1555 cache_key,
1556 source: "runtime".into(),
1557 evaluated_at,
1558 served_at: evaluated_at,
1559 },
1560 })
1561}
1562
1563fn evaluate_context_packet(
1564 target: DecisionRecordRef,
1565 definition: &DecisionDefinition,
1566 context: &DecisionContextPacket,
1567 runtime: &dyn DecisionRuntime,
1568 options: &DecisionOptions,
1569) -> Result<DecisionEvaluation, DecisionError> {
1570 let definition = validate_decision_definition(definition)?;
1571 require_runtime_capability(runtime, &definition.kind())?;
1572 if context.fields.is_empty() {
1573 return Err(DecisionError::new(
1574 "DECISION_CONTEXT_EMPTY",
1575 "decision refuses to evaluate an empty authorized context",
1576 ));
1577 }
1578 let runtime_metadata = runtime.metadata();
1579 let cache_key = decision_cache_key(
1580 &target.collection,
1581 &target.record_id,
1582 &definition,
1583 context,
1584 &runtime_metadata,
1585 schema_version(options),
1586 );
1587 let request = request_with_context(&definition, context);
1588 let result = validate_decision_result(&request, &runtime.execute(&request)?)?;
1589 let evaluated_at = semantic_now_ms();
1590 let supporting_fields = valid_supporting_fields(context, &result);
1591 let state = DecisionStateReference {
1592 collection: target.collection,
1593 record_id: target.record_id,
1594 record_version: context.record_version,
1595 reference: context.reference.clone(),
1596 relevant_state_version: context.relevant_state_version.clone(),
1597 withheld_fields: context.withheld_fields.clone(),
1598 };
1599 let evidence = DecisionEvidence {
1600 decision_id: cache_key.clone(),
1601 kind: definition.kind(),
1602 input: definition,
1603 result: result.clone(),
1604 supporting_context: supporting_context(context, &supporting_fields),
1605 runtime: runtime_metadata.clone(),
1606 state,
1607 };
1608 Ok(DecisionEvaluation {
1609 result,
1610 cached: false,
1611 evidence: vec![evidence],
1612 provenance: DecisionProvenance {
1613 runtime: runtime_metadata.runtime,
1614 model: runtime_metadata.model,
1615 model_revision: runtime_metadata.model_revision,
1616 prompt_revision: runtime_metadata.prompt_revision,
1617 execution_method: runtime_metadata.execution_method,
1618 decision_schema_revision: runtime_metadata.decision_schema_revision,
1619 context_hash: context.context_hash.clone(),
1620 relevant_state_version: context.relevant_state_version.clone(),
1621 cache_key,
1622 source: "runtime".into(),
1623 evaluated_at,
1624 served_at: evaluated_at,
1625 },
1626 })
1627}
1628
1629fn inline_context_packet(
1630 request: &DecisionTransportExecutionRequest,
1631 authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
1632) -> Result<DecisionContextPacket, DecisionError> {
1633 let context = request.context.as_ref().ok_or_else(|| {
1634 DecisionError::new(
1635 "DECISION_INVALID_RESULT",
1636 "inline decision transport requires a context object",
1637 )
1638 })?;
1639 authorize_context(
1640 "$context",
1641 "inline",
1642 context,
1643 &request.options,
1644 authorization,
1645 )
1646}
1647
1648pub fn execute_inline_decision_transport(
1649 request: &DecisionTransportExecutionRequest,
1650) -> Result<DecisionTransportExecutionResponse, DecisionError> {
1651 let runtime = TransportRuntimeHandle::new(&request.runtime);
1652 let authorization_gate = request.authorization.as_ref().map(|authorization| {
1653 (
1654 TransportAuthorizationGate::new(&authorization.allowed_fields),
1655 DecisionAuthorizationConfig {
1656 principal: authorization.principal.clone(),
1657 },
1658 )
1659 });
1660 let authorization = authorization_gate
1661 .as_ref()
1662 .map(|(gate, config)| (gate as &dyn DecisionAuthorizationGate, config));
1663 if request.target.is_some() {
1664 return Err(DecisionError::new(
1665 "DECISION_INVALID_RESULT",
1666 "inline decision transport does not support durable target records",
1667 ));
1668 }
1669 let evaluation = evaluate_context_packet(
1670 DecisionRecordRef {
1671 collection: "$context".into(),
1672 record_id: "inline".into(),
1673 },
1674 &request.definition,
1675 &inline_context_packet(request, authorization)?,
1676 &runtime,
1677 &request.options,
1678 )?;
1679 Ok(DecisionTransportExecutionResponse {
1680 evaluation,
1681 runtime_requests: runtime.recorded_requests(),
1682 })
1683}
1684
1685pub fn execute_decision_transport(
1686 db: &FeltDb,
1687 request: &DecisionTransportExecutionRequest,
1688) -> Result<DecisionTransportExecutionResponse, DecisionError> {
1689 let runtime = TransportRuntimeHandle::new(&request.runtime);
1690 let authorization_gate = request.authorization.as_ref().map(|authorization| {
1691 (
1692 TransportAuthorizationGate {
1693 allowed_fields: authorization.allowed_fields.iter().cloned().collect(),
1694 },
1695 DecisionAuthorizationConfig {
1696 principal: authorization.principal.clone(),
1697 },
1698 )
1699 });
1700 let authorization = authorization_gate
1701 .as_ref()
1702 .map(|(gate, config)| (gate as &dyn DecisionAuthorizationGate, config));
1703 let evaluation = match (&request.target, &request.context) {
1704 (Some(_), Some(_)) => return Err(DecisionError::new(
1705 "DECISION_INVALID_RESULT",
1706 "decision transport accepts either a target record or an inline context, but not both",
1707 )),
1708 (Some(target), None) => decide_record(
1709 db,
1710 target,
1711 &request.definition,
1712 &runtime,
1713 &request.options,
1714 authorization,
1715 )?,
1716 (None, Some(context)) => evaluate_context_packet(
1717 DecisionRecordRef {
1718 collection: "$context".into(),
1719 record_id: "inline".into(),
1720 },
1721 &request.definition,
1722 &authorize_context(
1723 "$context",
1724 "inline",
1725 context,
1726 &request.options,
1727 authorization,
1728 )?,
1729 &runtime,
1730 &request.options,
1731 )?,
1732 (None, None) => {
1733 return Err(DecisionError::new(
1734 "DECISION_INVALID_RESULT",
1735 "decision transport requires either a target record or an inline context",
1736 ))
1737 }
1738 };
1739 Ok(DecisionTransportExecutionResponse {
1740 evaluation,
1741 runtime_requests: runtime.recorded_requests(),
1742 })
1743}
1744
1745pub fn execute_decision_batch(
1746 context: &DecisionContextPacket,
1747 batch: &DecisionBatch,
1748 runtime: &dyn DecisionRuntime,
1749) -> Result<Vec<DecisionBatchEvaluation>, DecisionError> {
1750 if batch.decisions.is_empty() {
1751 return Err(DecisionError::new(
1752 "DECISION_INVALID_RESULT",
1753 "decision batches require at least one decision",
1754 ));
1755 }
1756 if !runtime.capabilities().batch {
1757 return Err(DecisionError::new(
1758 "DECISION_UNSUPPORTED",
1759 "runtime does not support decision batches",
1760 ));
1761 }
1762 let mut ids = BTreeSet::new();
1763 let shared = require_object(
1764 &batch.context,
1765 "decision batch shared context must be object-shaped",
1766 )?;
1767 let mut base = context.values.clone();
1768 for (field, value) in shared {
1769 base.insert(field, value);
1770 }
1771 let mut requests = Vec::with_capacity(batch.decisions.len());
1772 let mut ids_in_order = Vec::with_capacity(batch.decisions.len());
1773 for decision in &batch.decisions {
1774 if canonical_text(&decision.id).is_empty() || !ids.insert(decision.id.clone()) {
1775 return Err(DecisionError::new(
1776 "DECISION_INVALID_RESULT",
1777 "decision batches require unique non-empty decision ids",
1778 ));
1779 }
1780 let local = require_object(
1781 &decision.context,
1782 "decision batch decision context must be object-shaped",
1783 )?;
1784 let definition = validate_decision_definition(&decision.definition)?;
1785 let mut merged = base.clone();
1786 for (field, value) in local {
1787 merged.insert(field, value);
1788 }
1789 let request = request_with_values(&definition, &merged);
1790 ids_in_order.push(decision.id.clone());
1791 requests.push(request);
1792 }
1793 let results = runtime.execute_batch(&requests)?;
1794 if results.len() != requests.len() {
1795 return Err(DecisionError::new(
1796 "DECISION_INVALID_RESULT",
1797 "runtime returned a batch result count different from the request count",
1798 ));
1799 }
1800 let mut evaluations = Vec::with_capacity(results.len());
1801 for ((request, result), id) in requests.into_iter().zip(results).zip(ids_in_order) {
1802 let result = validate_decision_result(&request, &result)?;
1803 evaluations.push(DecisionBatchEvaluation { id, result });
1804 }
1805 Ok(evaluations)
1806}
1807
1808fn permutations(values: &[String]) -> Vec<Vec<String>> {
1809 if values.len() <= 1 {
1810 return vec![values.to_vec()];
1811 }
1812 let mut result = Vec::new();
1813 for index in 0..values.len() {
1814 let mut remaining = values.to_vec();
1815 let head = remaining.remove(index);
1816 for mut tail in permutations(&remaining) {
1817 let mut permutation = vec![head.clone()];
1818 permutation.append(&mut tail);
1819 result.push(permutation);
1820 }
1821 }
1822 result
1823}
1824
1825fn observe_choice(
1826 context: &DecisionContextPacket,
1827 question: &str,
1828 options: Vec<String>,
1829 runtime: &dyn DecisionRuntime,
1830) -> Result<ChoicePermutationObservation, DecisionError> {
1831 let definition = validate_decision_definition(&DecisionDefinition::Choice {
1832 question: question.into(),
1833 options: options.clone(),
1834 })?;
1835 let request = request_with_context(&definition, context);
1836 let result = validate_decision_result(&request, &runtime.execute(&request)?)?;
1837 match result {
1838 DecisionResult::Choice {
1839 selected,
1840 options: distribution,
1841 ..
1842 } => {
1843 let rank = distribution
1844 .iter()
1845 .enumerate()
1846 .find_map(|(index, option)| (option.value == selected).then_some(index + 1))
1847 .unwrap_or(distribution.len());
1848 let probability = distribution
1849 .iter()
1850 .find_map(|option| (option.value == selected).then_some(option.probability))
1851 .unwrap_or(0.0);
1852 Ok(ChoicePermutationObservation {
1853 order: options,
1854 selected,
1855 distribution,
1856 rank,
1857 probability,
1858 })
1859 }
1860 _ => Err(DecisionError::new(
1861 "DECISION_INVALID_RESULT",
1862 "choice diagnostics require a choice decision",
1863 )),
1864 }
1865}
1866
1867pub fn run_choice_diagnostics(
1868 context: &DecisionContextPacket,
1869 question: &str,
1870 options: &[String],
1871 expansion_option: Option<&str>,
1872 runtime: &dyn DecisionRuntime,
1873) -> Result<ChoiceDiagnosticsReport, DecisionError> {
1874 let canonical = validate_decision_definition(&DecisionDefinition::Choice {
1875 question: question.into(),
1876 options: options.to_vec(),
1877 })?;
1878 let base_options = canonical.schema_values().to_vec();
1879 let mut permutations_observed = Vec::new();
1880 for order in permutations(&base_options) {
1881 permutations_observed.push(observe_choice(context, question, order, runtime)?);
1882 }
1883 let expansion = match expansion_option {
1884 Some(option) => {
1885 let baseline = observe_choice(context, question, base_options.clone(), runtime)?;
1886 let mut expanded = base_options.clone();
1887 expanded.push(option.into());
1888 Some(ChoiceExpansionObservation {
1889 baseline,
1890 expanded: observe_choice(context, question, expanded, runtime)?,
1891 })
1892 }
1893 None => None,
1894 };
1895 Ok(ChoiceDiagnosticsReport {
1896 permutations: permutations_observed,
1897 expansion,
1898 })
1899}
1900
1901pub fn jev(
1902 db: &FeltDb,
1903 target: &DecisionRecordRef,
1904 predicate: &str,
1905 runtime: &dyn DecisionRuntime,
1906 options: &DecisionOptions,
1907 authorization: Option<(&dyn DecisionAuthorizationGate, &DecisionAuthorizationConfig)>,
1908) -> Result<DecisionEvaluation, DecisionError> {
1909 decide_record(
1910 db,
1911 target,
1912 &DecisionDefinition::Binary {
1913 predicate: predicate.into(),
1914 },
1915 runtime,
1916 options,
1917 authorization,
1918 )
1919}
1920
1921#[derive(Debug, Clone)]
1922pub struct DeterministicDecisionRuntime {
1923 metadata: DecisionRuntimeMetadata,
1924 responses: BTreeMap<String, DecisionResult>,
1925}
1926
1927impl DeterministicDecisionRuntime {
1928 pub fn new(
1929 metadata: DecisionRuntimeMetadata,
1930 responses: BTreeMap<String, DecisionResult>,
1931 ) -> Self {
1932 Self {
1933 metadata,
1934 responses,
1935 }
1936 }
1937}
1938
1939impl DecisionRuntime for DeterministicDecisionRuntime {
1940 fn metadata(&self) -> DecisionRuntimeMetadata {
1941 self.metadata.clone()
1942 }
1943
1944 fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
1945 let key = serde_json::to_string(request)
1946 .map_err(|error| DecisionError::new("DECISION_INVALID_RESULT", error.to_string()))?;
1947 self.responses.get(&key).cloned().ok_or_else(|| {
1948 DecisionError::new(
1949 "DECISION_INVALID_RESULT",
1950 "deterministic runtime has no configured response for this request",
1951 )
1952 })
1953 }
1954}
1955
1956pub fn deterministic_request_key(request: &DecisionRequest) -> Result<String, DecisionError> {
1957 serde_json::to_string(request)
1958 .map_err(|error| DecisionError::new("DECISION_INVALID_RESULT", error.to_string()))
1959}
1960
1961pub fn run_decision_benchmark(
1962 db: &FeltDb,
1963 runtime: &dyn DecisionRuntime,
1964 options: &DecisionOptions,
1965) -> Result<DecisionBenchmarkReport, DecisionError> {
1966 let collection = db.capability("benchmark_people");
1967 for index in 0..20 {
1968 collection
1969 .insert_with_key(
1970 &format!("b-{index}"),
1971 json!({
1972 "id": format!("b-{index}"),
1973 "__version": 1,
1974 "employmentType": if index % 2 == 0 { "remote" } else { "office" },
1975 "publicRole": if index % 3 == 0 { "Staff Backend Engineer" } else { "Lab Technician" },
1976 "urgency": if index % 3 == 0 { "high" } else if index % 3 == 1 { "medium" } else { "low" },
1977 "notes": if index % 2 == 0 { "Remote-first and distributed systems ownership." } else { "On-site inventory and lab work." }
1978 }),
1979 )
1980 .map_err(|error| DecisionError::new("DECISION_STORAGE_ERROR", error.to_string()))?;
1981 }
1982 let mut first = 0usize;
1983 let mut second_cache_hits = 0usize;
1984 let mut second_runtime = 0usize;
1985 for index in 0..20 {
1986 let target = DecisionRecordRef {
1987 collection: "benchmark_people".into(),
1988 record_id: format!("b-{index}"),
1989 };
1990 for definition in [
1991 DecisionDefinition::Binary {
1992 predicate: "could work from home".into(),
1993 },
1994 DecisionDefinition::Choice {
1995 question: "Which reviewer should handle this?".into(),
1996 options: vec!["approve".into(), "reject".into(), "review".into()],
1997 },
1998 DecisionDefinition::Score {
1999 question: "What is the urgency?".into(),
2000 levels: vec!["low".into(), "medium".into(), "high".into()],
2001 },
2002 ] {
2003 let first_eval = decide_record(db, &target, &definition, runtime, options, None)?;
2004 if !first_eval.cached {
2005 first += 1;
2006 }
2007 let second_eval = decide_record(db, &target, &definition, runtime, options, None)?;
2008 if second_eval.cached {
2009 second_cache_hits += 1;
2010 } else {
2011 second_runtime += 1;
2012 }
2013 }
2014 }
2015 Ok(DecisionBenchmarkReport {
2016 binary: 20,
2017 choice: 20,
2018 score: 20,
2019 runtime_invocations_first_pass: first,
2020 runtime_invocations_second_pass: second_runtime,
2021 cache_hits_second_pass: second_cache_hits,
2022 })
2023}
2024
2025impl From<DecisionError> for FlowError {
2026 fn from(error: DecisionError) -> Self {
2027 FlowError::CapabilityError(format!("{}: {}", error.code, error.message))
2028 }
2029}