create-feltdb 0.11.2 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1165 @@
1
+ use feltdb::authorization::{AuthorizationRequest, Subject};
2
+ use feltdb::semantic_decision::{
3
+ authorize_context, decide_record, deterministic_request_key, execute_decision_batch,
4
+ execute_decision_transport, jev, run_choice_diagnostics, run_decision_benchmark,
5
+ DecisionAuthorizationConfig, DecisionAuthorizationDecision, DecisionAuthorizationGate,
6
+ DecisionBatch, DecisionBatchDecision, DecisionDefinition, DecisionError, DecisionOptions,
7
+ DecisionRecordRef, DecisionRequest, DecisionResult, DecisionRuntime, DecisionRuntimeMetadata,
8
+ DecisionStatus, DecisionTransportAuthorization, DecisionTransportExecutionRequest,
9
+ DecisionTransportRuntime, DeterministicDecisionRuntime, WeightedOption,
10
+ DEFAULT_DECISION_COLLECTION,
11
+ };
12
+ use feltdb::FeltDb;
13
+ use serde_json::{json, Value};
14
+ use std::collections::{BTreeMap, BTreeSet};
15
+ use std::sync::atomic::{AtomicUsize, Ordering};
16
+ use std::sync::{Arc, Mutex};
17
+ use tempfile::TempDir;
18
+
19
+ #[derive(Clone)]
20
+ struct RecordingRuntime {
21
+ metadata: DecisionRuntimeMetadata,
22
+ calls: Arc<Mutex<Vec<DecisionRequest>>>,
23
+ invocations: Arc<AtomicUsize>,
24
+ }
25
+
26
+ impl RecordingRuntime {
27
+ fn new(model_revision: &str, prompt_revision: &str) -> Self {
28
+ Self {
29
+ metadata: DecisionRuntimeMetadata {
30
+ runtime: "deterministic".into(),
31
+ model: Some("deterministic-runtime".into()),
32
+ model_revision: Some(model_revision.into()),
33
+ prompt_revision: Some(prompt_revision.into()),
34
+ execution_method: Some(feltdb::DecisionExecutionMethod::Logit),
35
+ decision_schema_revision: Some("semantic-decision-v1".into()),
36
+ },
37
+ calls: Arc::new(Mutex::new(Vec::new())),
38
+ invocations: Arc::new(AtomicUsize::new(0)),
39
+ }
40
+ }
41
+
42
+ fn calls(&self) -> Vec<DecisionRequest> {
43
+ self.calls.lock().expect("calls").clone()
44
+ }
45
+
46
+ fn invocations(&self) -> usize {
47
+ self.invocations.load(Ordering::SeqCst)
48
+ }
49
+ }
50
+
51
+ impl DecisionRuntime for RecordingRuntime {
52
+ fn metadata(&self) -> DecisionRuntimeMetadata {
53
+ self.metadata.clone()
54
+ }
55
+
56
+ fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
57
+ self.invocations.fetch_add(1, Ordering::SeqCst);
58
+ self.calls.lock().expect("calls").push(request.clone());
59
+ let context = request.context().as_object().cloned().unwrap_or_default();
60
+ Ok(match request {
61
+ DecisionRequest::Binary { .. } => {
62
+ let decision = context
63
+ .get("employmentType")
64
+ .and_then(Value::as_str)
65
+ .is_some_and(|value| matches!(value, "remote" | "hybrid"));
66
+ DecisionResult::Binary {
67
+ decision,
68
+ option_mass: if decision { 0.9 } else { 0.8 },
69
+ supporting_fields: vec!["employmentType".into()],
70
+ }
71
+ }
72
+ DecisionRequest::Choice { options, .. } => {
73
+ let selected = context
74
+ .get("actionHint")
75
+ .and_then(Value::as_str)
76
+ .unwrap_or(options.first().map(String::as_str).unwrap_or("approve"))
77
+ .to_string();
78
+ DecisionResult::Choice {
79
+ option_mass: 0.85,
80
+ selected: selected.clone(),
81
+ options: options
82
+ .iter()
83
+ .map(|value| WeightedOption {
84
+ value: value.clone(),
85
+ probability: if value == &selected {
86
+ 0.85
87
+ } else {
88
+ 0.15 / ((options.len() - 1).max(1) as f64)
89
+ },
90
+ })
91
+ .collect(),
92
+ supporting_fields: vec!["actionHint".into()],
93
+ }
94
+ }
95
+ DecisionRequest::Score { levels, .. } => {
96
+ let selected = context
97
+ .get("urgency")
98
+ .and_then(Value::as_str)
99
+ .unwrap_or(levels.first().map(String::as_str).unwrap_or("low"))
100
+ .to_string();
101
+ DecisionResult::Score {
102
+ option_mass: 0.88,
103
+ selected: selected.clone(),
104
+ levels: levels
105
+ .iter()
106
+ .map(|value| WeightedOption {
107
+ value: value.clone(),
108
+ probability: if value == &selected {
109
+ 0.88
110
+ } else {
111
+ 0.12 / ((levels.len() - 1).max(1) as f64)
112
+ },
113
+ })
114
+ .collect(),
115
+ supporting_fields: vec!["urgency".into()],
116
+ }
117
+ }
118
+ })
119
+ }
120
+ }
121
+
122
+ struct StaticGate {
123
+ allowed: BTreeSet<String>,
124
+ requests: Arc<Mutex<Vec<AuthorizationRequest>>>,
125
+ }
126
+
127
+ impl StaticGate {
128
+ fn new(fields: &[&str]) -> Self {
129
+ Self {
130
+ allowed: fields.iter().map(|field| (*field).to_string()).collect(),
131
+ requests: Arc::new(Mutex::new(Vec::new())),
132
+ }
133
+ }
134
+ }
135
+
136
+ impl DecisionAuthorizationGate for StaticGate {
137
+ fn check(
138
+ &self,
139
+ request: &AuthorizationRequest,
140
+ ) -> Result<DecisionAuthorizationDecision, DecisionError> {
141
+ self.requests
142
+ .lock()
143
+ .expect("requests")
144
+ .push(request.clone());
145
+ let field = request
146
+ .resource
147
+ .uri
148
+ .split('/')
149
+ .next_back()
150
+ .unwrap_or_default()
151
+ .to_string();
152
+ Ok(DecisionAuthorizationDecision {
153
+ allowed: self.allowed.contains(&field),
154
+ decision_id: Some(format!("decision-{field}")),
155
+ })
156
+ }
157
+ }
158
+
159
+ fn authorization_config() -> DecisionAuthorizationConfig {
160
+ DecisionAuthorizationConfig {
161
+ principal: Some(feltdb::DecisionPrincipal {
162
+ subject: Subject::Service("reporting-service".into()),
163
+ tenant_id: "tenant-a".into(),
164
+ application_id: "people-directory".into(),
165
+ capability: "state:read".into(),
166
+ }),
167
+ }
168
+ }
169
+
170
+ fn memory_db(name: &str) -> FeltDb {
171
+ let path = std::env::temp_dir().join(format!(
172
+ "feltdb-semantic-decision-{name}-{}.log",
173
+ std::time::SystemTime::now()
174
+ .duration_since(std::time::UNIX_EPOCH)
175
+ .expect("time")
176
+ .as_nanos()
177
+ ));
178
+ FeltDb::open(path).expect("db")
179
+ }
180
+
181
+ fn insert_person(db: &FeltDb, id: &str, record: Value) {
182
+ db.insert(&format!("people:{id}"), record).expect("insert");
183
+ }
184
+
185
+ fn update_person(db: &FeltDb, id: &str, record: Value) {
186
+ db.update(&format!("people:{id}"), record).expect("update");
187
+ }
188
+
189
+ fn judgment_rows(db: &FeltDb) -> Vec<feltdb::DecisionJudgmentRecord> {
190
+ db.capability(DEFAULT_DECISION_COLLECTION)
191
+ .find(|_: &feltdb::DecisionJudgmentRecord| true)
192
+ .expect("judgments")
193
+ }
194
+
195
+ #[test]
196
+ fn binary_choice_and_score_decisions_are_valid_and_authoritative() {
197
+ let db = memory_db("valid");
198
+ insert_person(
199
+ &db,
200
+ "p-1",
201
+ json!({
202
+ "id": "p-1",
203
+ "__version": 1,
204
+ "publicRole": "Staff Backend Engineer",
205
+ "employmentType": "remote",
206
+ "actionHint": "review",
207
+ "urgency": "high",
208
+ }),
209
+ );
210
+ let runtime = RecordingRuntime::new("deterministic-v1", "prompt-v1");
211
+ let target = DecisionRecordRef {
212
+ collection: "people".into(),
213
+ record_id: "p-1".into(),
214
+ };
215
+
216
+ let binary = jev(
217
+ &db,
218
+ &target,
219
+ "could work from home",
220
+ &runtime,
221
+ &DecisionOptions::default(),
222
+ None,
223
+ )
224
+ .expect("binary");
225
+ let choice = decide_record(
226
+ &db,
227
+ &target,
228
+ &DecisionDefinition::Choice {
229
+ question: "Which action should be taken?".into(),
230
+ options: vec!["approve".into(), "reject".into(), "review".into()],
231
+ },
232
+ &runtime,
233
+ &DecisionOptions::default(),
234
+ None,
235
+ )
236
+ .expect("choice");
237
+ let score = decide_record(
238
+ &db,
239
+ &target,
240
+ &DecisionDefinition::Score {
241
+ question: "What is the urgency?".into(),
242
+ levels: vec!["low".into(), "medium".into(), "high".into()],
243
+ },
244
+ &runtime,
245
+ &DecisionOptions::default(),
246
+ None,
247
+ )
248
+ .expect("score");
249
+
250
+ assert!(matches!(
251
+ &binary.result,
252
+ DecisionResult::Binary { decision: true, .. }
253
+ ));
254
+ assert!(
255
+ matches!(choice.result, DecisionResult::Choice { ref selected, .. } if selected == "review")
256
+ );
257
+ assert!(
258
+ matches!(score.result, DecisionResult::Score { ref selected, .. } if selected == "high")
259
+ );
260
+ assert_eq!(runtime.invocations(), 3);
261
+ assert_eq!(judgment_rows(&db).len(), 3);
262
+ assert_eq!(
263
+ binary.provenance.execution_method,
264
+ Some(feltdb::DecisionExecutionMethod::Logit)
265
+ );
266
+ assert_eq!(
267
+ binary.provenance.decision_schema_revision.as_deref(),
268
+ Some("semantic-decision-v1")
269
+ );
270
+ }
271
+
272
+ #[test]
273
+ fn invalid_runtime_results_fail_closed() {
274
+ let db = memory_db("invalid-results");
275
+ insert_person(
276
+ &db,
277
+ "p-1",
278
+ json!({"id":"p-1","__version":1,"employmentType":"remote","actionHint":"review","urgency":"medium"}),
279
+ );
280
+ let target = DecisionRecordRef {
281
+ collection: "people".into(),
282
+ record_id: "p-1".into(),
283
+ };
284
+
285
+ struct InvalidRuntime(&'static str);
286
+ impl DecisionRuntime for InvalidRuntime {
287
+ fn metadata(&self) -> DecisionRuntimeMetadata {
288
+ DecisionRuntimeMetadata {
289
+ runtime: "broken".into(),
290
+ model: Some("broken".into()),
291
+ model_revision: Some("broken-v1".into()),
292
+ prompt_revision: Some("broken-prompt".into()),
293
+ execution_method: None,
294
+ decision_schema_revision: None,
295
+ }
296
+ }
297
+
298
+ fn decide(&self, _request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
299
+ Ok(match self.0 {
300
+ "option" => DecisionResult::Choice {
301
+ selected: "maybe".into(),
302
+ options: vec![
303
+ WeightedOption {
304
+ value: "approve".into(),
305
+ probability: 0.5,
306
+ },
307
+ WeightedOption {
308
+ value: "reject".into(),
309
+ probability: 0.5,
310
+ },
311
+ ],
312
+ option_mass: 0.5,
313
+ supporting_fields: vec![],
314
+ },
315
+ _ => DecisionResult::Score {
316
+ selected: "high".into(),
317
+ levels: vec![
318
+ WeightedOption {
319
+ value: "low".into(),
320
+ probability: 0.4,
321
+ },
322
+ WeightedOption {
323
+ value: "medium".into(),
324
+ probability: 0.4,
325
+ },
326
+ WeightedOption {
327
+ value: "high".into(),
328
+ probability: 0.4,
329
+ },
330
+ ],
331
+ option_mass: 0.4,
332
+ supporting_fields: vec![],
333
+ },
334
+ })
335
+ }
336
+ }
337
+
338
+ let invalid_option = decide_record(
339
+ &db,
340
+ &target,
341
+ &DecisionDefinition::Choice {
342
+ question: "Which action should be taken?".into(),
343
+ options: vec!["approve".into(), "reject".into(), "review".into()],
344
+ },
345
+ &InvalidRuntime("option"),
346
+ &DecisionOptions::default(),
347
+ None,
348
+ )
349
+ .expect_err("invalid option");
350
+ assert_eq!(invalid_option.code, "DECISION_INVALID_OPTION");
351
+
352
+ let invalid_distribution = decide_record(
353
+ &db,
354
+ &target,
355
+ &DecisionDefinition::Score {
356
+ question: "What is the urgency?".into(),
357
+ levels: vec!["low".into(), "medium".into(), "high".into()],
358
+ },
359
+ &InvalidRuntime("distribution"),
360
+ &DecisionOptions::default(),
361
+ None,
362
+ )
363
+ .expect_err("invalid distribution");
364
+ assert_eq!(invalid_distribution.code, "DECISION_INVALID_DISTRIBUTION");
365
+ }
366
+
367
+ #[test]
368
+ fn authorization_filters_context_before_runtime_and_evidence() {
369
+ let db = memory_db("authorization");
370
+ let secret = "SENTINEL-remote-every-day";
371
+ insert_person(
372
+ &db,
373
+ "p-1",
374
+ json!({
375
+ "id":"p-1",
376
+ "__version":1,
377
+ "publicRole":"Staff Backend Engineer",
378
+ "employmentType":"office",
379
+ "privateNotes": secret
380
+ }),
381
+ );
382
+ let runtime = RecordingRuntime::new("deterministic-v1", "prompt-v1");
383
+ let gate = StaticGate::new(&["publicRole", "employmentType"]);
384
+ let result = jev(
385
+ &db,
386
+ &DecisionRecordRef {
387
+ collection: "people".into(),
388
+ record_id: "p-1".into(),
389
+ },
390
+ "could work from home",
391
+ &runtime,
392
+ &DecisionOptions::default(),
393
+ Some((&gate, &authorization_config())),
394
+ )
395
+ .expect("jev");
396
+
397
+ let calls = runtime.calls();
398
+ assert_eq!(calls.len(), 1);
399
+ let payload = serde_json::to_string(&calls).expect("serialize calls");
400
+ assert!(!payload.contains(secret));
401
+ assert!(!payload.contains("privateNotes"));
402
+ let request_uris: Vec<String> = gate
403
+ .requests
404
+ .lock()
405
+ .expect("requests")
406
+ .iter()
407
+ .map(|request| request.resource.uri.clone())
408
+ .collect();
409
+ assert_eq!(
410
+ request_uris,
411
+ vec![
412
+ "flow://people-directory/people/p-1/employmentType".to_string(),
413
+ "flow://people-directory/people/p-1/privateNotes".to_string(),
414
+ "flow://people-directory/people/p-1/publicRole".to_string(),
415
+ ]
416
+ );
417
+ assert_eq!(
418
+ result.evidence[0].state.withheld_fields,
419
+ vec!["privateNotes".to_string()]
420
+ );
421
+ }
422
+
423
+ #[test]
424
+ fn cache_reuse_invalidation_and_supersession_follow_authorized_state() {
425
+ let db = memory_db("cache");
426
+ insert_person(
427
+ &db,
428
+ "p-1",
429
+ json!({
430
+ "id":"p-1",
431
+ "__version":1,
432
+ "publicRole":"Staff Backend Engineer",
433
+ "employmentType":"office",
434
+ "lastSeenAt":"2026-01-01"
435
+ }),
436
+ );
437
+ let runtime = RecordingRuntime::new("deterministic-v1", "prompt-v1");
438
+ let target = DecisionRecordRef {
439
+ collection: "people".into(),
440
+ record_id: "p-1".into(),
441
+ };
442
+ let options = DecisionOptions {
443
+ fields: Some(vec!["publicRole".into(), "employmentType".into()]),
444
+ ..DecisionOptions::default()
445
+ };
446
+
447
+ let first = jev(
448
+ &db,
449
+ &target,
450
+ "could work from home",
451
+ &runtime,
452
+ &options,
453
+ None,
454
+ )
455
+ .expect("first");
456
+ let second = jev(
457
+ &db,
458
+ &target,
459
+ "could work from home",
460
+ &runtime,
461
+ &options,
462
+ None,
463
+ )
464
+ .expect("second");
465
+ assert!(!first.cached);
466
+ assert!(second.cached);
467
+ assert_eq!(runtime.invocations(), 1);
468
+
469
+ update_person(
470
+ &db,
471
+ "p-1",
472
+ json!({
473
+ "id":"p-1",
474
+ "__version":2,
475
+ "publicRole":"Staff Backend Engineer",
476
+ "employmentType":"office",
477
+ "lastSeenAt":"2026-09-18"
478
+ }),
479
+ );
480
+ let after_irrelevant = jev(
481
+ &db,
482
+ &target,
483
+ "could work from home",
484
+ &runtime,
485
+ &options,
486
+ None,
487
+ )
488
+ .expect("irrelevant");
489
+ assert!(after_irrelevant.cached);
490
+ assert_eq!(runtime.invocations(), 1);
491
+
492
+ update_person(
493
+ &db,
494
+ "p-1",
495
+ json!({
496
+ "id":"p-1",
497
+ "__version":3,
498
+ "publicRole":"Staff Backend Engineer",
499
+ "employmentType":"remote",
500
+ "lastSeenAt":"2026-09-18"
501
+ }),
502
+ );
503
+ let after_relevant = jev(
504
+ &db,
505
+ &target,
506
+ "could work from home",
507
+ &runtime,
508
+ &options,
509
+ None,
510
+ )
511
+ .expect("relevant");
512
+ assert!(!after_relevant.cached);
513
+ assert_eq!(runtime.invocations(), 2);
514
+ let judgments = judgment_rows(&db);
515
+ assert_eq!(judgments.len(), 2);
516
+ assert!(judgments
517
+ .iter()
518
+ .any(|row| row.status == DecisionStatus::Superseded));
519
+ assert!(judgments
520
+ .iter()
521
+ .any(|row| row.status == DecisionStatus::Active));
522
+ }
523
+
524
+ #[test]
525
+ fn decision_schema_and_runtime_revisions_invalidate_cache_identity() {
526
+ let db = memory_db("identity");
527
+ insert_person(
528
+ &db,
529
+ "p-1",
530
+ json!({"id":"p-1","__version":1,"actionHint":"approve","urgency":"medium"}),
531
+ );
532
+ let target = DecisionRecordRef {
533
+ collection: "people".into(),
534
+ record_id: "p-1".into(),
535
+ };
536
+ let runtime_a = RecordingRuntime::new("deterministic-v1", "prompt-v1");
537
+ let runtime_b = RecordingRuntime::new("deterministic-v2", "prompt-v1");
538
+
539
+ let first = decide_record(
540
+ &db,
541
+ &target,
542
+ &DecisionDefinition::Choice {
543
+ question: "Which action should be taken?".into(),
544
+ options: vec!["approve".into(), "reject".into()],
545
+ },
546
+ &runtime_a,
547
+ &DecisionOptions::default(),
548
+ None,
549
+ )
550
+ .expect("first");
551
+ let changed_options = decide_record(
552
+ &db,
553
+ &target,
554
+ &DecisionDefinition::Choice {
555
+ question: "Which action should be taken?".into(),
556
+ options: vec!["approve".into(), "reject".into(), "review".into()],
557
+ },
558
+ &runtime_a,
559
+ &DecisionOptions::default(),
560
+ None,
561
+ )
562
+ .expect("changed options");
563
+ let changed_runtime = decide_record(
564
+ &db,
565
+ &target,
566
+ &DecisionDefinition::Choice {
567
+ question: "Which action should be taken?".into(),
568
+ options: vec!["approve".into(), "reject".into()],
569
+ },
570
+ &runtime_b,
571
+ &DecisionOptions::default(),
572
+ None,
573
+ )
574
+ .expect("changed runtime");
575
+
576
+ assert_ne!(
577
+ first.provenance.cache_key,
578
+ changed_options.provenance.cache_key
579
+ );
580
+ assert_ne!(
581
+ first.provenance.cache_key,
582
+ changed_runtime.provenance.cache_key
583
+ );
584
+ }
585
+
586
+ #[test]
587
+ fn deterministic_runtime_and_file_backed_reopen_reuse_durable_judgments() {
588
+ let tempdir = TempDir::new().expect("tempdir");
589
+ let path = tempdir.path().join("decision.log");
590
+
591
+ let runtime = {
592
+ let request = DecisionRequest::Binary {
593
+ predicate: "could work from home".into(),
594
+ context: json!({"employmentType":"remote"}),
595
+ };
596
+ let mut responses = BTreeMap::new();
597
+ responses.insert(
598
+ deterministic_request_key(&request).expect("request key"),
599
+ DecisionResult::Binary {
600
+ decision: true,
601
+ option_mass: 0.9,
602
+ supporting_fields: vec!["employmentType".into()],
603
+ },
604
+ );
605
+ DeterministicDecisionRuntime::new(
606
+ DecisionRuntimeMetadata {
607
+ runtime: "deterministic".into(),
608
+ model: Some("deterministic-runtime".into()),
609
+ model_revision: Some("deterministic-v1".into()),
610
+ prompt_revision: Some("prompt-v1".into()),
611
+ execution_method: None,
612
+ decision_schema_revision: None,
613
+ },
614
+ responses,
615
+ )
616
+ };
617
+
618
+ {
619
+ let db = FeltDb::open(&path).expect("open writer");
620
+ insert_person(
621
+ &db,
622
+ "p-1",
623
+ json!({"id":"p-1","__version":1,"employmentType":"remote"}),
624
+ );
625
+ let first = jev(
626
+ &db,
627
+ &DecisionRecordRef {
628
+ collection: "people".into(),
629
+ record_id: "p-1".into(),
630
+ },
631
+ "could work from home",
632
+ &runtime,
633
+ &DecisionOptions {
634
+ fields: Some(vec!["employmentType".into()]),
635
+ ..DecisionOptions::default()
636
+ },
637
+ None,
638
+ )
639
+ .expect("first");
640
+ assert!(!first.cached);
641
+ }
642
+
643
+ let db = FeltDb::open(&path).expect("open reader");
644
+ let second = jev(
645
+ &db,
646
+ &DecisionRecordRef {
647
+ collection: "people".into(),
648
+ record_id: "p-1".into(),
649
+ },
650
+ "could work from home",
651
+ &runtime,
652
+ &DecisionOptions {
653
+ fields: Some(vec!["employmentType".into()]),
654
+ ..DecisionOptions::default()
655
+ },
656
+ None,
657
+ )
658
+ .expect("second");
659
+ assert!(second.cached);
660
+ }
661
+
662
+ #[test]
663
+ fn benchmark_reports_twenty_cases_for_each_primitive_and_reuses_cache() {
664
+ let db = memory_db("benchmark");
665
+ struct BenchmarkRuntime(Arc<AtomicUsize>);
666
+ impl DecisionRuntime for BenchmarkRuntime {
667
+ fn metadata(&self) -> DecisionRuntimeMetadata {
668
+ DecisionRuntimeMetadata {
669
+ runtime: "deterministic".into(),
670
+ model: Some("benchmark-runtime".into()),
671
+ model_revision: Some("benchmark-v1".into()),
672
+ prompt_revision: Some("prompt-v1".into()),
673
+ execution_method: None,
674
+ decision_schema_revision: None,
675
+ }
676
+ }
677
+
678
+ fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
679
+ self.0.fetch_add(1, Ordering::SeqCst);
680
+ let context = request.context().as_object().cloned().unwrap_or_default();
681
+ Ok(match request {
682
+ DecisionRequest::Binary { .. } => DecisionResult::Binary {
683
+ decision: context
684
+ .get("employmentType")
685
+ .and_then(Value::as_str)
686
+ .is_some_and(|value| value == "remote"),
687
+ option_mass: 0.75,
688
+ supporting_fields: vec!["employmentType".into()],
689
+ },
690
+ DecisionRequest::Choice { options, .. } => DecisionResult::Choice {
691
+ selected: if context
692
+ .get("publicRole")
693
+ .and_then(Value::as_str)
694
+ .is_some_and(|value| value.contains("Engineer"))
695
+ {
696
+ "approve".into()
697
+ } else {
698
+ "review".into()
699
+ },
700
+ options: options
701
+ .iter()
702
+ .map(|value| WeightedOption {
703
+ value: value.clone(),
704
+ probability: if value == "reject" {
705
+ 0.05
706
+ } else if value == "review" {
707
+ 0.15
708
+ } else {
709
+ 0.8
710
+ },
711
+ })
712
+ .collect(),
713
+ option_mass: 0.8,
714
+ supporting_fields: vec!["publicRole".into()],
715
+ },
716
+ DecisionRequest::Score { levels, .. } => {
717
+ let selected = context
718
+ .get("urgency")
719
+ .and_then(Value::as_str)
720
+ .unwrap_or("low")
721
+ .to_string();
722
+ DecisionResult::Score {
723
+ selected: selected.clone(),
724
+ levels: levels
725
+ .iter()
726
+ .map(|value| WeightedOption {
727
+ value: value.clone(),
728
+ probability: if value == &selected { 0.8 } else { 0.1 },
729
+ })
730
+ .collect(),
731
+ option_mass: 0.8,
732
+ supporting_fields: vec!["urgency".into()],
733
+ }
734
+ }
735
+ })
736
+ }
737
+ }
738
+
739
+ let counter = Arc::new(AtomicUsize::new(0));
740
+ let report = run_decision_benchmark(
741
+ &db,
742
+ &BenchmarkRuntime(counter.clone()),
743
+ &DecisionOptions::default(),
744
+ )
745
+ .expect("benchmark");
746
+ assert_eq!(report.binary, 20);
747
+ assert_eq!(report.choice, 20);
748
+ assert_eq!(report.score, 20);
749
+ assert_eq!(report.runtime_invocations_first_pass, 60);
750
+ assert_eq!(report.runtime_invocations_second_pass, 0);
751
+ assert_eq!(report.cache_hits_second_pass, 60);
752
+ assert_eq!(counter.load(Ordering::SeqCst), 60);
753
+ }
754
+
755
+ #[test]
756
+ fn transport_execution_matches_direct_rust_semantics() {
757
+ fn seeded(name: &str) -> FeltDb {
758
+ let db = memory_db(name);
759
+ insert_person(
760
+ &db,
761
+ "p-1",
762
+ json!({
763
+ "id":"p-1",
764
+ "__version":1,
765
+ "employmentType":"remote",
766
+ "actionHint":"review",
767
+ "urgency":"high"
768
+ }),
769
+ );
770
+ db
771
+ }
772
+
773
+ let metadata = DecisionRuntimeMetadata {
774
+ runtime: "deterministic".into(),
775
+ model: Some("deterministic-runtime".into()),
776
+ model_revision: Some("deterministic-v1".into()),
777
+ prompt_revision: Some("prompt-v1".into()),
778
+ execution_method: None,
779
+ decision_schema_revision: None,
780
+ };
781
+ let context = json!({
782
+ "actionHint":"review",
783
+ "employmentType":"remote",
784
+ "urgency":"high"
785
+ });
786
+ let binary_request = DecisionRequest::Binary {
787
+ predicate: "could work from home".into(),
788
+ context: context.clone(),
789
+ };
790
+ let choice_request = DecisionRequest::Choice {
791
+ question: "Which action should be taken?".into(),
792
+ options: vec!["approve".into(), "reject".into(), "review".into()],
793
+ context: context.clone(),
794
+ };
795
+ let score_request = DecisionRequest::Score {
796
+ question: "What is the urgency?".into(),
797
+ levels: vec!["low".into(), "medium".into(), "high".into()],
798
+ context,
799
+ };
800
+ let mut responses = BTreeMap::new();
801
+ responses.insert(
802
+ deterministic_request_key(&binary_request).expect("binary key"),
803
+ DecisionResult::Binary {
804
+ decision: true,
805
+ option_mass: 0.9,
806
+ supporting_fields: vec!["employmentType".into()],
807
+ },
808
+ );
809
+ responses.insert(
810
+ deterministic_request_key(&choice_request).expect("choice key"),
811
+ DecisionResult::Choice {
812
+ selected: "review".into(),
813
+ options: vec![
814
+ WeightedOption {
815
+ value: "approve".into(),
816
+ probability: 0.05,
817
+ },
818
+ WeightedOption {
819
+ value: "reject".into(),
820
+ probability: 0.05,
821
+ },
822
+ WeightedOption {
823
+ value: "review".into(),
824
+ probability: 0.9,
825
+ },
826
+ ],
827
+ option_mass: 0.9,
828
+ supporting_fields: vec!["actionHint".into()],
829
+ },
830
+ );
831
+ responses.insert(
832
+ deterministic_request_key(&score_request).expect("score key"),
833
+ DecisionResult::Score {
834
+ selected: "high".into(),
835
+ levels: vec![
836
+ WeightedOption {
837
+ value: "low".into(),
838
+ probability: 0.05,
839
+ },
840
+ WeightedOption {
841
+ value: "medium".into(),
842
+ probability: 0.05,
843
+ },
844
+ WeightedOption {
845
+ value: "high".into(),
846
+ probability: 0.9,
847
+ },
848
+ ],
849
+ option_mass: 0.9,
850
+ supporting_fields: vec!["urgency".into()],
851
+ },
852
+ );
853
+
854
+ let target = DecisionRecordRef {
855
+ collection: "people".into(),
856
+ record_id: "p-1".into(),
857
+ };
858
+ let definitions = vec![
859
+ DecisionDefinition::Binary {
860
+ predicate: "could work from home".into(),
861
+ },
862
+ DecisionDefinition::Choice {
863
+ question: "Which action should be taken?".into(),
864
+ options: vec!["approve".into(), "reject".into(), "review".into()],
865
+ },
866
+ DecisionDefinition::Score {
867
+ question: "What is the urgency?".into(),
868
+ levels: vec!["low".into(), "medium".into(), "high".into()],
869
+ },
870
+ ];
871
+
872
+ for definition in definitions {
873
+ let direct_db = seeded("direct-transport");
874
+ let transport_db = seeded("transport");
875
+ let direct = decide_record(
876
+ &direct_db,
877
+ &target,
878
+ &definition,
879
+ &DeterministicDecisionRuntime::new(metadata.clone(), responses.clone()),
880
+ &DecisionOptions::default(),
881
+ None,
882
+ )
883
+ .expect("direct");
884
+ let via_transport = execute_decision_transport(
885
+ &transport_db,
886
+ &DecisionTransportExecutionRequest {
887
+ target: Some(target.clone()),
888
+ context: None,
889
+ definition: definition.clone(),
890
+ options: DecisionOptions::default(),
891
+ authorization: None,
892
+ runtime: DecisionTransportRuntime::Deterministic {
893
+ metadata: metadata.clone(),
894
+ responses: responses.clone(),
895
+ },
896
+ },
897
+ )
898
+ .expect("transport");
899
+ assert_eq!(
900
+ via_transport.runtime_requests,
901
+ Vec::<DecisionRequest>::new()
902
+ );
903
+ assert_eq!(via_transport.evaluation.result, direct.result);
904
+ assert_eq!(via_transport.evaluation.evidence, direct.evidence);
905
+ assert_eq!(
906
+ via_transport.evaluation.provenance.context_hash,
907
+ direct.provenance.context_hash
908
+ );
909
+ assert_eq!(
910
+ via_transport.evaluation.provenance.relevant_state_version,
911
+ direct.provenance.relevant_state_version
912
+ );
913
+ assert_eq!(
914
+ via_transport.evaluation.provenance.cache_key,
915
+ direct.provenance.cache_key
916
+ );
917
+ }
918
+ }
919
+
920
+ #[test]
921
+ fn transport_rejects_ambiguous_target_and_inline_context() {
922
+ let db = memory_db("transport-ambiguous");
923
+ insert_person(
924
+ &db,
925
+ "p-1",
926
+ json!({"id":"p-1","__version":1,"employmentType":"remote"}),
927
+ );
928
+ let error = execute_decision_transport(
929
+ &db,
930
+ &DecisionTransportExecutionRequest {
931
+ target: Some(DecisionRecordRef {
932
+ collection: "people".into(),
933
+ record_id: "p-1".into(),
934
+ }),
935
+ context: Some(json!({"employmentType":"remote"})),
936
+ definition: DecisionDefinition::Binary {
937
+ predicate: "could work from home".into(),
938
+ },
939
+ options: DecisionOptions::default(),
940
+ authorization: None,
941
+ runtime: DecisionTransportRuntime::Recording {
942
+ metadata: DecisionRuntimeMetadata {
943
+ runtime: "recording".into(),
944
+ model: Some("recording-runtime".into()),
945
+ model_revision: Some("recording-v1".into()),
946
+ prompt_revision: Some("prompt-v1".into()),
947
+ execution_method: None,
948
+ decision_schema_revision: None,
949
+ },
950
+ result: DecisionResult::Binary {
951
+ decision: true,
952
+ option_mass: 0.9,
953
+ supporting_fields: vec!["employmentType".into()],
954
+ },
955
+ },
956
+ },
957
+ )
958
+ .expect_err("ambiguous request should fail");
959
+ assert_eq!(error.code, "DECISION_INVALID_RESULT");
960
+ }
961
+
962
+ #[test]
963
+ fn transport_authorization_filters_runtime_input_before_execution() {
964
+ let db = memory_db("transport-auth");
965
+ let secret = "SENTINEL-secret-field";
966
+ insert_person(
967
+ &db,
968
+ "p-1",
969
+ json!({
970
+ "id":"p-1",
971
+ "__version":1,
972
+ "public_field":"visible",
973
+ "secret_field": secret
974
+ }),
975
+ );
976
+ let response = execute_decision_transport(
977
+ &db,
978
+ &DecisionTransportExecutionRequest {
979
+ target: Some(DecisionRecordRef {
980
+ collection: "people".into(),
981
+ record_id: "p-1".into(),
982
+ }),
983
+ context: None,
984
+ definition: DecisionDefinition::Binary {
985
+ predicate: "is visible".into(),
986
+ },
987
+ options: DecisionOptions::default(),
988
+ authorization: Some(DecisionTransportAuthorization {
989
+ allowed_fields: vec!["public_field".into()],
990
+ principal: Some(feltdb::DecisionPrincipal {
991
+ subject: Subject::Service("reporting-service".into()),
992
+ tenant_id: "tenant-a".into(),
993
+ application_id: "people-directory".into(),
994
+ capability: "state:read".into(),
995
+ }),
996
+ }),
997
+ runtime: DecisionTransportRuntime::Recording {
998
+ metadata: DecisionRuntimeMetadata {
999
+ runtime: "recording".into(),
1000
+ model: Some("recording-runtime".into()),
1001
+ model_revision: Some("recording-v1".into()),
1002
+ prompt_revision: Some("prompt-v1".into()),
1003
+ execution_method: None,
1004
+ decision_schema_revision: None,
1005
+ },
1006
+ result: DecisionResult::Binary {
1007
+ decision: true,
1008
+ option_mass: 0.8,
1009
+ supporting_fields: vec!["public_field".into()],
1010
+ },
1011
+ },
1012
+ },
1013
+ )
1014
+ .expect("transport authorization");
1015
+ assert_eq!(response.runtime_requests.len(), 1);
1016
+ let payload = serde_json::to_string(&response.runtime_requests).expect("serialize");
1017
+ assert!(!payload.contains(secret));
1018
+ assert!(!payload.contains("secret_field"));
1019
+ assert!(payload.contains("public_field"));
1020
+ assert_eq!(
1021
+ response.evaluation.evidence[0].state.withheld_fields,
1022
+ vec!["secret_field".to_string()]
1023
+ );
1024
+ }
1025
+
1026
+ #[test]
1027
+ fn decision_batch_shares_authorized_context_without_cross_decision_leakage() {
1028
+ let db = memory_db("decision-batch");
1029
+ insert_person(
1030
+ &db,
1031
+ "p-1",
1032
+ json!({
1033
+ "id":"p-1",
1034
+ "__version":1,
1035
+ "employmentType":"remote",
1036
+ "public_field":"visible"
1037
+ }),
1038
+ );
1039
+ let runtime = RecordingRuntime::new("deterministic-v1", "prompt-v1");
1040
+ let context = authorize_context(
1041
+ "people",
1042
+ "p-1",
1043
+ &json!({
1044
+ "id":"p-1",
1045
+ "__version":1,
1046
+ "employmentType":"remote",
1047
+ "public_field":"visible"
1048
+ }),
1049
+ &DecisionOptions::default(),
1050
+ None,
1051
+ )
1052
+ .expect("context");
1053
+ let evaluations = execute_decision_batch(
1054
+ &context,
1055
+ &DecisionBatch {
1056
+ context: json!({ "shared_fact":"shared" }),
1057
+ decisions: vec![
1058
+ DecisionBatchDecision {
1059
+ id: "decision-a".into(),
1060
+ definition: DecisionDefinition::Binary {
1061
+ predicate: "could work from home".into(),
1062
+ },
1063
+ context: json!({ "decision_only":"a" }),
1064
+ },
1065
+ DecisionBatchDecision {
1066
+ id: "decision-b".into(),
1067
+ definition: DecisionDefinition::Binary {
1068
+ predicate: "could work from home".into(),
1069
+ },
1070
+ context: json!({}),
1071
+ },
1072
+ ],
1073
+ },
1074
+ &runtime,
1075
+ )
1076
+ .expect("batch");
1077
+ assert_eq!(evaluations.len(), 2);
1078
+ let calls = runtime.calls();
1079
+ assert_eq!(calls.len(), 2);
1080
+ let first = serde_json::to_value(&calls[0]).expect("first");
1081
+ let second = serde_json::to_value(&calls[1]).expect("second");
1082
+ assert_eq!(first["context"]["shared_fact"], "shared");
1083
+ assert_eq!(second["context"]["shared_fact"], "shared");
1084
+ assert_eq!(first["context"]["decision_only"], "a");
1085
+ assert!(second["context"].get("decision_only").is_none());
1086
+ }
1087
+
1088
+ #[test]
1089
+ fn choice_diagnostics_measure_permutations_and_option_expansion() {
1090
+ struct ChoiceRuntime;
1091
+ impl DecisionRuntime for ChoiceRuntime {
1092
+ fn metadata(&self) -> DecisionRuntimeMetadata {
1093
+ DecisionRuntimeMetadata {
1094
+ runtime: "diagnostic".into(),
1095
+ model: Some("diagnostic-runtime".into()),
1096
+ model_revision: Some("diagnostic-v1".into()),
1097
+ prompt_revision: Some("prompt-v1".into()),
1098
+ execution_method: None,
1099
+ decision_schema_revision: None,
1100
+ }
1101
+ }
1102
+
1103
+ fn decide(&self, request: &DecisionRequest) -> Result<DecisionResult, DecisionError> {
1104
+ match request {
1105
+ DecisionRequest::Choice { options, .. } => Ok(DecisionResult::Choice {
1106
+ selected: options.first().cloned().unwrap_or_default(),
1107
+ options: options
1108
+ .iter()
1109
+ .enumerate()
1110
+ .map(|(index, value)| WeightedOption {
1111
+ value: value.clone(),
1112
+ probability: if index == 0 {
1113
+ 0.6
1114
+ } else if index == 1 {
1115
+ 0.3
1116
+ } else {
1117
+ 0.1 / ((options.len() - 2).max(1) as f64)
1118
+ },
1119
+ })
1120
+ .collect(),
1121
+ option_mass: 0.6,
1122
+ supporting_fields: vec!["employmentType".into()],
1123
+ }),
1124
+ _ => Err(DecisionError::new(
1125
+ "DECISION_INVALID_RESULT",
1126
+ "expected choice request",
1127
+ )),
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ let context = authorize_context(
1133
+ "people",
1134
+ "p-1",
1135
+ &json!({
1136
+ "id":"p-1",
1137
+ "__version":1,
1138
+ "employmentType":"remote"
1139
+ }),
1140
+ &DecisionOptions::default(),
1141
+ None,
1142
+ )
1143
+ .expect("context");
1144
+ let report = run_choice_diagnostics(
1145
+ &context,
1146
+ "Which option should be selected?",
1147
+ &["A".into(), "B".into(), "C".into()],
1148
+ Some("D"),
1149
+ &ChoiceRuntime,
1150
+ )
1151
+ .expect("choice diagnostics");
1152
+ assert_eq!(report.permutations.len(), 6);
1153
+ assert!(report
1154
+ .permutations
1155
+ .iter()
1156
+ .any(|entry| entry.order == vec!["A", "B", "C"] && entry.selected == "A"));
1157
+ assert!(report
1158
+ .permutations
1159
+ .iter()
1160
+ .any(|entry| entry.order == vec!["C", "B", "A"] && entry.selected == "C"));
1161
+ let expansion = report.expansion.expect("expansion");
1162
+ assert_eq!(expansion.baseline.order, vec!["A", "B", "C"]);
1163
+ assert_eq!(expansion.expanded.order, vec!["A", "B", "C", "D"]);
1164
+ assert_eq!(expansion.expanded.rank, 1);
1165
+ }