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