Skip to main content

areev_trigger/
eval.rs

1//! The evaluation cycle: find due, claim, invoke, ingest, start, release.
2//!
3//! One invocation performs the whole cycle and exits. Nothing stays resident.
4//! Losing a claim race is a no-op exit, not an error — that is the steady state
5//! when several nodes share a heartbeat.
6//!
7//! ## Why correctness does not rest on the lease
8//!
9//! A lease deduplicates *"who is working right now"*. But the thing that
10//! actually needs deduplicating — a firing of trigger T for item I — has a
11//! natural identity, so the run id is derived from it. `areev run start`
12//! already refuses a duplicate run id, so two evaluators racing produce one run
13//! and one recorded skip, with no lease consulted at all: no duration to guess,
14//! no fencing token to compare, no clock-skew window.
15//!
16//! The lease then does one narrower job: stopping two nodes from both making
17//! the same expensive, side-effecting connector call. Losing it can cost a
18//! wasted API call; it can never cause a missed or duplicated firing. That is
19//! the risk profile a lease should carry.
20
21use std::sync::Arc;
22
23use areev_cal::AreevFacade;
24use areev_core::types::{Event, Grain, GrainType, Observation, Trigger, TriggerKind};
25use areev_run::HostToolExecutor;
26use areev_store::TriggerState;
27use serde::Serialize;
28use sha2::{Digest, Sha256};
29
30use crate::clock::Clock;
31use crate::connector::{dedup_value, PollItem, PollRequest, PollResponse};
32use crate::error::{Result, TriggerError};
33use crate::schedule;
34
35/// Namespace for trigger firing records. Alongside the runtime's own
36/// `agent:harness`, so operational journalling stays out of user namespaces.
37pub const TRIGGER_NS: &str = "agent:triggers";
38
39/// How many triggers one invocation will look at. A backstop, not a policy.
40const MAX_TRIGGERS_SCANNED: usize = 4096;
41
42/// Knobs for one evaluation pass.
43#[derive(Debug, Clone)]
44pub struct EvalOptions {
45    /// Evaluate only this trigger (its content address).
46    pub only: Option<String>,
47    /// How long a claim is held before another evaluator may take over.
48    /// Deliberately generous: a lease shorter than the worst-case connector
49    /// runtime causes takeovers mid-poll, which costs duplicate API calls.
50    pub lease: std::time::Duration,
51    /// Cap on items requested per firing.
52    pub max_items: usize,
53    /// Report what would happen and touch nothing. The safe first command on a
54    /// new deployment.
55    pub dry_run: bool,
56    /// This evaluator's identity, recorded in the claim.
57    pub node: String,
58}
59
60impl Default for EvalOptions {
61    fn default() -> Self {
62        EvalOptions {
63            only: None,
64            lease: std::time::Duration::from_secs(300),
65            max_items: 100,
66            dry_run: false,
67            node: default_node_id(),
68        }
69    }
70}
71
72/// First `n` characters, safely — `&s[..n]` panics on a short string or a
73/// multi-byte boundary, and a message-formatting helper must not be what takes
74/// the process down. Declarations can arrive by bundle import from an
75/// implementation we did not write.
76fn short(s: &str, n: usize) -> &str {
77    match s.char_indices().nth(n) {
78        Some((idx, _)) => &s[..idx],
79        None => s,
80    }
81}
82
83/// A stable-ish identity for this evaluator. Host name plus pid: enough to tell
84/// two nodes apart in a claim, and to recognise our own stale claim after a
85/// restart.
86fn default_node_id() -> String {
87    let host = std::env::var("HOSTNAME")
88        .or_else(|_| std::env::var("COMPUTERNAME"))
89        .unwrap_or_else(|_| "node".into());
90    format!("{host}/{}", std::process::id())
91}
92
93/// What one evaluation pass did. Shaped like `loop run`'s report for symmetry.
94#[derive(Debug, Clone, Default, Serialize)]
95pub struct EvalReport {
96    pub claimed: usize,
97    pub skipped_not_due: usize,
98    pub skipped_locked: usize,
99    pub skipped_paused: usize,
100    pub items: usize,
101    pub duplicates: usize,
102    pub runs_started: usize,
103    /// Items recorded without starting a workflow, because no runtime is wired
104    /// in. Counted apart from `runs_started` so the report never claims a run
105    /// happened when none did.
106    pub ingested: usize,
107    /// Items the connector returned that carry no usable identity, so no run
108    /// could be minted for them. Reported rather than silently dropped: an item
109    /// nobody can name is a connector bug, and it should be visible as one.
110    pub unidentifiable: usize,
111    /// Declarations that cannot fire as written — a cron that does not parse, a
112    /// timezone this build refuses, a composite gate naming a member the
113    /// declaration does not carry.
114    ///
115    /// Counted APART from `skipped_not_due` on purpose. Folded in there, an
116    /// unusable trigger is indistinguishable from a healthy one waiting its
117    /// turn, which is the failure mode with no symptom: the work simply never
118    /// happens and every report looks green. The reason lands in `errors`.
119    pub unusable: usize,
120    /// Not fatal to the pass: one broken trigger must not stop the others.
121    pub errors: Vec<String>,
122    /// A connector reported a backlog, so this trigger is due again at once
123    /// rather than after its interval.
124    pub draining: Vec<String>,
125}
126
127/// One trigger considered, for `--dry-run` and `status`.
128#[derive(Debug, Clone, Serialize)]
129pub struct TriggerStatus {
130    pub trigger: String,
131    pub kind: String,
132    pub workflow: String,
133    pub enabled: bool,
134    pub paused: bool,
135    pub due: bool,
136    pub leased_by: Option<String>,
137    pub next_due_at: Option<i64>,
138    pub last_fired_at: Option<i64>,
139    pub consecutive_failures: u32,
140    pub last_error: Option<String>,
141    /// Will never fire again — a one-shot past its instant.
142    pub exhausted: bool,
143    /// Never fired and no failure recorded — the state an unnoticed
144    /// misconfiguration sits in, so it is reported rather than inferred.
145    pub never_fired: bool,
146    /// Why this declaration can never fire, if it cannot.
147    ///
148    /// A trigger can become unusable without ever being written through a
149    /// validating path — it can arrive by bundle import from an implementation
150    /// that validated differently, or predate the check. So the evaluator
151    /// reports it rather than assuming the write path caught everything.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub unusable: Option<String>,
154}
155
156/// Starts the workflow a trigger is bound to.
157///
158/// A seam rather than a direct `areev_run::Runner` call, for the same reason
159/// the CAL executor takes a governance host: the runtime sits above this crate
160/// in the dependency order for hosts that want it, and a test needs to observe
161/// what *would* be started without spending a real run. It is also where the
162/// duplicate rule lives, which is the whole idempotency story.
163pub trait RunStarter: Send + Sync {
164    fn start(&self, workflow: &str, run_id: &str, input: serde_json::Value) -> StartResult;
165}
166
167/// What starting a run did.
168#[derive(Debug, Clone, PartialEq)]
169pub enum StartResult {
170    Started,
171    /// A run with this id already exists. **Not an error** — it is the
172    /// idempotency guarantee working: this item was already handled, by an
173    /// earlier firing or by another node that won the race.
174    Duplicate,
175    Failed(String),
176}
177
178/// Evaluates triggers against one memory.
179pub struct Evaluator {
180    pub facade: Arc<AreevFacade>,
181    pub clock: Arc<dyn Clock>,
182    /// Executes connector commands. `None` means a due polling trigger fails
183    /// loudly with `TRG-E003` rather than quietly doing nothing — a poll that
184    /// silently returns no items is indistinguishable from a healthy source.
185    pub connector: Option<Arc<dyn HostToolExecutor>>,
186    /// Starts the bound workflow. `None` ingests the item and records the
187    /// firing without starting anything — what `--no-start` is for, and what
188    /// the ingest-only tests use.
189    pub starter: Option<Arc<dyn RunStarter>>,
190    /// Credentials the broker may attach, by name. Values live here and never
191    /// in a grain or a connector's environment: a declaration names a
192    /// credential, it never carries one.
193    pub credentials: std::collections::BTreeMap<String, areev_run::Credential>,
194    pub ns: String,
195    pub principal: String,
196}
197
198impl Evaluator {
199    /// An evaluator that can inspect but not act: no connector, no runtime, no
200    /// credentials. What `list`, `show` and `status` want, and the shape that
201    /// makes "reading cannot fire anything" true by construction rather than by
202    /// remembering to pass `None` four times.
203    pub fn read_only(facade: Arc<AreevFacade>, clock: Arc<dyn Clock>, ns: &str) -> Evaluator {
204        Evaluator {
205            facade,
206            clock,
207            connector: None,
208            starter: None,
209            credentials: Default::default(),
210            ns: ns.to_string(),
211            principal: "user:local".into(),
212        }
213    }
214
215    /// Every trigger declaration in the memory, newest first.
216    pub fn declarations(&self) -> Result<Vec<(String, Trigger)>> {
217        let ns = self.ns.clone();
218        let grains = self
219            .facade
220            .with_store(|m| {
221                m.recent_live_scoped(&[ns], Some(GrainType::Trigger), MAX_TRIGGERS_SCANNED)
222            })
223            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
224
225        let mut out = Vec::new();
226        for g in grains {
227            let hash = g.hash.to_hex();
228            match g.to_trigger() {
229                Ok(t) => out.push((hash, t)),
230                // A declaration this build cannot read is reported, never
231                // skipped silently: silence is the symptom of every trigger
232                // failure, so it must never also be the symptom of a bug.
233                Err(e) => {
234                    return Err(TriggerError::Malformed {
235                        what: format!("declaration {hash} is unreadable: {e}"),
236                    })
237                }
238            }
239        }
240        Ok(out)
241    }
242
243    /// What every trigger's state looks like right now.
244    pub fn status(&self) -> Result<Vec<TriggerStatus>> {
245        let now = self.clock.now_ms();
246        let mut out = Vec::new();
247        for (hash, t) in self.declarations()? {
248            let st = self
249                .facade
250                .with_store(|m| m.trigger_state(&hash))
251                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
252                .map(|(s, _)| s)
253                .unwrap_or_default();
254            let unusable = schedule::validate(&t).err().map(|e| e.to_string());
255            out.push(TriggerStatus {
256                trigger: hash,
257                kind: t.kind.as_str().to_string(),
258                workflow: t.workflow.clone(),
259                enabled: t.enabled,
260                paused: st.paused,
261                // An unusable declaration is never due. Saying otherwise would
262                // promise a firing that cannot happen.
263                due: unusable.is_none()
264                    && t.enabled
265                    && !st.leased(now)
266                    && schedule::is_due(&t, &st, now).unwrap_or(false),
267                leased_by: st.leased(now).then(|| st.claimed_by.clone()).flatten(),
268                next_due_at: st.next_due_at,
269                last_fired_at: st.last_fired_at,
270                consecutive_failures: st.consecutive_failures,
271                last_error: st.last_error.clone(),
272                exhausted: st.exhausted,
273                never_fired: st.last_fired_at.is_none() && st.consecutive_failures == 0,
274                unusable,
275            });
276        }
277        Ok(out)
278    }
279
280    /// Ingest a payload delivered by the host for a `webhook` or `manual`
281    /// trigger.
282    ///
283    /// Areev never opens a port. The host already terminates TLS and
284    /// authenticates the sender — it is far better at both than a memory engine
285    /// would be — and hands the payload to a one-shot invocation. That keeps the
286    /// useful half of webhooks without a listening daemon.
287    ///
288    /// Idempotent on the same terms as a poll: the payload's dedup value mints
289    /// the run id, so a webhook delivered twice (which every provider does)
290    /// produces one run and one recorded skip.
291    pub fn deliver(&self, trigger_hash: &str, payload: serde_json::Value) -> Result<EvalReport> {
292        let now = self.clock.now_ms();
293        let (hash, trigger) = self
294            .declarations()?
295            .into_iter()
296            .find(|(h, _)| h.starts_with(trigger_hash))
297            .ok_or_else(|| TriggerError::Malformed {
298                what: format!("no trigger matching '{trigger_hash}'"),
299            })?;
300
301        if !matches!(trigger.kind, TriggerKind::Webhook | TriggerKind::Manual) {
302            return Err(TriggerError::Malformed {
303                what: format!(
304                    "trigger {} is a {} trigger — only webhook and manual triggers accept a \
305                     delivery; a clocked or polling trigger fires from `trigger run`",
306                    short(&hash, 12),
307                    trigger.kind.as_str()
308                ),
309            });
310        }
311        if !trigger.enabled {
312            return Err(TriggerError::Malformed {
313                what: format!("trigger {} is disabled", short(&hash, 12)),
314            });
315        }
316        let (state, _) = self
317            .facade
318            .with_store(|m| m.trigger_state(&hash))
319            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
320            .unwrap_or_default();
321        if state.paused {
322            return Err(TriggerError::Malformed {
323                what: format!("trigger {} is paused", short(&hash, 12)),
324            });
325        }
326
327        let mut report = EvalReport::default();
328        let item = PollItem { id: String::new(), payload };
329        report.items = 1;
330        let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
331            report.unidentifiable = 1;
332            // Journal before returning. A delivery naming nothing is precisely
333            // the case the record exists for: the sender's payload shape
334            // changed under us, and stdout of whatever invoked this is not an
335            // audit record -- the replicating Observation is.
336            self.journal(
337                &hash,
338                &trigger,
339                &FireOutcome { items: 1, unidentifiable: 1, ..Default::default() },
340                Some("delivered"),
341                now,
342            )?;
343            return Ok(report);
344        };
345        let run_id = run_id_for(&hash, trigger.connector.as_deref(), &value);
346        match self.start_run(&trigger, &run_id, &item, &hash) {
347            StartOutcome::Started => report.runs_started = 1,
348            StartOutcome::Ingested => report.ingested = 1,
349            StartOutcome::Duplicate => report.duplicates = 1,
350            StartOutcome::Failed(why) => report.errors.push(why),
351        }
352
353        let outcome = FireOutcome {
354            items: 1,
355            runs_started: report.runs_started,
356            duplicates: report.duplicates,
357            ..Default::default()
358        };
359        self.journal(&hash, &trigger, &outcome, Some("delivered"), now)?;
360        Ok(report)
361    }
362
363    /// Run one evaluation pass.
364    pub fn run(&self, opts: &EvalOptions) -> Result<EvalReport> {
365        let mut report = EvalReport::default();
366        let now = self.clock.now_ms();
367
368        let declarations = self.declarations()?;
369        // Composites are settled after their members, so a gate can be
370        // satisfied in the same pass that completes it rather than waiting a
371        // whole heartbeat. Declaration order is newest-first and says nothing
372        // about dependency, so the split is explicit.
373        let (composites, members): (Vec<_>, Vec<_>) =
374            declarations.into_iter().partition(|(_, t)| t.kind == TriggerKind::Composite);
375
376        for (hash, trigger) in members.into_iter().chain(composites) {
377            if opts.only.as_ref().is_some_and(|only| only != &hash) {
378                continue;
379            }
380            if !trigger.enabled {
381                report.skipped_not_due += 1;
382                continue;
383            }
384            // Checked BEFORE dueness, so an unusable declaration is reported as
385            // what it is rather than disappearing into `not due` — where it
386            // looks exactly like a healthy trigger waiting its turn.
387            if let Err(why) = schedule::validate(&trigger) {
388                report.unusable += 1;
389                report.errors.push(format!("{}: {why}", short(&hash, 12)));
390                continue;
391            }
392            let stored = self
393                .facade
394                .with_store(|m| m.trigger_state(&hash))
395                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
396            let (state, raw) = match stored {
397                Some((s, r)) => (s, Some(r)),
398                None => (TriggerState::default(), None),
399            };
400
401            if state.paused {
402                report.skipped_paused += 1;
403                continue;
404            }
405            // An absolute schedule seen for the first time: record when it
406            // will be due rather than re-deriving it every pass, so `trigger
407            // status` can say when it next fires and the comparison afterwards
408            // is a plain timestamp check.
409            if state.next_due_at.is_none() && !state.exhausted {
410                if let Some(first) = schedule::initial_due(&trigger, now)? {
411                    let mut seeded = state.clone();
412                    seeded.next_due_at = Some(first);
413                    let _ = self
414                        .facade
415                        .with_store(|m| m.put_trigger_state(&hash, raw.as_deref(), &seeded))
416                        .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
417                    report.skipped_not_due += 1;
418                    continue;
419                }
420            }
421            if !schedule::is_due(&trigger, &state, now)? {
422                report.skipped_not_due += 1;
423                continue;
424            }
425            if state.leased(now) {
426                // The normal steady state with several nodes on one heartbeat,
427                // not a problem.
428                report.skipped_locked += 1;
429                continue;
430            }
431            if opts.dry_run {
432                report.claimed += 1;
433                continue;
434            }
435
436            match self.fire(&hash, &trigger, state, raw.as_deref(), now, opts) {
437                // Lost the claim between deciding it was free and taking it.
438                // Normal with several nodes on one heartbeat, and it must not
439                // be counted as work done.
440                Ok(outcome) if outcome.skipped_locked => report.skipped_locked += 1,
441                Ok(outcome) => {
442                    if !outcome.fired_items.is_empty() {
443                        if let Err(e) = self.record_for_composites(&hash, &outcome.fired_items, now)
444                        {
445                            report.errors.push(e.to_string());
446                        }
447                    }
448                    report.claimed += 1;
449                    report.items += outcome.items;
450                    report.duplicates += outcome.duplicates;
451                    report.runs_started += outcome.runs_started;
452                    report.ingested += outcome.ingested;
453                    report.unidentifiable += outcome.unidentifiable;
454                    for why in &outcome.failures {
455                        report.errors.push(format!("{hash}: {why}"));
456                    }
457                    if outcome.draining {
458                        report.draining.push(hash.clone());
459                    }
460                }
461                Err(e) => {
462                    // One broken trigger must not stop the others.
463                    report.errors.push(e.to_string());
464                }
465            }
466        }
467        Ok(report)
468    }
469
470    fn fire(
471        &self,
472        hash: &str,
473        trigger: &Trigger,
474        state: TriggerState,
475        raw: Option<&str>,
476        now: i64,
477        opts: &EvalOptions,
478    ) -> Result<FireOutcome> {
479        // Claim. Losing means another evaluator got here first.
480        let mut claimed = state.clone();
481        claimed.fence = claimed.fence.wrapping_add(1);
482        claimed.claimed_by = Some(opts.node.clone());
483        claimed.lease_until = Some(now + opts.lease.as_millis() as i64);
484        let won = self
485            .facade
486            .with_store(|m| m.put_trigger_state(hash, raw, &claimed))
487            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
488        if !won {
489            return Ok(FireOutcome { skipped_locked: true, ..Default::default() });
490        }
491        // What a later release must present as `expected`.
492        let claimed_raw = self
493            .facade
494            .with_store(|m| m.trigger_state(hash))
495            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
496            .map(|(_, r)| r);
497
498        let result = self.collect_and_start(hash, trigger, &claimed, now, opts);
499
500        // Release under the fence, whatever happened.
501        let mut released = claimed.clone();
502        released.claimed_by = None;
503        released.lease_until = None;
504        match &result {
505            Ok(outcome) => {
506                released.last_fired_at = Some(now);
507                released.consecutive_failures = 0;
508                released.last_error = None;
509                if let Some(c) = &outcome.cursor {
510                    released.cursor = Some(c.clone());
511                }
512                if let Some(c) = outcome.op_cursor {
513                    released.op_cursor = Some(c);
514                }
515                // A satisfied gate consumes its correlation key. Leaving it
516                // would re-fire the same set on every pass; Flink calls this
517                // the after-match skip strategy, and the conservative choice —
518                // clear only the key that fired — is the one that cannot
519                // swallow an unrelated in-flight match.
520                for k in &outcome.settled_keys {
521                    released.partials.remove(k);
522                }
523                prune_partials(&mut released, trigger, now);
524                released.exhausted = schedule::exhausts_after(trigger, now);
525                released.next_due_at = if outcome.draining {
526                    // A backlog drains at once rather than waiting out the
527                    // interval — a cold start should not take a day.
528                    Some(now)
529                } else {
530                    schedule::advance_after_firing(trigger, now, now)?
531                };
532            }
533            Err(e) => {
534                released.consecutive_failures = claimed.consecutive_failures.saturating_add(1);
535                released.last_error = Some(e.to_string());
536                released.next_due_at =
537                    Some(schedule::backoff_until(trigger, released.consecutive_failures, now));
538            }
539        }
540        let released_ok = self
541            .facade
542            .with_store(|m| m.put_trigger_state(hash, claimed_raw.as_deref(), &released))
543            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
544        if !released_ok {
545            // Our lease expired mid-firing and someone else took over. Their
546            // state stands; ours is refused. The work we did is not lost — the
547            // runs we started are journaled and idempotent — but we must not
548            // write back over the new owner.
549            self.journal(hash, trigger, &FireOutcome::default(), Some("claim lost"), now)?;
550            return Err(TriggerError::ClaimLost { trigger: hash.to_string() });
551        }
552
553        let outcome = result?;
554        self.journal(hash, trigger, &outcome, None, now)?;
555        Ok(outcome)
556    }
557
558    /// Get the items for this firing and start a run per item.
559    fn collect_and_start(
560        &self,
561        hash: &str,
562        trigger: &Trigger,
563        state: &TriggerState,
564        now: i64,
565        opts: &EvalOptions,
566    ) -> Result<FireOutcome> {
567        let mut outcome = FireOutcome::default();
568
569        let items: Vec<PollItem> = match trigger.kind {
570            TriggerKind::Polling => {
571                let response = self.poll(hash, trigger, state, opts)?;
572                outcome.cursor = response.cursor.clone();
573                outcome.draining = response.more;
574                // First contact seeds the cursor and fires nothing. Otherwise
575                // declaring a mailbox trigger starts a run for every message in
576                // history — Zapier's priming poll, and for the same reason.
577                if state.cursor.is_none() {
578                    outcome.seeded = true;
579                    return Ok(outcome);
580                }
581                response.items
582            }
583            // A clocked trigger with no external source fires once per
584            // occurrence, carrying the occurrence itself as the item.
585            TriggerKind::Interval | TriggerKind::Schedule | TriggerKind::Once => {
586                vec![PollItem { id: now.to_string(), payload: serde_json::json!({ "at_ms": now }) }]
587            }
588            TriggerKind::Memory => {
589                // Recorded so the journal says "seeded" rather than leaving a
590                // firing that produced nothing looking like a quiet memory.
591                outcome.seeded = state.op_cursor.is_none();
592                let (found, cursor) = self.scan_memory(hash, trigger, state, opts)?;
593                outcome.op_cursor = Some(cursor);
594                found
595            }
596            TriggerKind::Composite => {
597                let keys = self.satisfied_keys(hash, trigger, now)?;
598                outcome.settled_keys = keys.clone();
599                keys.into_iter()
600                    .map(|k| PollItem {
601                        // The correlation value IS the identity: one firing per
602                        // correlated set, however many members contributed.
603                        id: k.clone(),
604                        payload: serde_json::json!({ "correlation": k }),
605                    })
606                    .collect()
607            }
608            // These do not poll. The host owns the listener and hands the
609            // payload to `deliver`, which fires them on the same idempotency
610            // terms; an evaluation pass simply has nothing to do for one.
611            TriggerKind::Webhook | TriggerKind::Manual => Vec::new(),
612        };
613
614        outcome.items = items.len();
615        for item in items {
616            let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
617                outcome.unidentifiable += 1;
618                continue;
619            };
620            outcome.fired_items.push(item.payload.clone());
621            let run_id = run_id_for(hash, trigger.connector.as_deref(), &value);
622            match self.start_run(trigger, &run_id, &item, hash) {
623                StartOutcome::Started => outcome.runs_started += 1,
624                StartOutcome::Ingested => outcome.ingested += 1,
625                StartOutcome::Duplicate => outcome.duplicates += 1,
626                StartOutcome::Failed(why) => outcome.failures.push(why),
627            }
628        }
629        Ok(outcome)
630    }
631
632    /// Note a member firing against every composite that declares it.
633    ///
634    /// The correlation value comes from the composite's own `correlate`
635    /// pointer applied to the member's item — so two composites may correlate
636    /// the same member on different fields, which is why this is resolved per
637    /// composite rather than once per member.
638    fn record_for_composites(
639        &self,
640        member: &str,
641        items: &[serde_json::Value],
642        now: i64,
643    ) -> Result<()> {
644        for (chash, composite) in self.declarations()? {
645            let Some(alias) = composite.member_alias(member).map(str::to_string) else {
646                continue;
647            };
648            if composite.kind != TriggerKind::Composite {
649                continue;
650            }
651            let (mut state, raw) = self
652                .facade
653                .with_store(|m| m.trigger_state(&chash))
654                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
655                .map(|(s, r)| (s, Some(r)))
656                .unwrap_or_default();
657
658            for item in items {
659                // No correlate pointer means one global match — every member
660                // firing counts toward the same gate.
661                let key = match &composite.correlate {
662                    Some(ptr) => match item.pointer(ptr) {
663                        Some(serde_json::Value::String(s)) => s.clone(),
664                        Some(other) => other.to_string(),
665                        // An item that cannot be correlated cannot join a
666                        // correlated gate. Dropping it is right: attributing it
667                        // to an arbitrary key would pair unrelated work.
668                        None => continue,
669                    },
670                    None => String::new(),
671                };
672                let entry = state.partials.entry(key).or_insert_with(|| {
673                    areev_store::PartialMatch { members: Default::default(), started_ms: now }
674                });
675                // Recorded under the ALIAS, because that is what the gate
676                // expression names.
677                entry.members.insert(alias.clone(), now);
678            }
679            prune_partials(&mut state, &composite, now);
680            self.facade
681                .with_store(|m| m.put_trigger_state(&chash, raw.as_deref(), &state))
682                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
683        }
684        Ok(())
685    }
686
687    /// Which correlation keys currently satisfy this composite's gate.
688    fn satisfied_keys(&self, hash: &str, trigger: &Trigger, now: i64) -> Result<Vec<String>> {
689        let predicate = trigger
690            .predicate
691            .as_ref()
692            .ok_or_else(|| TriggerError::Malformed {
693                what: format!("composite trigger {hash} has no predicate"),
694            })
695            .and_then(crate::predicate::from_value)?;
696
697        // Refuse a gate that names a member the declaration does not carry: it
698        // could never be satisfied, so the trigger would be silently dead.
699        for referenced in crate::predicate::referenced_members(&predicate) {
700            if !trigger.members.contains_key(&referenced) {
701                return Err(TriggerError::UnknownMember {
702                    trigger: hash.to_string(),
703                    member: referenced,
704                });
705            }
706        }
707
708        let (state, _) = self
709            .facade
710            .with_store(|m| m.trigger_state(hash))
711            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
712            .unwrap_or_default();
713
714        let mut out = Vec::new();
715        for (key, partial) in &state.partials {
716            if let Some(w) = trigger.window_ms {
717                if now - partial.started_ms > w {
718                    continue;
719                }
720            }
721            let fired: Vec<String> = partial.members.keys().cloned().collect();
722            if crate::predicate::gate_satisfied(&predicate, &fired) {
723                out.push(key.clone());
724            }
725        }
726        Ok(out)
727    }
728
729    /// Grains matching the predicate that appeared since the stored op cursor.
730    ///
731    /// Built on the op-log rather than a scan: `changes_since` is an indexed
732    /// primary-key range read with a monotonic exclusive cursor, so cost is
733    /// proportional to what changed rather than to how much the memory holds.
734    ///
735    /// Considered and rejected: `PRAGMA data_version` as a cheap "did anything
736    /// change" pre-check. It is documented as unchanged for commits made on the
737    /// same connection — and Areev is single-writer with one connection, so our
738    /// own writes are exactly what it cannot see.
739    fn scan_memory(
740        &self,
741        hash: &str,
742        trigger: &Trigger,
743        state: &TriggerState,
744        opts: &EvalOptions,
745    ) -> Result<(Vec<PollItem>, i64)> {
746        let predicate = trigger
747            .predicate
748            .as_ref()
749            .ok_or_else(|| TriggerError::Malformed {
750                what: format!("memory trigger {hash} has no predicate"),
751            })
752            .and_then(crate::predicate::from_value)?;
753
754        // First contact seeds at the head of the log and matches nothing.
755        // Otherwise declaring a memory trigger on an established memory fires
756        // once for every historical grain that matches — the same reason a
757        // polling trigger primes rather than replaying a mailbox.
758        let Some(after) = state.op_cursor else {
759            let head = self
760                .facade
761                .with_store(|m| m.head_op_seq())
762                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
763            return Ok((Vec::new(), head));
764        };
765        let ops = self
766            .facade
767            .with_store(|m| m.changes_since(after, opts.max_items))
768            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
769
770        let mut items = Vec::new();
771        let mut cursor = after;
772        for op in ops {
773            cursor = cursor.max(op.op_seq);
774            // Additions and supersessions both mean "this is true now".
775            // A tombstone means the grain is gone, so there is nothing to match.
776            if op.op == areev_store::OP_FORGET {
777                continue;
778            }
779            let Ok(grain) = self.facade.with_store(|m| m.get(&op.hash)) else {
780                // A grain that vanished between the op-log read and the fetch
781                // (erasure, retention) is not an error — it simply no longer
782                // matches anything.
783                continue;
784            };
785            if !crate::predicate::grain_matches(&grain, &predicate) {
786                continue;
787            }
788            let hex = op.hash.to_hex();
789            items.push(PollItem {
790                // The content address IS the identity: the same grain can never
791                // fire twice, and two evaluators seeing it agree without
792                // coordinating.
793                id: hex.clone(),
794                payload: serde_json::json!({
795                    "hash": hex,
796                    "grain_type": grain.grain_type.as_str(),
797                    "op_seq": op.op_seq,
798                }),
799            });
800        }
801        Ok((items, cursor))
802    }
803
804    fn poll(
805        &self,
806        hash: &str,
807        trigger: &Trigger,
808        state: &TriggerState,
809        opts: &EvalOptions,
810    ) -> Result<PollResponse> {
811        let connector_name = trigger.connector.as_deref().unwrap_or_default();
812        let Some(exec) = &self.connector else {
813            return Err(TriggerError::NoConnector {
814                trigger: hash.to_string(),
815                connector: connector_name.to_string(),
816            });
817        };
818        // Start a broker for this call, scoped to this trigger's allowlist. The
819        // connector is handed its URL — never a token — so a compromised
820        // connector has nothing to exfiltrate and nowhere undeclared to send
821        // it. The broker stops when this call returns.
822        let policy = areev_run::EgressPolicy::from_config(trigger.config.as_ref())
823            .map_err(|what| TriggerError::Malformed { what })?;
824        // One connector runs per pass, so there is no second caller to tell it
825        // apart from: a default grant covers it. Its methods are whatever the
826        // connector needs to poll, and the credentials are the ones this host
827        // configured — naming one it was not given still fails.
828        let grants = areev_run::EgressGrants::new().default_for_all(
829            self.credentials.keys().fold(
830                areev_run::CallerGrant::new()
831                    .method("GET")
832                    .method("POST")
833                    .method("PUT")
834                    .method("PATCH")
835                    .method("DELETE"),
836                |g, name| g.credential(name),
837            ),
838        );
839        let broker = areev_run::Broker::start(
840            policy,
841            self.credentials.clone(),
842            grants,
843            "TRG-E009",
844        )
845        .map_err(|detail| TriggerError::Storage { detail })?;
846
847        let request = PollRequest {
848            trigger: hash,
849            connector: connector_name,
850            scope: trigger.scope.as_deref(),
851            cursor: state.cursor.as_deref(),
852            max_items: opts.max_items,
853            config: trigger.config.as_ref(),
854        };
855        let payload = serde_json::to_value(&request).map_err(|e| TriggerError::ConnectorFailed {
856            trigger: hash.to_string(),
857            detail: format!("serialize request: {e}"),
858        })?;
859        // The idempotency key ties this call to this occurrence, so a connector
860        // that deduplicates on it sees a retry as a retry.
861        let idem = format!("{hash}:{}", state.fence);
862        // The connector reads AREEV_EGRESS_URL out of its environment. The
863        // spawn seam scrubs whatever `--passphrase-env` and `--token-env` name,
864        // so this is the only network affordance it has from us.
865        //
866        // The variable is PROCESS-GLOBAL, and `HostToolExecutor::execute` has no
867        // per-call environment, so two evaluators in one process would race:
868        // one could hand its broker's URL to the other's connector, pointing it
869        // at the wrong allowlist. The lock makes the set→spawn→unset window
870        // exclusive. Production runs one evaluator per process, so this
871        // serialises nothing that was parallel; it closes a hazard that only
872        // exists in-process (and that our own threaded tests could reach).
873        static EGRESS_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
874        let result = {
875            let _guard = EGRESS_ENV.lock().unwrap_or_else(|e| e.into_inner());
876            std::env::set_var("AREEV_EGRESS_URL", broker.url());
877            // The broker authenticates its callers: loopback is not an
878            // authorization, and without the token every call would 401.
879            if let Some(t) = broker.token_for(connector_name) {
880                std::env::set_var("AREEV_EGRESS_TOKEN", t);
881            }
882            let r = exec.execute(connector_name, hash, &payload, &idem);
883            std::env::remove_var("AREEV_EGRESS_URL");
884            std::env::remove_var("AREEV_EGRESS_TOKEN");
885            r
886        };
887
888        // Surface a refused destination as its own error rather than letting it
889        // read as a generic connector failure — a connector reaching somewhere
890        // undeclared is a different problem from one that crashed.
891        let refused = broker.refusals();
892        if !refused.is_empty() {
893            return Err(TriggerError::EgressRefused {
894                trigger: hash.to_string(),
895                host: refused
896                    .iter()
897                    .map(|r| format!("{} ({})", r.destination, r.reason))
898                    .collect::<Vec<_>>()
899                    .join(", "),
900            });
901        }
902        match result {
903            areev_run::ExecResult::Ok(v) => {
904                serde_json::from_value(v).map_err(|e| TriggerError::ConnectorFailed {
905                    trigger: hash.to_string(),
906                    detail: format!("response is not a poll response: {e}"),
907                })
908            }
909            areev_run::ExecResult::Err { detail, .. } => {
910                Err(TriggerError::ConnectorFailed { trigger: hash.to_string(), detail })
911            }
912        }
913    }
914
915    /// Record the item as an Event and start the bound workflow.
916    fn start_run(
917        &self,
918        trigger: &Trigger,
919        run_id: &str,
920        item: &PollItem,
921        trigger_hash: &str,
922    ) -> StartOutcome {
923        // Idempotency has to hold whether or not a runtime is wired in. With a
924        // starter, the runtime's duplicate-run-id refusal is the guard; without
925        // one, nothing else would stop the same item being re-ingested on every
926        // poll forever, because an Event's `created_at` differs per firing and
927        // so content addressing does not collapse them.
928        let seen = self
929            .facade
930            .with_store(|m| m.run_grains(&self.ns, run_id, 0, 1))
931            .map(|g| !g.is_empty())
932            .unwrap_or(false);
933        if seen {
934            return StartOutcome::Duplicate;
935        }
936
937        let mut event = Event::new(&item.payload.to_string())
938            .namespace(&self.ns)
939            .extra_field("trigger", serde_json::json!(trigger_hash))
940            .extra_field("run_id", serde_json::json!(run_id));
941        if let Some(c) = &trigger.connector {
942            event = event.extra_field("connector", serde_json::json!(c));
943        }
944        if let Err(e) = self.facade.with_store(|m| m.add(&event)) {
945            return StartOutcome::Failed(format!("ingest: {e}"));
946        }
947        let Some(starter) = &self.starter else {
948            // Ingest-only: the item is recorded, nothing is executed. Reported
949            // as ingested rather than as a run, because claiming a run started
950            // when none did would make the report lie.
951            return StartOutcome::Ingested;
952        };
953        let input = serde_json::json!({
954            "trigger": trigger_hash,
955            "connector": trigger.connector,
956            "scope": trigger.scope,
957            "item": item.payload,
958        });
959        match starter.start(&trigger.workflow, run_id, input) {
960            StartResult::Started => StartOutcome::Started,
961            StartResult::Duplicate => StartOutcome::Duplicate,
962            StartResult::Failed(why) => StartOutcome::Failed(why),
963        }
964    }
965
966    /// One Observation per firing: what fired, what it produced, what it
967    /// skipped. An unfired trigger is invisible otherwise.
968    fn journal(
969        &self,
970        hash: &str,
971        trigger: &Trigger,
972        outcome: &FireOutcome,
973        note: Option<&str>,
974        now: i64,
975    ) -> Result<()> {
976        let mut obs = Observation::new("areev:trigger", "trigger:firing")
977            .namespace(TRIGGER_NS)
978            .created_at(now)
979            .extra_field("trigger", serde_json::json!(hash))
980            .extra_field("kind", serde_json::json!(trigger.kind.as_str()))
981            .extra_field("workflow", serde_json::json!(trigger.workflow))
982            .extra_field("items", serde_json::json!(outcome.items))
983            .extra_field("runs_started", serde_json::json!(outcome.runs_started))
984            .extra_field("duplicates", serde_json::json!(outcome.duplicates));
985        // Without these, a firing where every item lacked the dedup key reads
986        // as "items 5, runs_started 0, duplicates 0" -- and that last zero
987        // actively misleads, saying the items were not skipped as duplicates
988        // without saying why they were skipped at all. Emitted only when
989        // non-zero, so an ordinary firing stays as small as it was.
990        if outcome.unidentifiable > 0 {
991            obs = obs.extra_field("unidentifiable", serde_json::json!(outcome.unidentifiable));
992        }
993        if outcome.ingested > 0 {
994            obs = obs.extra_field("ingested", serde_json::json!(outcome.ingested));
995        }
996        if !outcome.failures.is_empty() {
997            obs = obs.extra_field("failures", serde_json::json!(outcome.failures));
998        }
999        if outcome.seeded {
1000            obs = obs.extra_field("seeded", serde_json::json!(true));
1001        }
1002        if let Some(n) = note {
1003            obs = obs.extra_field("note", serde_json::json!(n));
1004        }
1005        self.facade
1006            .with_store(|m| m.add_if_novel(&obs))
1007            .map(|_| ())
1008            .map_err(|e| TriggerError::Storage { detail: e.to_string() })
1009    }
1010}
1011
1012/// Drop partial matches past their window.
1013///
1014/// Without this, a composite that never completes accumulates keys forever and
1015/// a stale half-match eventually pairs with unrelated work — the exact failure
1016/// Argo Events' reset cron exists to paper over.
1017fn prune_partials(state: &mut TriggerState, trigger: &Trigger, now: i64) {
1018    let Some(window) = trigger.window_ms else {
1019        return;
1020    };
1021    state.partials.retain(|_, p| now - p.started_ms <= window);
1022}
1023
1024#[derive(Debug, Default, Clone)]
1025struct FireOutcome {
1026    items: usize,
1027    runs_started: usize,
1028    ingested: usize,
1029    duplicates: usize,
1030    unidentifiable: usize,
1031    failures: Vec<String>,
1032    cursor: Option<String>,
1033    /// The payloads this firing produced, so a composite can pull its
1034    /// correlation value out of them.
1035    fired_items: Vec<serde_json::Value>,
1036    /// Correlation keys a composite consumed, cleared on release.
1037    settled_keys: Vec<String>,
1038    op_cursor: Option<i64>,
1039    draining: bool,
1040    seeded: bool,
1041    skipped_locked: bool,
1042}
1043
1044enum StartOutcome {
1045    Started,
1046    /// Recorded but not executed — no runtime is wired in.
1047    Ingested,
1048    Duplicate,
1049    Failed(String),
1050}
1051
1052/// The run id for one firing of one item.
1053///
1054/// Derived from `(trigger, connector, dedup value)`, so the same item seen
1055/// twice — connector replay, overlapping cursors, two nodes racing a lease
1056/// boundary — produces the same id. `areev run start` refuses a duplicate, so
1057/// the second attempt is a recorded skip rather than a second run. At-most-once
1058/// firing with no lease, no fencing token, and no clock assumption.
1059///
1060/// The connector is part of the identity because CloudEvents made that
1061/// normative for the same reason: `source` + `id` is unique, `id` alone is only
1062/// unique within a producer. Two connectors sharing an id space must not
1063/// collide.
1064pub fn run_id_for(trigger_hash: &str, connector: Option<&str>, dedup_value: &str) -> String {
1065    let mut h = Sha256::new();
1066    h.update(b"areev-trigger:v1");
1067    h.update([0x1f]);
1068    h.update(connector.unwrap_or_default().as_bytes());
1069    h.update([0x1f]);
1070    h.update(dedup_value.as_bytes());
1071    let digest = hex(&h.finalize());
1072    format!("{}-{}", short(trigger_hash, 8), short(&digest, 12))
1073}
1074
1075fn hex(bytes: &[u8]) -> String {
1076    bytes.iter().map(|b| format!("{b:02x}")).collect()
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081    use super::*;
1082
1083    #[test]
1084    fn a_run_id_is_stable_for_the_same_item() {
1085        let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1086        let b = run_id_for("abcdef1234", Some("gmail"), "m1");
1087        assert_eq!(a, b, "the same item must always mint the same id");
1088    }
1089
1090    #[test]
1091    fn different_items_get_different_ids() {
1092        let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1093        let b = run_id_for("abcdef1234", Some("gmail"), "m2");
1094        assert_ne!(a, b);
1095    }
1096
1097    #[test]
1098    fn the_connector_is_part_of_the_identity() {
1099        // CloudEvents' rule: `id` alone is unique only within a producer, so two
1100        // connectors with a shared id space must not collide.
1101        let a = run_id_for("abcdef1234", Some("gmail"), "1");
1102        let b = run_id_for("abcdef1234", Some("stripe"), "1");
1103        assert_ne!(a, b);
1104    }
1105
1106    #[test]
1107    fn the_id_names_its_trigger_for_a_human_reading_a_run_list() {
1108        let id = run_id_for("abcdef1234567890", Some("gmail"), "m1");
1109        assert!(id.starts_with("abcdef12"), "{id}");
1110        assert!(id.len() < 32, "short enough to read: {id}");
1111    }
1112
1113    #[test]
1114    fn a_short_trigger_hash_does_not_panic() {
1115        let id = run_id_for("ab", None, "x");
1116        assert!(id.starts_with("ab-"));
1117    }
1118}