Skip to main content

feltdb/
trigger_contract.rs

1//! The deterministic contract behind state-trigger matching.
2//!
3//! This module is the first explicit boundary in FeltDB between a *deterministic
4//! semantic contract* and the *runtime* that executes it. It exists so the
5//! question "did this committed change match this trigger?" can be answered
6//! identically by more than one implementation.
7//!
8//! ```text
9//!               canonical input bytes
10//!                        │
11//!         ┌──────────────┴──────────────┐
12//!         │                             │
13//!   native evaluation             WASM evaluation
14//!         │                             │
15//!         └──────────────┬──────────────┘
16//!                        │
17//!               canonical output bytes
18//! ```
19//!
20//! The contract is *not* a plugin system. It defines one decision with one
21//! shape, and FeltDB remains authoritative for everything around it: state,
22//! committed changes, workload creation, idempotency, authorization,
23//! durability, provenance and execution lifecycle. A contract implementation
24//! returns a decision; it never mutates FeltDB.
25//!
26//! # Determinism budget
27//!
28//! Evaluation reads nothing but its input. It does not consult a clock, the
29//! filesystem, the network, a random source, or any process-global mutable
30//! state, and its output contains no values derived from any of those.
31//!
32//! # Relationship to production
33//!
34//! [`crate::state_trigger::StateTrigger::matches`] delegates to
35//! [`evaluate_trigger`] on the typed native path. Canonical bytes are the
36//! *wire* form used to carry the same input into another runtime; production
37//! evaluation does not pay for encoding on every candidate pair.
38
39use 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
46/// Stable identity of this contract. It is part of the input and the output so
47/// that a decision can never be silently attributed to a different contract.
48pub const TRIGGER_MATCH_CONTRACT_ID: &str = "feltdb.trigger.match";
49
50/// Version of this contract's input shape, output shape and semantics.
51///
52/// Any change to the fields, the ordering of the match checks, or the reasons
53/// reported is a new version. Implementations that disagree about the version
54/// must not be compared for conformance.
55pub const TRIGGER_MATCH_CONTRACT_VERSION: u32 = 1;
56
57/// The kind of committed change, as the contract names it.
58///
59/// The contract keeps its own enum rather than reusing [`OperationType`] so the
60/// wire form does not move when an unrelated Rust type is renamed.
61#[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    /// The contract's projection of a durable operation type.
71    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    /// The contract's projection of a trigger's declared change kind.
80    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/// The part of a committed change the decision actually depends on.
90///
91/// The audit that produced this contract established that trigger matching
92/// reads exactly three things from an [`Operation`]: its capability
93/// (collection), its operation type, and its value. `op_id`, `instance_id`,
94/// `sequence`, `timestamp_ms`, `rust_type`, `content_hash` and `vector_clock`
95/// are deliberately absent — including them would put a clock reading and a
96/// node identity inside a contract that must be reproducible anywhere.
97///
98/// Fields are declared in alphabetical order because the canonical encoding is
99/// declaration order for structs and sorted order for JSON objects.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct ContractChange {
103    pub change_kind: ContractChangeKind,
104    pub collection: String,
105    /// The committed record value, when the change carried one.
106    ///
107    /// Absence and `null` are different: a delete carries no value at all,
108    /// while an insert of a JSON `null` carries one. The canonical encoding
109    /// distinguishes them by whether the `value` key is present, so a
110    /// whole-record predicate (`field: ""`) against `null` is decidable.
111    #[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    /// Projects a committed operation onto the contract's input.
121    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/// The part of a trigger definition the decision actually depends on.
131///
132/// A [`StateTrigger`] also carries tenancy, authorization snapshots, retry
133/// policy and scheduling. None of it participates in matching, and none of it
134/// belongs in a contract that a foreign runtime evaluates: an authorization
135/// snapshot handed to a replaceable implementation would be a second source of
136/// truth for authorization. FeltDB keeps all of it.
137#[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    /// Projects a durable trigger definition onto the contract's input.
149    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/// The deterministic equality predicate the state-trigger evaluator already had.
160///
161/// `field` is a dot-separated path read through JSON objects only; `equals` is
162/// compared with JSON value equality, which distinguishes integers from floats
163/// exactly as `serde_json` does.
164#[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/// The complete deterministic input to one trigger-match decision.
181#[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    /// Builds an input stamped with this contract's identity and version.
192    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    /// The input for deciding whether `trigger` matches `operation`.
202    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/// Why the decision came out the way it did.
211///
212/// The reason is part of the contract, not a diagnostic afterthought: it fixes
213/// the order in which the checks are applied, so two implementations cannot
214/// agree on `matched` by accident while disagreeing about the semantics.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum MatchReason {
218    /// Every check passed.
219    Matched,
220    /// The trigger is disabled.
221    TriggerDisabled,
222    /// The change's collection is not the trigger's source collection.
223    CollectionMismatch,
224    /// The change kind is not the trigger's change kind.
225    ChangeKindMismatch,
226    /// A predicate is declared but the change carried no value.
227    ChangeValueAbsent,
228    /// The predicate's field path does not resolve in the change value.
229    PredicateFieldAbsent,
230    /// The predicate's field resolved to a value that is not `equals`.
231    PredicateValueMismatch,
232}
233
234/// The decision.
235#[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/// Why an input could not be evaluated.
243///
244/// Error identity is a stable code and nothing else. A free-form message would
245/// be a divergence surface between implementations that says nothing about the
246/// semantics under test.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
249pub enum ContractErrorCode {
250    /// The bytes are not a well-formed instance of this contract's input.
251    ContractInputInvalid,
252    /// The input declares a different contract.
253    ContractIdMismatch,
254    /// The input declares a version this implementation does not implement.
255    ContractVersionUnsupported,
256}
257
258/// The error body of a contract output.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct ContractErrorBody {
262    pub code: ContractErrorCode,
263}
264
265/// The complete deterministic output of one trigger-match decision.
266///
267/// Exactly one of `error` and `outcome` is present. Both carry the evaluating
268/// implementation's own contract identity and version, never the input's, so a
269/// mismatch cannot be laundered back into agreement.
270#[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    /// Whether the trigger matched. An unevaluatable input never matches.
301    pub fn matched(&self) -> bool {
302        self.outcome.is_some_and(|outcome| outcome.matched)
303    }
304}
305
306/// The deterministic contract, natively implemented.
307///
308/// This is the whole semantic authority for state-trigger matching. The checks
309/// run in a fixed order and the first failing one names the reason, which is
310/// the same short-circuit order the state-trigger evaluator has always used.
311pub 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
339/// Reads a dot-separated field path, descending through JSON objects only.
340///
341/// An empty path is the whole value. Array elements are not addressable, which
342/// is the behaviour the state-trigger evaluator shipped with: `Value::get` with
343/// a string key returns nothing for an array.
344fn 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
355/// The canonical byte encoding of a contract input.
356///
357/// FeltDB already canonicalizes by serializing sorted structures with
358/// `serde_json` (see [`crate::application::canonical_bytes`] and
359/// [`crate::state_contract::canonical_schema_bytes`]). This contract uses the
360/// same convention: `serde_json::Map` is a `BTreeMap`, so every JSON object is
361/// emitted in sorted key order, and contract structs declare their fields in
362/// alphabetical order so struct encoding matches object encoding.
363pub fn canonical_input_bytes(input: &TriggerMatchInput) -> Vec<u8> {
364    serde_json::to_vec(input).expect("contract input is always serializable")
365}
366
367/// The canonical byte encoding of a contract output.
368pub fn canonical_output_bytes(output: &TriggerMatchOutput) -> Vec<u8> {
369    serde_json::to_vec(output).expect("contract output is always serializable")
370}
371
372/// Content identity of a contract input, for provenance.
373pub fn input_digest(input: &TriggerMatchInput) -> String {
374    format!("sha256:{:x}", Sha256::digest(canonical_input_bytes(input)))
375}
376
377/// The runtime that executed a contract, recorded in evidence.
378///
379/// Only [`ContractRuntime::Native`] is wired into production. The variant for
380/// WASM exists so that recorded provenance is honest about which
381/// implementation produced a decision if that ever changes; it is not a
382/// selection switch, and nothing in this crate constructs it.
383#[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
399/// The runtime-independent entry point: canonical bytes in, canonical bytes out.
400///
401/// This is the exact shape a foreign runtime implements. It never panics and
402/// never returns anything but a canonical output encoding, so a malformed input
403/// is a contract *decision* rather than a host-specific failure mode.
404pub fn evaluate_canonical(input: &[u8]) -> Vec<u8> {
405    canonical_output_bytes(&evaluate_canonical_output(input))
406}
407
408/// [`evaluate_canonical`] before encoding, for callers that want the value.
409pub 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
422/// Deserializes a field so that a present `null` is `Some(Value::Null)`.
423///
424/// `Option<Value>` would collapse `"value": null` to `None`, erasing the
425/// difference between "the change carried no record" and "the change carried
426/// JSON null". Key presence is the distinction, and it is one a hand-written
427/// parser in another runtime can reproduce without knowing anything about serde.
428fn 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        // Every check fails at once. The reported reason names the first, which
467        // is what makes the reason comparable between implementations.
468        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        // Two operations that differ in every field the contract does not
638        // project must produce identical canonical input. If a clock reading,
639        // a node identity or a content hash ever leaked into the contract, the
640        // decision would stop being reproducible anywhere and this would fail.
641        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        // The production entry point and the contract are the same decision,
671        // not two implementations that happen to agree today.
672        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            // And through the wire form, which is what a foreign runtime sees.
745            let bytes = canonical_input_bytes(&state_trigger.contract_input(&operation));
746            assert_eq!(evaluate_canonical_output(&bytes).matched(), expected);
747        }
748    }
749}