Skip to main content

feltdb/
semantic_decision.rs

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
1323/// The stored judgment whose identity matches `cache_key`, if one exists.
1324///
1325/// A judgment superseded by an intermediate state change that has since been
1326/// reversed carries the same identity as the state it was derived from, so it
1327/// is reactivated rather than re-derived: the lineage's other judgments are
1328/// superseded and this one becomes active again.
1329fn 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
1347/// Reuse a durable judgment for a record without invoking the runtime.
1348///
1349/// This is the `reuse-only` path of semantic query composition: the record is
1350/// read and authorized exactly as `decide_record` reads and authorizes it, the
1351/// judgment identity is derived from that authorized state and the runtime's
1352/// metadata, and the stored judgment is returned when one with that identity
1353/// exists. `Ok(None)` means no reusable judgment exists; nothing is executed
1354/// and nothing is written except the reactivation described on
1355/// `reusable_judgment`.
1356pub 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
1409/// Build the runtime binding a FeltDB deployment configures for semantic
1410/// query composition.
1411///
1412/// A query never names its runtime; the deployment does, and the binding's
1413/// identity becomes part of every judgment the query stage produces. Only the
1414/// deterministic runtime exists in Rust today, so this is the only binding a
1415/// deployment can configure; the recording runtime is a transport test double
1416/// that answers every request identically and is refused here.
1417pub 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}