1use crate::operation::{Operation, OperationType};
40use crate::state_trigger::{StateTrigger, StateTriggerChangeKind, StateTriggerPredicate};
41use serde::de::Deserializer;
42use serde::{Deserialize, Serialize};
43use serde_json::Value;
44use sha2::{Digest, Sha256};
45
46pub const TRIGGER_MATCH_CONTRACT_ID: &str = "feltdb.trigger.match";
49
50pub const TRIGGER_MATCH_CONTRACT_VERSION: u32 = 1;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum ContractChangeKind {
64 Insert,
65 Update,
66 Delete,
67}
68
69impl ContractChangeKind {
70 pub fn from_operation_type(value: &OperationType) -> Self {
72 match value {
73 OperationType::Insert => Self::Insert,
74 OperationType::Update => Self::Update,
75 OperationType::Delete => Self::Delete,
76 }
77 }
78
79 pub fn from_trigger_change_kind(value: StateTriggerChangeKind) -> Self {
81 match value {
82 StateTriggerChangeKind::Insert => Self::Insert,
83 StateTriggerChangeKind::Update => Self::Update,
84 StateTriggerChangeKind::Delete => Self::Delete,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct ContractChange {
103 pub change_kind: ContractChangeKind,
104 pub collection: String,
105 #[serde(
112 default,
113 deserialize_with = "deserialize_present",
114 skip_serializing_if = "Option::is_none"
115 )]
116 pub value: Option<Value>,
117}
118
119impl ContractChange {
120 pub fn from_operation(operation: &Operation) -> Self {
122 Self {
123 change_kind: ContractChangeKind::from_operation_type(&operation.op_type),
124 collection: operation.capability.clone(),
125 value: operation.value.clone(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct ContractTrigger {
140 pub change_kind: ContractChangeKind,
141 pub enabled: bool,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub predicate: Option<ContractPredicate>,
144 pub source_collection: String,
145}
146
147impl ContractTrigger {
148 pub fn from_state_trigger(trigger: &StateTrigger) -> Self {
150 Self {
151 change_kind: ContractChangeKind::from_trigger_change_kind(trigger.change_kind),
152 enabled: trigger.enabled,
153 predicate: trigger.predicate.as_ref().map(ContractPredicate::from),
154 source_collection: trigger.source_collection.clone(),
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct ContractPredicate {
167 pub equals: Value,
168 pub field: String,
169}
170
171impl From<&StateTriggerPredicate> for ContractPredicate {
172 fn from(value: &StateTriggerPredicate) -> Self {
173 Self {
174 equals: value.equals.clone(),
175 field: value.field.clone(),
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct TriggerMatchInput {
184 pub change: ContractChange,
185 pub contract: String,
186 pub trigger: ContractTrigger,
187 pub version: u32,
188}
189
190impl TriggerMatchInput {
191 pub fn new(change: ContractChange, trigger: ContractTrigger) -> Self {
193 Self {
194 change,
195 contract: TRIGGER_MATCH_CONTRACT_ID.to_string(),
196 trigger,
197 version: TRIGGER_MATCH_CONTRACT_VERSION,
198 }
199 }
200
201 pub fn for_operation(trigger: &StateTrigger, operation: &Operation) -> Self {
203 Self::new(
204 ContractChange::from_operation(operation),
205 ContractTrigger::from_state_trigger(trigger),
206 )
207 }
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum MatchReason {
218 Matched,
220 TriggerDisabled,
222 CollectionMismatch,
224 ChangeKindMismatch,
226 ChangeValueAbsent,
228 PredicateFieldAbsent,
230 PredicateValueMismatch,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct MatchOutcome {
238 pub matched: bool,
239 pub reason: MatchReason,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
249pub enum ContractErrorCode {
250 ContractInputInvalid,
252 ContractIdMismatch,
254 ContractVersionUnsupported,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct ContractErrorBody {
262 pub code: ContractErrorCode,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct TriggerMatchOutput {
273 pub contract: String,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub error: Option<ContractErrorBody>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub outcome: Option<MatchOutcome>,
278 pub version: u32,
279}
280
281impl TriggerMatchOutput {
282 fn decision(matched: bool, reason: MatchReason) -> Self {
283 Self {
284 contract: TRIGGER_MATCH_CONTRACT_ID.to_string(),
285 error: None,
286 outcome: Some(MatchOutcome { matched, reason }),
287 version: TRIGGER_MATCH_CONTRACT_VERSION,
288 }
289 }
290
291 fn failure(code: ContractErrorCode) -> Self {
292 Self {
293 contract: TRIGGER_MATCH_CONTRACT_ID.to_string(),
294 error: Some(ContractErrorBody { code }),
295 outcome: None,
296 version: TRIGGER_MATCH_CONTRACT_VERSION,
297 }
298 }
299
300 pub fn matched(&self) -> bool {
302 self.outcome.is_some_and(|outcome| outcome.matched)
303 }
304}
305
306pub fn evaluate_trigger(input: &TriggerMatchInput) -> TriggerMatchOutput {
312 let change = &input.change;
313 let trigger = &input.trigger;
314
315 if !trigger.enabled {
316 return TriggerMatchOutput::decision(false, MatchReason::TriggerDisabled);
317 }
318 if change.collection != trigger.source_collection {
319 return TriggerMatchOutput::decision(false, MatchReason::CollectionMismatch);
320 }
321 if change.change_kind != trigger.change_kind {
322 return TriggerMatchOutput::decision(false, MatchReason::ChangeKindMismatch);
323 }
324 let Some(predicate) = trigger.predicate.as_ref() else {
325 return TriggerMatchOutput::decision(true, MatchReason::Matched);
326 };
327 let Some(value) = change.value.as_ref() else {
328 return TriggerMatchOutput::decision(false, MatchReason::ChangeValueAbsent);
329 };
330 let Some(resolved) = resolve_field(value, &predicate.field) else {
331 return TriggerMatchOutput::decision(false, MatchReason::PredicateFieldAbsent);
332 };
333 if resolved != &predicate.equals {
334 return TriggerMatchOutput::decision(false, MatchReason::PredicateValueMismatch);
335 }
336 TriggerMatchOutput::decision(true, MatchReason::Matched)
337}
338
339fn resolve_field<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
345 if path.is_empty() {
346 return Some(value);
347 }
348 let mut current = value;
349 for segment in path.split('.') {
350 current = current.as_object()?.get(segment)?;
351 }
352 Some(current)
353}
354
355pub fn canonical_input_bytes(input: &TriggerMatchInput) -> Vec<u8> {
364 serde_json::to_vec(input).expect("contract input is always serializable")
365}
366
367pub fn canonical_output_bytes(output: &TriggerMatchOutput) -> Vec<u8> {
369 serde_json::to_vec(output).expect("contract output is always serializable")
370}
371
372pub fn input_digest(input: &TriggerMatchInput) -> String {
374 format!("sha256:{:x}", Sha256::digest(canonical_input_bytes(input)))
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "snake_case")]
385pub enum ContractRuntime {
386 Native,
387 Wasm,
388}
389
390impl ContractRuntime {
391 pub fn as_str(self) -> &'static str {
392 match self {
393 Self::Native => "native",
394 Self::Wasm => "wasm",
395 }
396 }
397}
398
399pub fn evaluate_canonical(input: &[u8]) -> Vec<u8> {
405 canonical_output_bytes(&evaluate_canonical_output(input))
406}
407
408pub fn evaluate_canonical_output(input: &[u8]) -> TriggerMatchOutput {
410 let Ok(parsed) = serde_json::from_slice::<TriggerMatchInput>(input) else {
411 return TriggerMatchOutput::failure(ContractErrorCode::ContractInputInvalid);
412 };
413 if parsed.contract != TRIGGER_MATCH_CONTRACT_ID {
414 return TriggerMatchOutput::failure(ContractErrorCode::ContractIdMismatch);
415 }
416 if parsed.version != TRIGGER_MATCH_CONTRACT_VERSION {
417 return TriggerMatchOutput::failure(ContractErrorCode::ContractVersionUnsupported);
418 }
419 evaluate_trigger(&parsed)
420}
421
422fn deserialize_present<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
429where
430 D: Deserializer<'de>,
431{
432 Value::deserialize(deserializer).map(Some)
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::operation::Operation;
439 use serde_json::json;
440
441 fn change(kind: ContractChangeKind, collection: &str, value: Option<Value>) -> ContractChange {
442 ContractChange {
443 change_kind: kind,
444 collection: collection.into(),
445 value,
446 }
447 }
448
449 fn trigger(predicate: Option<ContractPredicate>) -> ContractTrigger {
450 ContractTrigger {
451 change_kind: ContractChangeKind::Insert,
452 enabled: true,
453 predicate,
454 source_collection: "orders".into(),
455 }
456 }
457
458 fn decide(change: ContractChange, trigger: ContractTrigger) -> (bool, MatchReason) {
459 let output = evaluate_trigger(&TriggerMatchInput::new(change, trigger));
460 let outcome = output.outcome.expect("a typed input always decides");
461 (outcome.matched, outcome.reason)
462 }
463
464 #[test]
465 fn checks_run_in_a_fixed_order() {
466 let mut disabled = trigger(Some(ContractPredicate {
469 equals: json!("paid"),
470 field: "status".into(),
471 }));
472 disabled.enabled = false;
473 assert_eq!(
474 decide(
475 change(ContractChangeKind::Delete, "customers", None),
476 disabled
477 ),
478 (false, MatchReason::TriggerDisabled)
479 );
480
481 assert_eq!(
482 decide(
483 change(ContractChangeKind::Delete, "customers", None),
484 trigger(None)
485 ),
486 (false, MatchReason::CollectionMismatch)
487 );
488
489 assert_eq!(
490 decide(
491 change(ContractChangeKind::Delete, "orders", None),
492 trigger(None)
493 ),
494 (false, MatchReason::ChangeKindMismatch)
495 );
496 }
497
498 #[test]
499 fn absent_and_null_record_values_are_different_inputs() {
500 let whole_record = || {
501 Some(ContractPredicate {
502 equals: Value::Null,
503 field: String::new(),
504 })
505 };
506 assert_eq!(
507 decide(
508 change(ContractChangeKind::Insert, "orders", Some(Value::Null)),
509 trigger(whole_record())
510 ),
511 (true, MatchReason::Matched)
512 );
513 assert_eq!(
514 decide(
515 change(ContractChangeKind::Insert, "orders", None),
516 trigger(whole_record())
517 ),
518 (false, MatchReason::ChangeValueAbsent)
519 );
520 }
521
522 #[test]
523 fn a_missing_field_is_not_a_null_field() {
524 let expects_null = || {
525 Some(ContractPredicate {
526 equals: Value::Null,
527 field: "cancelled_at".into(),
528 })
529 };
530 assert_eq!(
531 decide(
532 change(
533 ContractChangeKind::Insert,
534 "orders",
535 Some(json!({"cancelled_at": null}))
536 ),
537 trigger(expects_null())
538 ),
539 (true, MatchReason::Matched)
540 );
541 assert_eq!(
542 decide(
543 change(ContractChangeKind::Insert, "orders", Some(json!({}))),
544 trigger(expects_null())
545 ),
546 (false, MatchReason::PredicateFieldAbsent)
547 );
548 }
549
550 #[test]
551 fn field_paths_descend_through_objects_only() {
552 let value = json!({"customer": {"country": "NL"}, "tags": ["a"]});
553 assert_eq!(
554 resolve_field(&value, "customer.country"),
555 Some(&json!("NL"))
556 );
557 assert_eq!(resolve_field(&value, ""), Some(&value));
558 assert_eq!(resolve_field(&value, "tags.0"), None);
559 assert_eq!(resolve_field(&value, "customer.missing"), None);
560 }
561
562 #[test]
563 fn canonical_encoding_sorts_members() {
564 let bytes = canonical_input_bytes(&TriggerMatchInput::new(
565 change(
566 ContractChangeKind::Insert,
567 "orders",
568 Some(json!({"zeta": 1, "alpha": 2})),
569 ),
570 trigger(None),
571 ));
572 assert_eq!(
573 String::from_utf8(bytes).unwrap(),
574 r#"{"change":{"change_kind":"insert","collection":"orders","value":{"alpha":2,"zeta":1}},"contract":"feltdb.trigger.match","trigger":{"change_kind":"insert","enabled":true,"source_collection":"orders"},"version":1}"#
575 );
576 }
577
578 #[test]
579 fn canonical_encoding_omits_an_absent_value_and_keeps_a_null_one() {
580 let with_null = canonical_input_bytes(&TriggerMatchInput::new(
581 change(ContractChangeKind::Insert, "orders", Some(Value::Null)),
582 trigger(None),
583 ));
584 assert!(String::from_utf8_lossy(&with_null).contains(r#""value":null"#));
585
586 let without = canonical_input_bytes(&TriggerMatchInput::new(
587 change(ContractChangeKind::Delete, "orders", None),
588 trigger(None),
589 ));
590 assert!(!String::from_utf8_lossy(&without).contains("value"));
591 }
592
593 #[test]
594 fn canonical_round_trip_is_stable() {
595 let input = TriggerMatchInput::new(
596 change(
597 ContractChangeKind::Insert,
598 "orders",
599 Some(json!({"status": "paid", "total": 42})),
600 ),
601 trigger(Some(ContractPredicate {
602 equals: json!("paid"),
603 field: "status".into(),
604 })),
605 );
606 let once = canonical_input_bytes(&input);
607 let parsed: TriggerMatchInput = serde_json::from_slice(&once).unwrap();
608 assert_eq!(parsed, input);
609 assert_eq!(canonical_input_bytes(&parsed), once);
610 assert_eq!(input_digest(&parsed), input_digest(&input));
611 }
612
613 #[test]
614 fn unevaluatable_inputs_are_decisions_rather_than_failures() {
615 for (bytes, code) in [
616 (
617 &b"not json"[..],
618 ContractErrorCode::ContractInputInvalid,
619 ),
620 (
621 br#"{"change":{"change_kind":"insert","collection":"o"},"contract":"other","trigger":{"change_kind":"insert","enabled":true,"source_collection":"o"},"version":1}"#,
622 ContractErrorCode::ContractIdMismatch,
623 ),
624 (
625 br#"{"change":{"change_kind":"insert","collection":"o"},"contract":"feltdb.trigger.match","trigger":{"change_kind":"insert","enabled":true,"source_collection":"o"},"version":9}"#,
626 ContractErrorCode::ContractVersionUnsupported,
627 ),
628 ] {
629 let output = evaluate_canonical_output(bytes);
630 assert_eq!(output.error.map(|error| error.code), Some(code));
631 assert!(!output.matched(), "an unevaluatable input never matches");
632 }
633 }
634
635 #[test]
636 fn the_contract_reads_only_what_the_decision_depends_on() {
637 let one = Operation::insert(
642 1,
643 "node-a".into(),
644 1,
645 "orders:1".into(),
646 json!({"status": "paid"}),
647 "Order".into(),
648 "orders".into(),
649 );
650 let mut two = Operation::insert(
651 999,
652 "node-b".into(),
653 77,
654 "orders:2".into(),
655 json!({"status": "paid"}),
656 "OtherOrder".into(),
657 "orders".into(),
658 );
659 two.timestamp_ms = one.timestamp_ms + 1_000_000;
660 two.content_hash = "different".into();
661
662 assert_eq!(
663 ContractChange::from_operation(&one),
664 ContractChange::from_operation(&two)
665 );
666 }
667
668 #[test]
669 fn the_native_contract_decides_production_matching() {
670 use crate::authorization::{
673 Grant, GrantConstraints, GrantSigner, GrantStatus, ResourceScope, ResourceScopeKind,
674 Subject,
675 };
676 use crate::state_trigger::{StateTrigger, StateTriggerChangeKind, StateTriggerPredicate};
677 use std::collections::BTreeSet;
678
679 let subject = Subject::Agent("a".into());
680 let signed = GrantSigner::new("k", vec![7; 32])
681 .unwrap()
682 .sign(Grant {
683 contract_version: 1,
684 id: "g".into(),
685 issuer: Subject::Human("o".into()),
686 subject: subject.clone(),
687 tenant_id: "t".into(),
688 application_id: "app".into(),
689 capabilities: ["workload:execute".into()].into(),
690 resources: vec![ResourceScope {
691 kind: ResourceScopeKind::Workload,
692 uri: "flow://app/workloads/*".into(),
693 }],
694 constraints: GrantConstraints::default(),
695 issued_at: 1,
696 expires_at: 9999,
697 parent_grant: None,
698 revision: 1,
699 status: GrantStatus::Active,
700 })
701 .unwrap();
702 let state_trigger = StateTrigger {
703 trigger_id: "t1".into(),
704 source_collection: "orders".into(),
705 change_kind: StateTriggerChangeKind::Insert,
706 predicate: Some(StateTriggerPredicate {
707 field: "status".into(),
708 equals: json!("paid"),
709 }),
710 target_capability: "identity".into(),
711 tenant_id: "t".into(),
712 application_id: "app".into(),
713 environment: "production".into(),
714 definition_revision: "r".into(),
715 requested_by: subject,
716 authorization_snapshot: signed,
717 capability_snapshot: BTreeSet::new(),
718 connection_scopes: BTreeSet::new(),
719 priority: 0,
720 retry_policy: Default::default(),
721 timeouts: Default::default(),
722 not_before: None,
723 enabled: true,
724 };
725
726 for (value, expected) in [
727 (json!({"status": "paid"}), true),
728 (json!({"status": "draft"}), false),
729 ] {
730 let operation = Operation::insert(
731 1,
732 "node".into(),
733 1,
734 "orders:1".into(),
735 value,
736 "Order".into(),
737 "orders".into(),
738 );
739 assert_eq!(state_trigger.matches(&operation), expected);
740 assert_eq!(
741 evaluate_trigger(&state_trigger.contract_input(&operation)).matched(),
742 expected
743 );
744 let bytes = canonical_input_bytes(&state_trigger.contract_input(&operation));
746 assert_eq!(evaluate_canonical_output(&bytes).matched(), expected);
747 }
748 }
749}