Skip to main content

lash_core/
triggers.rs

1use std::collections::BTreeMap;
2use std::sync::{Arc, Mutex};
3
4use serde::{Deserialize, Serialize};
5
6use crate::plugin::PluginError;
7
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9pub struct TriggerEvent {
10    pub resource_type: String,
11    pub alias: String,
12    pub event: String,
13    pub payload_schema: crate::LashSchema,
14}
15
16impl TriggerEvent {
17    pub fn new(
18        resource_type: impl Into<String>,
19        alias: impl Into<String>,
20        event: impl Into<String>,
21        payload_schema: crate::LashSchema,
22    ) -> Self {
23        Self {
24            resource_type: resource_type.into(),
25            alias: alias.into(),
26            event: event.into(),
27            payload_schema,
28        }
29    }
30
31    pub fn payload_schema(&self) -> &crate::LashSchema {
32        &self.payload_schema
33    }
34
35    pub fn key(&self) -> TriggerEventKey {
36        TriggerEventKey {
37            resource_type: self.resource_type.clone(),
38            alias: self.alias.clone(),
39            event: self.event.clone(),
40        }
41    }
42
43    pub fn source_type(&self) -> String {
44        trigger_event_type(&self.alias, &self.event)
45    }
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
49pub struct TriggerEventKey {
50    pub resource_type: String,
51    pub alias: String,
52    pub event: String,
53}
54
55impl TriggerEventKey {
56    pub fn new(
57        resource_type: impl Into<String>,
58        alias: impl Into<String>,
59        event: impl Into<String>,
60    ) -> Self {
61        Self {
62            resource_type: resource_type.into(),
63            alias: alias.into(),
64            event: event.into(),
65        }
66    }
67
68    pub fn source_type(&self) -> String {
69        trigger_event_type(&self.alias, &self.event)
70    }
71}
72
73pub fn trigger_event_type(alias: &str, event: &str) -> String {
74    format!("{alias}.{event}")
75}
76
77#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
78pub struct TriggerEventCatalog {
79    events: BTreeMap<TriggerEventKey, TriggerEvent>,
80}
81
82impl TriggerEventCatalog {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    pub fn declare(&mut self, event: TriggerEvent) -> Result<(), String> {
88        let key = event.key();
89        if self.events.contains_key(&key) {
90            return Err(format!(
91                "duplicate trigger occurrence `{}.{}.{}`",
92                key.resource_type, key.alias, key.event
93            ));
94        }
95        let source_type = event.source_type();
96        if let Some(existing) = self
97            .events
98            .values()
99            .find(|existing| existing.source_type() == source_type)
100        {
101            return Err(format!(
102                "duplicate trigger source `{source_type}` declared by `{}.{}.{}` and `{}.{}.{}`",
103                existing.resource_type,
104                existing.alias,
105                existing.event,
106                key.resource_type,
107                key.alias,
108                key.event
109            ));
110        }
111        self.events.insert(key, event);
112        Ok(())
113    }
114
115    pub fn from_events(events: impl IntoIterator<Item = TriggerEvent>) -> Result<Self, String> {
116        let mut catalog = Self::new();
117        for event in events {
118            catalog.declare(event)?;
119        }
120        Ok(catalog)
121    }
122
123    pub fn get(&self, resource_type: &str, alias: &str, event: &str) -> Option<&TriggerEvent> {
124        self.events
125            .get(&TriggerEventKey::new(resource_type, alias, event))
126    }
127
128    pub fn is_empty(&self) -> bool {
129        self.events.is_empty()
130    }
131
132    pub fn events(&self) -> impl Iterator<Item = &TriggerEvent> {
133        self.events.values()
134    }
135}
136
137#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum TriggerDeliveryEmitOutcome {
140    Started,
141    AlreadyReserved,
142    Failed { reason: String },
143}
144
145#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
146pub struct TriggerDeliveryEmitReport {
147    pub occurrence_id: String,
148    pub subscription_id: String,
149    pub process_id: String,
150    pub outcome: TriggerDeliveryEmitOutcome,
151}
152
153#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
154pub struct TriggerEmitReport {
155    #[serde(default, skip_serializing_if = "String::is_empty")]
156    pub occurrence_id: String,
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub deliveries: Vec<TriggerDeliveryEmitReport>,
159}
160
161impl TriggerEmitReport {
162    pub fn empty() -> Self {
163        Self::default()
164    }
165
166    fn new(occurrence_id: String, deliveries: Vec<TriggerDeliveryEmitReport>) -> Self {
167        Self {
168            occurrence_id,
169            deliveries,
170        }
171    }
172
173    pub fn started_process_ids(&self) -> Vec<String> {
174        self.deliveries
175            .iter()
176            .filter(|delivery| delivery.outcome == TriggerDeliveryEmitOutcome::Started)
177            .map(|delivery| delivery.process_id.clone())
178            .collect()
179    }
180}
181
182#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
183pub struct TriggerOccurrenceRequest {
184    pub source_type: String,
185    pub source_key: String,
186    #[serde(default)]
187    pub payload: serde_json::Value,
188    pub idempotency_key: String,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub source: Option<serde_json::Value>,
191}
192
193impl TriggerOccurrenceRequest {
194    pub fn new(
195        source_type: impl Into<String>,
196        source_key: impl Into<String>,
197        payload: serde_json::Value,
198        idempotency_key: impl Into<String>,
199    ) -> Self {
200        Self {
201            source_type: source_type.into(),
202            source_key: source_key.into(),
203            payload,
204            idempotency_key: idempotency_key.into(),
205            source: None,
206        }
207    }
208
209    pub fn with_source(mut self, source: serde_json::Value) -> Self {
210        self.source = Some(source);
211        self
212    }
213}
214
215#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
216pub struct TriggerOccurrenceRecord {
217    pub occurrence_id: String,
218    pub source_type: String,
219    pub source_key: String,
220    #[serde(default)]
221    pub payload: serde_json::Value,
222    pub idempotency_key: String,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub source: Option<serde_json::Value>,
225    pub occurred_at_ms: u64,
226}
227
228#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct TriggerOccurrenceFilter {
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub source_type: Option<String>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub source_key: Option<String>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub occurred_at_start_ms: Option<u64>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub occurred_at_end_ms: Option<u64>,
238}
239
240impl TriggerOccurrenceFilter {
241    pub fn for_source(source_type: impl Into<String>, source_key: impl Into<String>) -> Self {
242        Self {
243            source_type: Some(source_type.into()),
244            source_key: Some(source_key.into()),
245            ..Self::default()
246        }
247    }
248
249    pub fn matches(&self, record: &TriggerOccurrenceRecord) -> bool {
250        self.source_type
251            .as_deref()
252            .is_none_or(|source_type| record.source_type == source_type)
253            && self
254                .source_key
255                .as_deref()
256                .is_none_or(|source_key| record.source_key == source_key)
257            && self
258                .occurred_at_start_ms
259                .is_none_or(|start_ms| record.occurred_at_ms >= start_ms)
260            && self
261                .occurred_at_end_ms
262                .is_none_or(|end_ms| record.occurred_at_ms < end_ms)
263    }
264}
265
266#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
267#[serde(transparent)]
268pub struct TriggerEventType(String);
269
270impl TriggerEventType {
271    pub fn new(value: impl Into<String>) -> Self {
272        Self(value.into())
273    }
274
275    pub fn as_str(&self) -> &str {
276        &self.0
277    }
278}
279
280impl From<String> for TriggerEventType {
281    fn from(value: String) -> Self {
282        Self::new(value)
283    }
284}
285
286impl From<&str> for TriggerEventType {
287    fn from(value: &str) -> Self {
288        Self::new(value)
289    }
290}
291
292impl AsRef<str> for TriggerEventType {
293    fn as_ref(&self) -> &str {
294        self.as_str()
295    }
296}
297
298impl std::fmt::Display for TriggerEventType {
299    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        formatter.write_str(self.as_str())
301    }
302}
303
304#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
305pub struct TriggerRegistration {
306    pub handle: String,
307    pub source_key: String,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub name: Option<String>,
310    pub source_type: TriggerEventType,
311    pub source: serde_json::Value,
312    pub target: TriggerTargetSummary,
313    #[serde(default = "default_enabled")]
314    pub enabled: bool,
315}
316
317#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
318pub struct TriggerTargetSummary {
319    pub label: Option<String>,
320    pub identity: crate::ProcessIdentity,
321    pub input: crate::ProcessInput,
322    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
323    pub inputs: BTreeMap<String, TriggerInputBinding>,
324}
325
326#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
327#[serde(tag = "type", rename_all = "snake_case")]
328pub enum TriggerInputBinding {
329    Event,
330    Fixed { value: serde_json::Value },
331}
332
333#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
334pub struct TriggerSubscriptionDraft {
335    pub registrant: crate::ProcessOriginator,
336    pub env_ref: crate::ProcessExecutionEnvRef,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub wake_target: Option<crate::SessionScope>,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub name: Option<String>,
341    pub source_type: String,
342    pub source_key: String,
343    pub source: serde_json::Value,
344    pub payload_schema: crate::LashSchema,
345    pub target: crate::ProcessInput,
346    pub target_identity: crate::ProcessIdentity,
347    #[serde(default)]
348    pub event_types: Vec<crate::ProcessEventType>,
349    #[serde(default)]
350    pub input_template: BTreeMap<String, TriggerInputBinding>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub target_label: Option<String>,
353}
354
355impl TriggerSubscriptionDraft {
356    pub fn for_process(
357        registrant: crate::ProcessOriginator,
358        env_ref: crate::ProcessExecutionEnvRef,
359        source_type: impl Into<String>,
360        source_key: impl Into<String>,
361        target: crate::ProcessInput,
362        target_identity: crate::ProcessIdentity,
363    ) -> Self {
364        let target_label = target_identity.label.clone();
365        Self {
366            registrant,
367            env_ref,
368            wake_target: None,
369            name: None,
370            source_type: source_type.into(),
371            source_key: source_key.into(),
372            source: serde_json::Value::Object(serde_json::Map::new()),
373            payload_schema: crate::LashSchema::new(serde_json::Value::Object(
374                serde_json::Map::new(),
375            )),
376            target,
377            target_identity,
378            event_types: Vec::new(),
379            input_template: BTreeMap::new(),
380            target_label,
381        }
382    }
383
384    pub fn with_name(mut self, name: impl Into<String>) -> Self {
385        self.name = Some(name.into());
386        self
387    }
388
389    pub fn with_source(mut self, source: serde_json::Value) -> Self {
390        self.source = source;
391        self
392    }
393
394    pub fn with_payload_schema(mut self, payload_schema: crate::LashSchema) -> Self {
395        self.payload_schema = payload_schema;
396        self
397    }
398
399    pub fn with_wake_target(mut self, wake_target: crate::SessionScope) -> Self {
400        self.wake_target = Some(wake_target);
401        self
402    }
403
404    pub fn with_event_types(
405        mut self,
406        event_types: impl IntoIterator<Item = crate::ProcessEventType>,
407    ) -> Self {
408        self.event_types = event_types.into_iter().collect();
409        self
410    }
411
412    pub fn with_input_template(
413        mut self,
414        input_template: BTreeMap<String, TriggerInputBinding>,
415    ) -> Self {
416        self.input_template = input_template;
417        self
418    }
419
420    pub fn with_target_label(mut self, target_label: impl Into<String>) -> Self {
421        self.target_label = Some(target_label.into());
422        self
423    }
424
425    pub fn validate(&self) -> Result<(), PluginError> {
426        validate_trigger_subscription_target_label(
427            self.target_label.as_deref(),
428            self.target_identity.label.as_deref(),
429        )
430    }
431}
432
433#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
434pub struct TriggerSubscriptionRecord {
435    pub subscription_id: String,
436    pub registrant: crate::ProcessOriginator,
437    pub env_ref: crate::ProcessExecutionEnvRef,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub wake_target: Option<crate::SessionScope>,
440    pub handle: String,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub name: Option<String>,
443    pub source_type: String,
444    pub source_key: String,
445    pub source: serde_json::Value,
446    pub payload_schema: crate::LashSchema,
447    pub target: crate::ProcessInput,
448    pub target_identity: crate::ProcessIdentity,
449    #[serde(default)]
450    pub event_types: Vec<crate::ProcessEventType>,
451    #[serde(default)]
452    pub input_template: BTreeMap<String, TriggerInputBinding>,
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub target_label: Option<String>,
455    #[serde(default = "default_enabled")]
456    pub enabled: bool,
457    pub created_at_ms: u64,
458    pub updated_at_ms: u64,
459}
460
461impl TriggerSubscriptionRecord {
462    pub fn registrant_scope_id(&self) -> String {
463        self.registrant.scope_id()
464    }
465
466    pub fn registrant_session_id(&self) -> Option<&str> {
467        match &self.registrant {
468            crate::ProcessOriginator::Session { scope } => Some(scope.session_id.as_str()),
469            crate::ProcessOriginator::Host { .. } => None,
470        }
471    }
472}
473
474fn validate_trigger_subscription_target_label(
475    target_label: Option<&str>,
476    identity_label: Option<&str>,
477) -> Result<(), PluginError> {
478    match (target_label, identity_label) {
479        (Some(target_label), Some(identity_label)) if target_label != identity_label => {
480            Err(PluginError::Session(
481                "trigger target_label must match target_identity.label when both are present"
482                    .to_string(),
483            ))
484        }
485        _ => Ok(()),
486    }
487}
488
489impl From<&TriggerSubscriptionRecord> for TriggerRegistration {
490    fn from(route: &TriggerSubscriptionRecord) -> Self {
491        Self {
492            handle: route.handle.clone(),
493            source_key: route.source_key.clone(),
494            name: route.name.clone(),
495            source_type: TriggerEventType::new(route.source_type.clone()),
496            source: route.source.clone(),
497            target: TriggerTargetSummary {
498                label: route.target_label.clone(),
499                identity: route.target_identity.clone(),
500                input: route.target.clone(),
501                inputs: route.input_template.clone(),
502            },
503            enabled: route.enabled,
504        }
505    }
506}
507
508#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
509pub struct TriggerSubscriptionFilter {
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub registrant_scope_id: Option<String>,
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub session_id: Option<String>,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub handle: Option<String>,
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub name: Option<String>,
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub source_type: Option<String>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub source_key: Option<String>,
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub target: Option<serde_json::Value>,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub enabled: Option<bool>,
526}
527
528impl TriggerSubscriptionFilter {
529    pub fn for_session(session_id: impl Into<String>) -> Self {
530        Self {
531            session_id: Some(session_id.into()),
532            ..Self::default()
533        }
534    }
535
536    pub fn for_registrant_scope(scope_id: impl Into<String>) -> Self {
537        Self {
538            registrant_scope_id: Some(scope_id.into()),
539            ..Self::default()
540        }
541    }
542
543    pub fn for_source_type(source_type: impl Into<String>) -> Self {
544        Self {
545            source_type: Some(source_type.into()),
546            ..Self::default()
547        }
548    }
549
550    pub fn effective_registrant_scope_id(&self) -> Option<String> {
551        self.registrant_scope_id.clone()
552    }
553
554    pub fn matches(&self, record: &TriggerSubscriptionRecord) -> bool {
555        self.effective_registrant_scope_id()
556            .is_none_or(|scope_id| record.registrant_scope_id() == scope_id)
557            && self
558                .session_id
559                .as_deref()
560                .is_none_or(|session_id| record.registrant_session_id() == Some(session_id))
561            && self
562                .handle
563                .as_deref()
564                .is_none_or(|handle| record.handle == handle)
565            && self
566                .name
567                .as_deref()
568                .is_none_or(|name| record.name.as_deref() == Some(name))
569            && self
570                .source_type
571                .as_deref()
572                .is_none_or(|source_type| record.source_type == source_type)
573            && self
574                .source_key
575                .as_deref()
576                .is_none_or(|source_key| record.source_key == source_key)
577            && self.enabled.is_none_or(|enabled| record.enabled == enabled)
578            && self
579                .target
580                .as_ref()
581                .is_none_or(|target| record.target_identity.definition.as_ref() == Some(target))
582    }
583}
584
585#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum TriggerDeliveryReservationStatus {
588    Reserved,
589    AlreadyReserved,
590}
591
592#[derive(Clone, Debug, Serialize, Deserialize)]
593pub struct TriggerDeliveryReservation {
594    pub occurrence: TriggerOccurrenceRecord,
595    pub subscription: TriggerSubscriptionRecord,
596    pub process_id: String,
597    pub created_at_ms: u64,
598    pub reservation_status: TriggerDeliveryReservationStatus,
599}
600
601impl TriggerDeliveryReservation {
602    fn emit_report(&self, outcome: TriggerDeliveryEmitOutcome) -> TriggerDeliveryEmitReport {
603        TriggerDeliveryEmitReport {
604            occurrence_id: self.occurrence.occurrence_id.clone(),
605            subscription_id: self.subscription.subscription_id.clone(),
606            process_id: self.process_id.clone(),
607            outcome,
608        }
609    }
610}
611
612#[async_trait::async_trait]
613pub trait TriggerStore: Send + Sync {
614    fn durability_tier(&self) -> crate::DurabilityTier {
615        crate::DurabilityTier::Inline
616    }
617
618    async fn source_key_for_subscription(
619        &self,
620        source_type: &str,
621        source: &serde_json::Value,
622    ) -> Result<String, PluginError> {
623        default_trigger_source_key(source_type, source)
624    }
625
626    async fn register_subscription(
627        &self,
628        draft: TriggerSubscriptionDraft,
629    ) -> Result<TriggerSubscriptionRecord, PluginError>;
630
631    async fn list_subscriptions(
632        &self,
633        filter: TriggerSubscriptionFilter,
634    ) -> Result<Vec<TriggerSubscriptionRecord>, PluginError>;
635
636    async fn cancel_subscription(
637        &self,
638        registrant_scope_id: &str,
639        handle: &str,
640    ) -> Result<bool, PluginError>;
641
642    async fn delete_session_subscriptions(&self, session_id: &str) -> Result<usize, PluginError>;
643
644    async fn record_occurrence(
645        &self,
646        request: TriggerOccurrenceRequest,
647    ) -> Result<TriggerOccurrenceRecord, PluginError>;
648
649    async fn list_occurrences(
650        &self,
651        filter: TriggerOccurrenceFilter,
652    ) -> Result<Vec<TriggerOccurrenceRecord>, PluginError>;
653
654    async fn reserve_matching_deliveries(
655        &self,
656        occurrence_id: &str,
657    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError>;
658
659    async fn list_deliveries_by_occurrence_id(
660        &self,
661        occurrence_id: &str,
662    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError>;
663
664    async fn list_deliveries_by_subscription_id(
665        &self,
666        subscription_id: &str,
667    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError>;
668
669    async fn list_deliveries_by_process_id(
670        &self,
671        process_id: &str,
672    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError>;
673}
674
675pub struct InMemoryTriggerStore {
676    clock: Arc<dyn crate::Clock>,
677    state: Mutex<InMemoryTriggerEventState>,
678}
679
680impl InMemoryTriggerStore {
681    pub fn new() -> Self {
682        Self::with_clock(Arc::new(crate::SystemClock))
683    }
684
685    pub fn with_clock(clock: Arc<dyn crate::Clock>) -> Self {
686        Self {
687            clock,
688            state: Mutex::new(InMemoryTriggerEventState::default()),
689        }
690    }
691
692    fn list_deliveries(
693        &self,
694        matches: impl Fn(&InMemoryTriggerDeliveryRecord) -> bool,
695    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
696        let state = self
697            .state
698            .lock()
699            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
700        let mut deliveries = state
701            .deliveries
702            .values()
703            .filter(|delivery| matches(delivery))
704            .map(|delivery| {
705                in_memory_delivery_reservation(
706                    &state,
707                    delivery,
708                    TriggerDeliveryReservationStatus::AlreadyReserved,
709                )
710            })
711            .collect::<Result<Vec<_>, _>>()?;
712        deliveries.sort_by(|left, right| {
713            left.created_at_ms
714                .cmp(&right.created_at_ms)
715                .then_with(|| {
716                    left.occurrence
717                        .occurrence_id
718                        .cmp(&right.occurrence.occurrence_id)
719                })
720                .then_with(|| {
721                    left.subscription
722                        .subscription_id
723                        .cmp(&right.subscription.subscription_id)
724                })
725        });
726        Ok(deliveries)
727    }
728
729    #[cfg(any(test, feature = "testing"))]
730    pub(crate) fn delete_deliveries_by_process_ids(
731        &self,
732        process_ids: &std::collections::HashSet<String>,
733    ) -> Result<usize, PluginError> {
734        if process_ids.is_empty() {
735            return Ok(0);
736        }
737        let mut state = self
738            .state
739            .lock()
740            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
741        let before = state.deliveries.len();
742        state
743            .deliveries
744            .retain(|_, delivery| !process_ids.contains(&delivery.process_id));
745        Ok(before.saturating_sub(state.deliveries.len()))
746    }
747}
748
749impl Default for InMemoryTriggerStore {
750    fn default() -> Self {
751        Self::new()
752    }
753}
754
755#[derive(Default)]
756struct InMemoryTriggerEventState {
757    next_subscription_seq: u64,
758    subscriptions: BTreeMap<String, TriggerSubscriptionRecord>,
759    occurrences: BTreeMap<String, TriggerOccurrenceRecord>,
760    occurrence_id_by_idempotency_key: BTreeMap<String, String>,
761    occurrence_hashes: BTreeMap<String, String>,
762    deliveries: BTreeMap<(String, String), InMemoryTriggerDeliveryRecord>,
763}
764
765#[derive(Clone)]
766struct InMemoryTriggerDeliveryRecord {
767    occurrence_id: String,
768    subscription_id: String,
769    process_id: String,
770    created_at_ms: u64,
771}
772
773fn in_memory_delivery_reservation(
774    state: &InMemoryTriggerEventState,
775    delivery: &InMemoryTriggerDeliveryRecord,
776    reservation_status: TriggerDeliveryReservationStatus,
777) -> Result<TriggerDeliveryReservation, PluginError> {
778    let occurrence = state
779        .occurrences
780        .get(&delivery.occurrence_id)
781        .cloned()
782        .ok_or_else(|| {
783            PluginError::Session(format!(
784                "missing trigger occurrence `{}` for delivery",
785                delivery.occurrence_id
786            ))
787        })?;
788    let subscription = state
789        .subscriptions
790        .get(&delivery.subscription_id)
791        .cloned()
792        .ok_or_else(|| {
793            PluginError::Session(format!(
794                "missing trigger subscription `{}` for delivery",
795                delivery.subscription_id
796            ))
797        })?;
798    Ok(TriggerDeliveryReservation {
799        occurrence,
800        subscription,
801        process_id: delivery.process_id.clone(),
802        created_at_ms: delivery.created_at_ms,
803        reservation_status,
804    })
805}
806
807#[async_trait::async_trait]
808impl TriggerStore for InMemoryTriggerStore {
809    async fn register_subscription(
810        &self,
811        draft: TriggerSubscriptionDraft,
812    ) -> Result<TriggerSubscriptionRecord, PluginError> {
813        draft.validate()?;
814        let mut state = self
815            .state
816            .lock()
817            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
818        state.next_subscription_seq = state.next_subscription_seq.saturating_add(1);
819        let handle = format!("trigger:{}", state.next_subscription_seq);
820        let subscription_id = format!("subscription:{}", state.next_subscription_seq);
821        let now = self.clock.timestamp_ms();
822        let record = TriggerSubscriptionRecord {
823            subscription_id: subscription_id.clone(),
824            registrant: draft.registrant,
825            env_ref: draft.env_ref,
826            wake_target: draft.wake_target,
827            handle,
828            name: draft.name,
829            source_type: draft.source_type,
830            source_key: draft.source_key,
831            source: draft.source,
832            payload_schema: draft.payload_schema,
833            target: draft.target,
834            target_identity: draft.target_identity,
835            event_types: draft.event_types,
836            input_template: draft.input_template,
837            target_label: draft.target_label,
838            enabled: true,
839            created_at_ms: now,
840            updated_at_ms: now,
841        };
842        state.subscriptions.insert(subscription_id, record.clone());
843        Ok(record)
844    }
845
846    async fn list_subscriptions(
847        &self,
848        filter: TriggerSubscriptionFilter,
849    ) -> Result<Vec<TriggerSubscriptionRecord>, PluginError> {
850        let state = self
851            .state
852            .lock()
853            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
854        let mut records = state
855            .subscriptions
856            .values()
857            .filter(|record| filter.matches(record))
858            .cloned()
859            .collect::<Vec<_>>();
860        records.sort_by(|left, right| {
861            left.registrant_scope_id()
862                .cmp(&right.registrant_scope_id())
863                .then_with(|| left.handle.cmp(&right.handle))
864        });
865        Ok(records)
866    }
867
868    async fn cancel_subscription(
869        &self,
870        registrant_scope_id: &str,
871        handle: &str,
872    ) -> Result<bool, PluginError> {
873        let mut state = self
874            .state
875            .lock()
876            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
877        let now = self.clock.timestamp_ms();
878        let Some(record) = state.subscriptions.values_mut().find(|record| {
879            record.registrant_scope_id() == registrant_scope_id && record.handle == handle
880        }) else {
881            return Ok(false);
882        };
883        let changed = record.enabled;
884        record.enabled = false;
885        record.updated_at_ms = now;
886        Ok(changed)
887    }
888
889    async fn delete_session_subscriptions(&self, session_id: &str) -> Result<usize, PluginError> {
890        let mut state = self
891            .state
892            .lock()
893            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
894        let before = state.subscriptions.len();
895        state
896            .subscriptions
897            .retain(|_, record| record.registrant_session_id() != Some(session_id));
898        Ok(before.saturating_sub(state.subscriptions.len()))
899    }
900
901    async fn record_occurrence(
902        &self,
903        request: TriggerOccurrenceRequest,
904    ) -> Result<TriggerOccurrenceRecord, PluginError> {
905        validate_trigger_occurrence_request(&request)?;
906        let mut state = self
907            .state
908            .lock()
909            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
910        let request_hash = trigger_occurrence_request_hash(&request)?;
911        if let Some(existing_id) = state
912            .occurrence_id_by_idempotency_key
913            .get(&request.idempotency_key)
914            .cloned()
915        {
916            let existing_hash = state
917                .occurrence_hashes
918                .get(&existing_id)
919                .cloned()
920                .unwrap_or_default();
921            if existing_hash != request_hash {
922                return Err(PluginError::Session(format!(
923                    "trigger occurrence idempotency conflict for `{}`",
924                    request.idempotency_key
925                )));
926            }
927            return state.occurrences.get(&existing_id).cloned().ok_or_else(|| {
928                PluginError::Session(format!(
929                    "missing trigger occurrence `{existing_id}` for idempotency key"
930                ))
931            });
932        }
933        let occurrence_id = deterministic_occurrence_id(&request)?;
934        let record = TriggerOccurrenceRecord {
935            occurrence_id: occurrence_id.clone(),
936            source_type: request.source_type,
937            source_key: request.source_key,
938            payload: request.payload,
939            idempotency_key: request.idempotency_key.clone(),
940            source: request.source,
941            occurred_at_ms: self.clock.timestamp_ms(),
942        };
943        state
944            .occurrence_id_by_idempotency_key
945            .insert(request.idempotency_key, occurrence_id.clone());
946        state
947            .occurrence_hashes
948            .insert(occurrence_id.clone(), request_hash);
949        state.occurrences.insert(occurrence_id, record.clone());
950        Ok(record)
951    }
952
953    async fn list_occurrences(
954        &self,
955        filter: TriggerOccurrenceFilter,
956    ) -> Result<Vec<TriggerOccurrenceRecord>, PluginError> {
957        let state = self
958            .state
959            .lock()
960            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
961        let mut records = state
962            .occurrences
963            .values()
964            .filter(|record| filter.matches(record))
965            .cloned()
966            .collect::<Vec<_>>();
967        records.sort_by(|left, right| {
968            left.occurred_at_ms
969                .cmp(&right.occurred_at_ms)
970                .then_with(|| left.occurrence_id.cmp(&right.occurrence_id))
971        });
972        Ok(records)
973    }
974
975    async fn reserve_matching_deliveries(
976        &self,
977        occurrence_id: &str,
978    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
979        let mut state = self
980            .state
981            .lock()
982            .map_err(|_| PluginError::Session("trigger store lock poisoned".to_string()))?;
983        let occurrence = state
984            .occurrences
985            .get(occurrence_id)
986            .cloned()
987            .ok_or_else(|| {
988                PluginError::Session(format!("unknown trigger occurrence `{occurrence_id}`"))
989            })?;
990        let subscriptions = state
991            .subscriptions
992            .values()
993            .filter(|record| {
994                record.enabled
995                    && record.source_type == occurrence.source_type
996                    && record.source_key == occurrence.source_key
997            })
998            .cloned()
999            .collect::<Vec<_>>();
1000        let mut deliveries = Vec::new();
1001        for subscription in subscriptions {
1002            let process_id = deterministic_delivery_process_id(
1003                &occurrence.occurrence_id,
1004                &subscription.subscription_id,
1005            )?;
1006            let key = (
1007                occurrence.occurrence_id.clone(),
1008                subscription.subscription_id.clone(),
1009            );
1010            let (delivery, reservation_status) =
1011                if let Some(delivery) = state.deliveries.get(&key).cloned() {
1012                    (delivery, TriggerDeliveryReservationStatus::AlreadyReserved)
1013                } else {
1014                    let delivery = InMemoryTriggerDeliveryRecord {
1015                        occurrence_id: occurrence.occurrence_id.clone(),
1016                        subscription_id: subscription.subscription_id.clone(),
1017                        process_id,
1018                        created_at_ms: self.clock.timestamp_ms(),
1019                    };
1020                    state.deliveries.insert(key, delivery.clone());
1021                    (delivery, TriggerDeliveryReservationStatus::Reserved)
1022                };
1023            deliveries.push(TriggerDeliveryReservation {
1024                occurrence: occurrence.clone(),
1025                subscription,
1026                process_id: delivery.process_id,
1027                created_at_ms: delivery.created_at_ms,
1028                reservation_status,
1029            });
1030        }
1031        Ok(deliveries)
1032    }
1033
1034    async fn list_deliveries_by_occurrence_id(
1035        &self,
1036        occurrence_id: &str,
1037    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
1038        self.list_deliveries(|delivery| delivery.occurrence_id == occurrence_id)
1039    }
1040
1041    async fn list_deliveries_by_subscription_id(
1042        &self,
1043        subscription_id: &str,
1044    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
1045        self.list_deliveries(|delivery| delivery.subscription_id == subscription_id)
1046    }
1047
1048    async fn list_deliveries_by_process_id(
1049        &self,
1050        process_id: &str,
1051    ) -> Result<Vec<TriggerDeliveryReservation>, PluginError> {
1052        self.list_deliveries(|delivery| delivery.process_id == process_id)
1053    }
1054}
1055
1056fn default_enabled() -> bool {
1057    true
1058}
1059
1060pub fn default_trigger_source_key(
1061    source_type: &str,
1062    source: &serde_json::Value,
1063) -> Result<String, PluginError> {
1064    let digest = crate::stable_hash::stable_json_sha256_hex(&(source_type, source))
1065        .map_err(|err| PluginError::Session(format!("failed to hash trigger source key: {err}")))?;
1066    Ok(format!("source:{source_type}:sha256:{digest}"))
1067}
1068
1069pub fn empty_trigger_source_key(source_type: &str) -> Result<String, PluginError> {
1070    default_trigger_source_key(source_type, &serde_json::json!({}))
1071}
1072
1073pub fn deterministic_occurrence_id(
1074    request: &TriggerOccurrenceRequest,
1075) -> Result<String, PluginError> {
1076    let digest = crate::stable_hash::stable_json_sha256_hex(&(
1077        request.source_type.as_str(),
1078        request.source_key.as_str(),
1079        request.idempotency_key.as_str(),
1080    ))
1081    .map_err(|err| PluginError::Session(format!("failed to hash trigger occurrence: {err}")))?;
1082    Ok(format!("trigger:{digest}"))
1083}
1084
1085pub fn deterministic_delivery_process_id(
1086    occurrence_id: &str,
1087    subscription_id: &str,
1088) -> Result<String, PluginError> {
1089    let digest = crate::stable_hash::stable_json_sha256_hex(&(occurrence_id, subscription_id))
1090        .map_err(|err| PluginError::Session(format!("failed to hash trigger delivery: {err}")))?;
1091    Ok(format!("process:trigger:{digest}"))
1092}
1093
1094#[derive(Clone)]
1095pub struct TriggerRouter {
1096    store: Arc<dyn TriggerStore>,
1097    process_registry: Option<Arc<dyn crate::ProcessRegistry>>,
1098    process_work_driver: Option<crate::ProcessWorkDriver>,
1099}
1100
1101impl TriggerRouter {
1102    pub fn new(
1103        store: Arc<dyn TriggerStore>,
1104        process_registry: Option<Arc<dyn crate::ProcessRegistry>>,
1105        process_work_driver: Option<crate::ProcessWorkDriver>,
1106    ) -> Self {
1107        Self {
1108            store,
1109            process_registry,
1110            process_work_driver,
1111        }
1112    }
1113
1114    pub fn store(&self) -> Arc<dyn TriggerStore> {
1115        Arc::clone(&self.store)
1116    }
1117
1118    pub async fn emit(
1119        &self,
1120        request: TriggerOccurrenceRequest,
1121        effect_controller: &dyn crate::RuntimeEffectController,
1122    ) -> Result<TriggerEmitReport, PluginError> {
1123        let occurrence = self.store.record_occurrence(request).await?;
1124        let reservations = self
1125            .store
1126            .reserve_matching_deliveries(&occurrence.occurrence_id)
1127            .await?;
1128        let Some(process_registry) = self.process_registry.as_ref() else {
1129            let deliveries = reservations
1130                .iter()
1131                .map(|reservation| {
1132                    let outcome = match reservation.reservation_status {
1133                        TriggerDeliveryReservationStatus::Reserved => {
1134                            TriggerDeliveryEmitOutcome::Failed {
1135                                reason: "trigger delivery requires a process registry".to_string(),
1136                            }
1137                        }
1138                        TriggerDeliveryReservationStatus::AlreadyReserved => {
1139                            TriggerDeliveryEmitOutcome::AlreadyReserved
1140                        }
1141                    };
1142                    reservation.emit_report(outcome)
1143                })
1144                .collect();
1145            return Ok(TriggerEmitReport::new(occurrence.occurrence_id, deliveries));
1146        };
1147        let mut deliveries = Vec::new();
1148        let mut started_any = false;
1149        for reservation in reservations {
1150            if reservation.reservation_status == TriggerDeliveryReservationStatus::AlreadyReserved {
1151                deliveries
1152                    .push(reservation.emit_report(TriggerDeliveryEmitOutcome::AlreadyReserved));
1153                continue;
1154            }
1155            if let Err(err) = self
1156                .start_delivery(
1157                    &reservation,
1158                    Arc::clone(process_registry),
1159                    effect_controller,
1160                )
1161                .await
1162            {
1163                deliveries.push(reservation.emit_report(TriggerDeliveryEmitOutcome::Failed {
1164                    reason: err.to_string(),
1165                }));
1166                continue;
1167            }
1168            started_any = true;
1169            deliveries.push(reservation.emit_report(TriggerDeliveryEmitOutcome::Started));
1170        }
1171        if started_any && let Some(driver) = self.process_work_driver.as_ref() {
1172            driver.claim_and_run_pending("trigger_delivery").await?;
1173        }
1174        Ok(TriggerEmitReport::new(occurrence.occurrence_id, deliveries))
1175    }
1176
1177    pub(crate) async fn start_delivery(
1178        &self,
1179        reservation: &TriggerDeliveryReservation,
1180        process_registry: Arc<dyn crate::ProcessRegistry>,
1181        effect_controller: &dyn crate::RuntimeEffectController,
1182    ) -> Result<(), PluginError> {
1183        let subscription = &reservation.subscription;
1184        let occurrence = &reservation.occurrence;
1185        subscription
1186            .payload_schema
1187            .validate(&occurrence.payload)
1188            .map_err(|err| {
1189                PluginError::Session(format!(
1190                    "invalid payload for trigger `{}`: {err}",
1191                    subscription.handle
1192                ))
1193            })?;
1194        let args =
1195            materialize_trigger_process_args(&subscription.input_template, &occurrence.payload)?;
1196        let target = apply_trigger_inputs(subscription.target.clone(), args)?;
1197        let originator_scope_id = subscription.registrant_scope_id();
1198        let trigger_causal_ref = crate::CausalRef::TriggerOccurrence {
1199            occurrence_id: occurrence.occurrence_id.clone(),
1200            subscription_id: Some(subscription.subscription_id.clone()),
1201        };
1202        let trigger_occurrence_invocation = crate::runtime::causal::trigger_occurrence_invocation(
1203            &originator_scope_id,
1204            &occurrence.occurrence_id,
1205        );
1206        let registration = crate::ProcessRegistration::new(
1207            reservation.process_id.clone(),
1208            target.clone(),
1209            // Trigger targets are journaled engine/tool rows, idempotent by
1210            // process id, so recovery may re-execute them (ADR 0019).
1211            crate::RecoveryDisposition::Rerunnable,
1212            crate::ProcessProvenance::new(subscription.registrant.clone())
1213                .with_caused_by(Some(trigger_causal_ref.clone())),
1214        )
1215        .with_identity(subscription.target_identity.clone())
1216        .with_extra_event_types(subscription.event_types.clone())
1217        .with_execution_env_ref(Some(subscription.env_ref.clone()))
1218        .with_wake_target(subscription.wake_target.clone());
1219        let descriptor_kind = subscription.target_identity.kind.clone();
1220        let grant =
1221            subscription
1222                .wake_target
1223                .clone()
1224                .map(|session_scope| crate::ProcessStartGrant {
1225                    session_scope,
1226                    descriptor: crate::ProcessHandleDescriptor::new(
1227                        Some(descriptor_kind.as_str()),
1228                        subscription.target_label.as_deref(),
1229                    ),
1230                });
1231        let execution_context = crate::ProcessExecutionContext::default()
1232            .with_causal_invocation(Some(trigger_occurrence_invocation));
1233        let command = crate::ProcessCommand::Start {
1234            registration,
1235            grant,
1236            execution_context: Box::new(execution_context),
1237        };
1238        let effect_id = command.effect_id();
1239        let invocation = crate::RuntimeInvocation::effect(
1240            crate::RuntimeScope::new(originator_scope_id),
1241            effect_id.clone(),
1242            crate::RuntimeEffectKind::Process,
1243            format!(
1244                "trigger:{}:{}",
1245                occurrence.occurrence_id, subscription.subscription_id
1246            ),
1247        )
1248        .with_caused_by(Some(trigger_causal_ref));
1249        let outcome = effect_controller
1250            .execute_effect(
1251                crate::RuntimeEffectEnvelope::new(
1252                    invocation,
1253                    crate::RuntimeEffectCommand::process(command),
1254                ),
1255                crate::RuntimeEffectLocalExecutor::processes(
1256                    process_registry,
1257                    self.process_work_driver.clone(),
1258                ),
1259            )
1260            .await?;
1261        match outcome {
1262            crate::RuntimeEffectOutcome::Process {
1263                result: crate::ProcessEffectOutcome::Start { .. },
1264            } => Ok(()),
1265            other => Err(PluginError::Session(format!(
1266                "trigger process start returned the wrong outcome: {}",
1267                other.kind().as_str()
1268            ))),
1269        }
1270    }
1271}
1272
1273fn materialize_trigger_process_args(
1274    input_template: &BTreeMap<String, TriggerInputBinding>,
1275    event_payload: &serde_json::Value,
1276) -> Result<serde_json::Map<String, serde_json::Value>, PluginError> {
1277    let mut args = serde_json::Map::new();
1278    for (input_name, input) in input_template {
1279        let value = match input {
1280            TriggerInputBinding::Event => event_payload.clone(),
1281            TriggerInputBinding::Fixed { value } => value.clone(),
1282        };
1283        args.insert(input_name.to_string(), value);
1284    }
1285    Ok(args)
1286}
1287
1288fn apply_trigger_inputs(
1289    mut target: crate::ProcessInput,
1290    args: serde_json::Map<String, serde_json::Value>,
1291) -> Result<crate::ProcessInput, PluginError> {
1292    match &mut target {
1293        crate::ProcessInput::Engine { payload, .. } => {
1294            let object = payload.as_object_mut().ok_or_else(|| {
1295                PluginError::Session(
1296                    "trigger engine target payload must be a JSON object".to_string(),
1297                )
1298            })?;
1299            object.insert("args".to_string(), serde_json::Value::Object(args));
1300            Ok(target)
1301        }
1302        other => Err(PluginError::Session(format!(
1303            "trigger target must be an engine process, got {}",
1304            other.engine_kind()
1305        ))),
1306    }
1307}
1308
1309pub fn validate_trigger_occurrence_request(
1310    request: &TriggerOccurrenceRequest,
1311) -> Result<(), PluginError> {
1312    if request.source_type.trim().is_empty() {
1313        return Err(PluginError::Session(
1314            "trigger occurrence requires source_type".to_string(),
1315        ));
1316    }
1317    if request.source_key.trim().is_empty() {
1318        return Err(PluginError::Session(
1319            "trigger occurrence requires source_key".to_string(),
1320        ));
1321    }
1322    if request.idempotency_key.trim().is_empty() {
1323        return Err(PluginError::Session(
1324            "trigger occurrence requires idempotency_key".to_string(),
1325        ));
1326    }
1327    Ok(())
1328}
1329
1330pub fn trigger_occurrence_request_hash(
1331    request: &TriggerOccurrenceRequest,
1332) -> Result<String, PluginError> {
1333    crate::stable_hash::stable_json_sha256_hex(&(
1334        request.source_type.as_str(),
1335        request.source_key.as_str(),
1336        &request.payload,
1337        &request.source,
1338    ))
1339    .map_err(|err| PluginError::Session(format!("failed to hash trigger occurrence: {err}")))
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344    use super::*;
1345
1346    fn button_payload_schema() -> crate::LashSchema {
1347        crate::LashSchema::any()
1348    }
1349
1350    fn trigger_process_draft(source_key: &str, process_name: &str) -> TriggerSubscriptionDraft {
1351        TriggerSubscriptionDraft::for_process(
1352            crate::ProcessOriginator::host(),
1353            crate::ProcessExecutionEnvRef::new(format!("process-env:{process_name}")),
1354            "ui.button.pressed",
1355            source_key,
1356            crate::ProcessInput::Engine {
1357                kind: "test-engine".to_string(),
1358                payload: serde_json::json!({ "process": process_name }),
1359            },
1360            crate::ProcessIdentity::new("test-engine").with_label(Some(process_name)),
1361        )
1362        .with_payload_schema(crate::LashSchema::any())
1363    }
1364
1365    fn button_occurrence(
1366        source_key: impl Into<String>,
1367        idempotency_key: impl Into<String>,
1368    ) -> TriggerOccurrenceRequest {
1369        TriggerOccurrenceRequest::new(
1370            "ui.button.pressed",
1371            source_key,
1372            serde_json::json!({ "button": "Blue" }),
1373            idempotency_key,
1374        )
1375    }
1376
1377    #[test]
1378    fn trigger_catalog_rejects_duplicate_trigger_source_identity() {
1379        let mut catalog = TriggerEventCatalog::new();
1380        catalog
1381            .declare(TriggerEvent::new(
1382                "Button",
1383                "ui.button",
1384                "pressed",
1385                button_payload_schema(),
1386            ))
1387            .expect("first trigger occurrence");
1388
1389        let err = catalog
1390            .declare(TriggerEvent::new(
1391                "AlternateButton",
1392                "ui.button",
1393                "pressed",
1394                button_payload_schema(),
1395            ))
1396            .expect_err("duplicate public source identity should be rejected");
1397
1398        assert!(err.contains("duplicate trigger source `ui.button.pressed`"));
1399    }
1400
1401    #[tokio::test]
1402    async fn trigger_store_rejects_mismatched_target_label() {
1403        let store = InMemoryTriggerStore::default();
1404        let draft = TriggerSubscriptionDraft::for_process(
1405            crate::ProcessOriginator::host(),
1406            crate::ProcessExecutionEnvRef::new("process-env:test"),
1407            "ui.button.pressed",
1408            "source-key",
1409            crate::ProcessInput::External {
1410                metadata: serde_json::json!({}),
1411            },
1412            crate::ProcessIdentity::new("external").with_label(Some("expected")),
1413        )
1414        .with_target_label("other");
1415
1416        let err = store
1417            .register_subscription(draft)
1418            .await
1419            .expect_err("mismatched target labels should be rejected");
1420        assert!(err.to_string().contains("target_label must match"));
1421    }
1422
1423    #[tokio::test]
1424    async fn trigger_emit_report_records_started_and_already_reserved_deliveries() {
1425        let store = Arc::new(InMemoryTriggerStore::default());
1426        let registry: Arc<dyn crate::ProcessRegistry> =
1427            Arc::new(crate::TestLocalProcessRegistry::default());
1428        let source_key = empty_trigger_source_key("ui.button.pressed").expect("source key");
1429        let subscription = store
1430            .register_subscription(trigger_process_draft(&source_key, "started"))
1431            .await
1432            .expect("register subscription");
1433        let router = TriggerRouter::new(store, Some(Arc::clone(&registry)), None);
1434        let controller = crate::InlineRuntimeEffectController;
1435
1436        let report = router
1437            .emit(
1438                button_occurrence(source_key.clone(), "button-blue-report"),
1439                &controller,
1440            )
1441            .await
1442            .expect("emit trigger");
1443        assert_eq!(report.deliveries.len(), 1);
1444        let delivery = &report.deliveries[0];
1445        assert_eq!(delivery.occurrence_id, report.occurrence_id);
1446        assert_eq!(delivery.subscription_id, subscription.subscription_id);
1447        assert_eq!(delivery.outcome, TriggerDeliveryEmitOutcome::Started);
1448        let record = registry
1449            .get_process(&delivery.process_id)
1450            .await
1451            .expect("started process record");
1452        assert!(matches!(
1453            record.provenance.caused_by,
1454            Some(crate::CausalRef::TriggerOccurrence {
1455                occurrence_id,
1456                subscription_id: Some(subscription_id),
1457            }) if occurrence_id == report.occurrence_id
1458                && subscription_id == subscription.subscription_id
1459        ));
1460
1461        let replay = router
1462            .emit(
1463                button_occurrence(source_key, "button-blue-report"),
1464                &controller,
1465            )
1466            .await
1467            .expect("replay trigger");
1468        assert_eq!(replay.deliveries.len(), 1);
1469        assert_eq!(
1470            replay.deliveries[0].outcome,
1471            TriggerDeliveryEmitOutcome::AlreadyReserved
1472        );
1473        assert_eq!(replay.deliveries[0].process_id, delivery.process_id);
1474    }
1475
1476    #[tokio::test]
1477    async fn trigger_emit_report_records_failed_delivery_outcome() {
1478        let store = Arc::new(InMemoryTriggerStore::default());
1479        let source_key = empty_trigger_source_key("ui.button.pressed").expect("source key");
1480        let subscription = store
1481            .register_subscription(trigger_process_draft(&source_key, "failed"))
1482            .await
1483            .expect("register subscription");
1484        let router = TriggerRouter::new(store, None, None);
1485        let controller = crate::InlineRuntimeEffectController;
1486
1487        let report = router
1488            .emit(
1489                button_occurrence(source_key, "button-blue-failed"),
1490                &controller,
1491            )
1492            .await
1493            .expect("emit trigger");
1494        assert_eq!(report.deliveries.len(), 1);
1495        let delivery = &report.deliveries[0];
1496        assert_eq!(delivery.subscription_id, subscription.subscription_id);
1497        assert!(matches!(
1498            &delivery.outcome,
1499            TriggerDeliveryEmitOutcome::Failed { reason }
1500                if reason.contains("process registry")
1501        ));
1502    }
1503}