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::collections::HashMap;
22use std::sync::Arc;
23
24use areev_cal::AreevFacade;
25use areev_core::error::{AreevError, Hash};
26use areev_core::types::{ContentRef, Event, Grain, GrainType, Observation, Trigger, TriggerKind};
27use areev_run::HostToolExecutor;
28use areev_store::TriggerState;
29use serde::Serialize;
30use sha2::{Digest, Sha256};
31
32use crate::clock::Clock;
33use crate::connector::{
34    dedup_value, rewrite_blob_refs, PollItem, PollRequest, PollResponse,
35    MAX_BLOB_BYTES_PER_ITEM, MAX_BLOB_BYTES_PER_RESPONSE,
36};
37use crate::error::{Result, TriggerError};
38use crate::schedule;
39
40/// Namespace for trigger firing records. Alongside the runtime's own
41/// `agent:harness`, so operational journalling stays out of user namespaces.
42pub const TRIGGER_NS: &str = "agent:triggers";
43
44/// How many triggers one invocation will look at. A backstop, not a policy.
45const MAX_TRIGGERS_SCANNED: usize = 4096;
46
47/// Knobs for one evaluation pass.
48#[derive(Debug, Clone)]
49pub struct EvalOptions {
50    /// Evaluate only this trigger (its content address).
51    pub only: Option<String>,
52    /// How long a claim is held before another evaluator may take over.
53    /// Deliberately generous: a lease shorter than the worst-case connector
54    /// runtime causes takeovers mid-poll, which costs duplicate API calls.
55    pub lease: std::time::Duration,
56    /// Cap on items requested per firing.
57    pub max_items: usize,
58    /// Report what would happen and touch nothing. The safe first command on a
59    /// new deployment.
60    pub dry_run: bool,
61    /// This evaluator's identity, recorded in the claim.
62    pub node: String,
63    /// Decoded-size budget for one item's connector blobs (#93). Overrun
64    /// refuses the whole poll (TRG-E011) rather than truncating.
65    pub max_blob_bytes_per_item: usize,
66    /// Decoded-size budget for one poll response's blobs (#93).
67    pub max_blob_bytes_per_response: usize,
68}
69
70impl Default for EvalOptions {
71    fn default() -> Self {
72        EvalOptions {
73            only: None,
74            lease: std::time::Duration::from_secs(300),
75            max_items: 100,
76            dry_run: false,
77            node: default_node_id(),
78            max_blob_bytes_per_item: MAX_BLOB_BYTES_PER_ITEM,
79            max_blob_bytes_per_response: MAX_BLOB_BYTES_PER_RESPONSE,
80        }
81    }
82}
83
84/// First `n` characters, safely — `&s[..n]` panics on a short string or a
85/// multi-byte boundary, and a message-formatting helper must not be what takes
86/// the process down. Declarations can arrive by bundle import from an
87/// implementation we did not write.
88fn short(s: &str, n: usize) -> &str {
89    match s.char_indices().nth(n) {
90        Some((idx, _)) => &s[..idx],
91        None => s,
92    }
93}
94
95/// A stable-ish identity for this evaluator. Host name plus pid: enough to tell
96/// two nodes apart in a claim, and to recognise our own stale claim after a
97/// restart.
98fn default_node_id() -> String {
99    let host = std::env::var("HOSTNAME")
100        .or_else(|_| std::env::var("COMPUTERNAME"))
101        .unwrap_or_else(|_| "node".into());
102    format!("{host}/{}", std::process::id())
103}
104
105/// What one evaluation pass did. Shaped like `loop run`'s report for symmetry.
106#[derive(Debug, Clone, Default, Serialize)]
107pub struct EvalReport {
108    pub claimed: usize,
109    pub skipped_not_due: usize,
110    pub skipped_locked: usize,
111    pub skipped_paused: usize,
112    pub items: usize,
113    pub duplicates: usize,
114    pub runs_started: usize,
115    /// Items recorded without starting a workflow, because no runtime is wired
116    /// in. Counted apart from `runs_started` so the report never claims a run
117    /// happened when none did.
118    pub ingested: usize,
119    /// Items the connector returned that carry no usable identity, so no run
120    /// could be minted for them. Reported rather than silently dropped: an item
121    /// nobody can name is a connector bug, and it should be visible as one.
122    pub unidentifiable: usize,
123    /// Declarations that cannot fire as written — a cron that does not parse, a
124    /// timezone this build refuses, a composite gate naming a member the
125    /// declaration does not carry.
126    ///
127    /// Counted APART from `skipped_not_due` on purpose. Folded in there, an
128    /// unusable trigger is indistinguishable from a healthy one waiting its
129    /// turn, which is the failure mode with no symptom: the work simply never
130    /// happens and every report looks green. The reason lands in `errors`.
131    pub unusable: usize,
132    /// Not fatal to the pass: one broken trigger must not stop the others.
133    pub errors: Vec<String>,
134    /// A connector reported a backlog, so this trigger is due again at once
135    /// rather than after its interval.
136    pub draining: Vec<String>,
137    /// Triggers whose cursor was deliberately left unmoved this pass because
138    /// at least one item in the firing failed to start a run (#129) — the
139    /// Event add failed, declared context was unavailable, or the run
140    /// itself refused. The same page is offered again next tick; whatever
141    /// already started comes back as a `duplicates` count rather than
142    /// double-processing, which is what makes re-polling safe — see #128's
143    /// stable run ids, a prerequisite for this.
144    pub cursor_held: Vec<String>,
145}
146
147/// One trigger considered, for `--dry-run` and `status`.
148#[derive(Debug, Clone, Serialize)]
149pub struct TriggerStatus {
150    pub trigger: String,
151    /// The declaration's `name`, when it carries one.
152    ///
153    /// `name` is what a human uses to identify a trigger, and it is accepted
154    /// on write — but nothing read it back (#73), so identity fell onto the
155    /// workflow hash, which is stable only until the plan is re-declared. It
156    /// rides in `extra_fields` rather than being a typed field: OMS §A.7 has
157    /// no Trigger `name`, and inventing one would be a spec-level decision
158    /// for a label.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub name: Option<String>,
161    pub kind: String,
162    pub workflow: String,
163    pub enabled: bool,
164    pub paused: bool,
165    pub due: bool,
166    pub leased_by: Option<String>,
167    pub next_due_at: Option<i64>,
168    pub last_fired_at: Option<i64>,
169    pub consecutive_failures: u32,
170    pub last_error: Option<String>,
171    /// Will never fire again — a one-shot past its instant.
172    pub exhausted: bool,
173    /// Never fired and no failure recorded — the state an unnoticed
174    /// misconfiguration sits in, so it is reported rather than inferred.
175    pub never_fired: bool,
176    /// Why this declaration can never fire, if it cannot.
177    ///
178    /// A trigger can become unusable without ever being written through a
179    /// validating path — it can arrive by bundle import from an implementation
180    /// that validated differently, or predate the check. So the evaluator
181    /// reports it rather than assuming the write path caught everything.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub unusable: Option<String>,
184    /// The connector's last reported position, for a trigger that has ever
185    /// polled. Opaque and connector-defined (#128) — surfaced so an operator
186    /// can SEE the state that a re-pointed trigger's evaluation carries
187    /// forward, which before this was invisible even though nothing else
188    /// could carry it by hand.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub cursor: Option<String>,
191    /// The op-log position, a `memory` trigger's cursor equivalent.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub op_cursor: Option<i64>,
194    /// The hash evaluation state is actually keyed under: `trg:<chain_root>`
195    /// (#128). Equal to `trigger` for a trigger that has never been
196    /// superseded — the common case, unchanged from before this field
197    /// existed. Differs only for a trigger re-pointed by `SUPERSEDE`, where
198    /// it names the ORIGINAL declaration's hash — the identity the cursor
199    /// and dedup fence actually survive under.
200    pub chain_root: String,
201}
202
203/// The declaration's human label, if it has one.
204///
205/// Written through `extra_fields` by every surface that accepts `name` on a
206/// trigger, and read back here so `list` and `status` can print it (#73).
207/// Blank is treated as absent: a name that renders as nothing is worse than
208/// none, because it displaces the hash a reader could otherwise have used.
209pub fn trigger_name(trigger: &Trigger) -> Option<String> {
210    trigger
211        .common
212        .extra_fields
213        .get("name")
214        .and_then(|v| v.as_str())
215        .map(str::trim)
216        .filter(|s| !s.is_empty())
217        .map(str::to_string)
218}
219
220/// Starts the workflow a trigger is bound to.
221///
222/// A seam rather than a direct `areev_run::Runner` call, for the same reason
223/// the CAL executor takes a governance host: the runtime sits above this crate
224/// in the dependency order for hosts that want it, and a test needs to observe
225/// what *would* be started without spending a real run. It is also where the
226/// duplicate rule lives, which is the whole idempotency story.
227pub trait RunStarter: Send + Sync {
228    fn start(&self, workflow: &str, run_id: &str, input: serde_json::Value) -> StartResult;
229}
230
231/// What starting a run did.
232#[derive(Debug, Clone, PartialEq)]
233pub enum StartResult {
234    Started,
235    /// A run with this id already exists. **Not an error** — it is the
236    /// idempotency guarantee working: this item was already handled, by an
237    /// earlier firing or by another node that won the race.
238    Duplicate,
239    Failed(String),
240}
241
242/// Evaluates triggers against one memory.
243pub struct Evaluator {
244    pub facade: Arc<AreevFacade>,
245    pub clock: Arc<dyn Clock>,
246    /// Executes connector commands. `None` means a due polling trigger fails
247    /// loudly with `TRG-E003` rather than quietly doing nothing — a poll that
248    /// silently returns no items is indistinguishable from a healthy source.
249    pub connector: Option<Arc<dyn HostToolExecutor>>,
250    /// Starts the bound workflow. `None` ingests the item and records the
251    /// firing without starting anything — what `--no-start` is for, and what
252    /// the ingest-only tests use.
253    pub starter: Option<Arc<dyn RunStarter>>,
254    /// Credentials the broker may attach, by name. Values live here and never
255    /// in a grain or a connector's environment: a declaration names a
256    /// credential, it never carries one.
257    /// A SOURCE per name, not a resolved value (#113): the heartbeat is the
258    /// path that most needs short-lived credentials, because it is the one
259    /// nobody is watching. An env-var source resolves once, as before; a
260    /// `cmd:`/`vault:` source is minted per TTL window inside the broker, so a
261    /// cadence that outlives a token's hour does not start 401'ing overnight.
262    pub credentials: std::collections::BTreeMap<String, areev_run::CredentialSource>,
263    pub ns: String,
264    pub principal: String,
265}
266
267impl Evaluator {
268    /// An evaluator that can inspect but not act: no connector, no runtime, no
269    /// credentials. What `list`, `show` and `status` want, and the shape that
270    /// makes "reading cannot fire anything" true by construction rather than by
271    /// remembering to pass `None` four times.
272    pub fn read_only(facade: Arc<AreevFacade>, clock: Arc<dyn Clock>, ns: &str) -> Evaluator {
273        Evaluator {
274            facade,
275            clock,
276            connector: None,
277            starter: None,
278            credentials: Default::default(),
279            ns: ns.to_string(),
280            principal: "user:local".into(),
281        }
282    }
283
284    /// Every trigger declaration in the memory, newest first.
285    pub fn declarations(&self) -> Result<Vec<(String, Trigger)>> {
286        let ns = self.ns.clone();
287        let grains = self
288            .facade
289            .with_store(|m| {
290                m.recent_live_scoped(&[ns], Some(GrainType::Trigger), MAX_TRIGGERS_SCANNED)
291            })
292            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
293
294        let mut out = Vec::new();
295        for g in grains {
296            let hash = g.hash.to_hex();
297            match g.to_trigger() {
298                Ok(mut t) => {
299                    // `to_trigger` reconstructs the typed fields; `extra_fields`
300                    // is not restored for any grain type, so the declaration's
301                    // `name` — an extra field by design, since OMS §A.7 has no
302                    // Trigger `name` — has to be carried over here. Doing it in
303                    // the shared deserializer would silently change what every
304                    // read-modify-write re-serializes, which is the kind of
305                    // change that moves content addresses.
306                    if let Some(name) = g.get_str("name") {
307                        t.common
308                            .extra_fields
309                            .insert("name".into(), serde_json::json!(name));
310                    }
311                    out.push((hash, t))
312                }
313                // A declaration this build cannot read is reported, never
314                // skipped silently: silence is the symptom of every trigger
315                // failure, so it must never also be the symptom of a bug.
316                Err(e) => {
317                    return Err(TriggerError::Malformed {
318                        what: format!("declaration {hash} is unreadable: {e}"),
319                    })
320                }
321            }
322        }
323        Ok(out)
324    }
325
326    /// What every trigger's state looks like right now.
327    pub fn status(&self) -> Result<Vec<TriggerStatus>> {
328        let now = self.clock.now_ms();
329        let mut out = Vec::new();
330        // One cache for this call, same as a `run()` pass — a chain is
331        // walked at most once even though `declarations()` above may list
332        // the same workflow's triggers repeatedly.
333        let mut chains: HashMap<String, Chain> = HashMap::new();
334        for (hash, t) in self.declarations()? {
335            let chain = self.resolve_chain(&hash, &mut chains)?;
336            // Read-only: `adopt = false`. `trigger status`/`show` must not
337            // have the side effect of migrating a legacy state row — a real
338            // evaluation pass (`run`/`deliver`) does that; this only
339            // reports whatever it finds, root row or legacy.
340            let (st, _raw) = self.resolve_state(&chain, false)?;
341            let unusable = schedule::validate(&t).err().map(|e| e.to_string());
342            out.push(TriggerStatus {
343                trigger: hash,
344                name: trigger_name(&t),
345                kind: t.kind.as_str().to_string(),
346                workflow: t.workflow.clone(),
347                enabled: t.enabled,
348                paused: st.paused,
349                // An unusable declaration is never due. Saying otherwise would
350                // promise a firing that cannot happen.
351                due: unusable.is_none()
352                    && t.enabled
353                    && !st.leased(now)
354                    && schedule::is_due(&t, &st, now).unwrap_or(false),
355                leased_by: st.leased(now).then(|| st.claimed_by.clone()).flatten(),
356                next_due_at: st.next_due_at,
357                last_fired_at: st.last_fired_at,
358                consecutive_failures: st.consecutive_failures,
359                last_error: st.last_error.clone(),
360                exhausted: st.exhausted,
361                never_fired: st.last_fired_at.is_none() && st.consecutive_failures == 0,
362                unusable,
363                cursor: st.cursor.clone(),
364                op_cursor: st.op_cursor,
365                chain_root: chain.root().to_string(),
366            });
367        }
368        Ok(out)
369    }
370
371    /// Ingest a payload delivered by the host for a `webhook` or `manual`
372    /// trigger.
373    ///
374    /// Areev never opens a port. The host already terminates TLS and
375    /// authenticates the sender — it is far better at both than a memory engine
376    /// would be — and hands the payload to a one-shot invocation. That keeps the
377    /// useful half of webhooks without a listening daemon.
378    ///
379    /// Idempotent on the same terms as a poll: the payload's dedup value mints
380    /// the run id, so a webhook delivered twice (which every provider does)
381    /// produces one run and one recorded skip.
382    pub fn deliver(&self, trigger_hash: &str, payload: serde_json::Value) -> Result<EvalReport> {
383        let now = self.clock.now_ms();
384        let (hash, trigger) = self
385            .declarations()?
386            .into_iter()
387            .find(|(h, _)| h.starts_with(trigger_hash))
388            .ok_or_else(|| TriggerError::Malformed {
389                what: format!("no trigger matching '{trigger_hash}'"),
390            })?;
391
392        if !matches!(trigger.kind, TriggerKind::Webhook | TriggerKind::Manual) {
393            return Err(TriggerError::Malformed {
394                what: format!(
395                    "trigger {} is a {} trigger — only webhook and manual triggers accept a \
396                     delivery; a clocked or polling trigger fires from `trigger run`",
397                    short(&hash, 12),
398                    trigger.kind.as_str()
399                ),
400            });
401        }
402        if !trigger.enabled {
403            return Err(TriggerError::Malformed {
404                what: format!("trigger {} is disabled", short(&hash, 12)),
405            });
406        }
407        // #128 — a single-trigger cache: `deliver` fires exactly one
408        // trigger, so this walks its chain at most once regardless of how
409        // many places below need the root.
410        let mut chains: HashMap<String, Chain> = HashMap::new();
411        let chain = self.resolve_chain(&hash, &mut chains)?;
412        let (state, _raw) = self.resolve_state(&chain, true)?;
413        if state.paused {
414            return Err(TriggerError::Malformed {
415                what: format!("trigger {} is paused", short(&hash, 12)),
416            });
417        }
418
419        let mut report = EvalReport::default();
420        let item = PollItem { id: String::new(), payload, blobs: Vec::new() };
421        report.items = 1;
422        let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
423            report.unidentifiable = 1;
424            // Journal before returning. A delivery naming nothing is precisely
425            // the case the record exists for: the sender's payload shape
426            // changed under us, and stdout of whatever invoked this is not an
427            // audit record -- the replicating Observation is.
428            self.journal(
429                &hash,
430                &trigger,
431                &FireOutcome { items: 1, unidentifiable: 1, ..Default::default() },
432                Some("delivered"),
433                now,
434            )?;
435            return Ok(report);
436        };
437        // Keyed on the chain ROOT (#128), so a delivery replayed after the
438        // trigger has been re-pointed by `SUPERSEDE` still mints the same id
439        // it always would have. `legacy_run_ids` covers a trigger already
440        // superseded before this fix — the id it would have minted under
441        // its old (then-current) head.
442        let root = chain.root();
443        let run_id = run_id_for(root, trigger.connector.as_deref(), &value);
444        let legacy_run_ids: Vec<String> = chain
445            .legacy()
446            .iter()
447            .map(|h| run_id_for(h, trigger.connector.as_deref(), &value))
448            .collect();
449        match self.start_run(&trigger, &run_id, &legacy_run_ids, &item, &hash, &[]) {
450            StartOutcome::Started => report.runs_started = 1,
451            StartOutcome::Ingested => report.ingested = 1,
452            StartOutcome::Duplicate => report.duplicates = 1,
453            StartOutcome::Failed(why) => report.errors.push(why),
454        }
455
456        let outcome = FireOutcome {
457            items: 1,
458            runs_started: report.runs_started,
459            duplicates: report.duplicates,
460            ..Default::default()
461        };
462        self.journal(&hash, &trigger, &outcome, Some("delivered"), now)?;
463        Ok(report)
464    }
465
466    /// Run one evaluation pass.
467    pub fn run(&self, opts: &EvalOptions) -> Result<EvalReport> {
468        let mut report = EvalReport::default();
469        let now = self.clock.now_ms();
470        // #128 — resolved once per trigger per PASS, never once per item:
471        // every chain walk below goes through this cache, shared with
472        // `record_for_composites` (a composite may be touched by several
473        // members firing in the same pass).
474        let mut chains: HashMap<String, Chain> = HashMap::new();
475
476        let declarations = self.declarations()?;
477        // Composites are settled after their members, so a gate can be
478        // satisfied in the same pass that completes it rather than waiting a
479        // whole heartbeat. Declaration order is newest-first and says nothing
480        // about dependency, so the split is explicit.
481        let (composites, members): (Vec<_>, Vec<_>) =
482            declarations.into_iter().partition(|(_, t)| t.kind == TriggerKind::Composite);
483
484        for (hash, trigger) in members.into_iter().chain(composites) {
485            if opts.only.as_ref().is_some_and(|only| only != &hash) {
486                continue;
487            }
488            if !trigger.enabled {
489                report.skipped_not_due += 1;
490                continue;
491            }
492            // Checked BEFORE dueness, so an unusable declaration is reported as
493            // what it is rather than disappearing into `not due` — where it
494            // looks exactly like a healthy trigger waiting its turn.
495            if let Err(why) = schedule::validate(&trigger) {
496                report.unusable += 1;
497                report.errors.push(format!("{}: {why}", short(&hash, 12)));
498                continue;
499            }
500            // #128 — every read/write of this trigger's evaluation state
501            // below is keyed on the chain ROOT, not on `hash` (the
502            // declaration's current head): a trigger re-pointed by
503            // `SUPERSEDE` mints a new head for the same standing rule, and
504            // keying state on the head alone would orphan the cursor and
505            // the dedup fence on every applied recommendation. For a
506            // never-superseded trigger root == hash, so this resolves to
507            // exactly the lookup this crate always did — a byte-identical
508            // no-op (pinned by `a_never_superseded_trigger_keys_state_on_its_own_hash`).
509            // `adopt = true`: this is a real evaluation pass with write
510            // access, so a legacy (pre-#128, head-keyed) row found along the
511            // way is migrated under the root.
512            let chain = self.resolve_chain(&hash, &mut chains)?;
513            let (state, raw) = self.resolve_state(&chain, true)?;
514
515            if state.paused {
516                report.skipped_paused += 1;
517                continue;
518            }
519            // An absolute schedule seen for the first time: record when it
520            // will be due rather than re-deriving it every pass, so `trigger
521            // status` can say when it next fires and the comparison afterwards
522            // is a plain timestamp check.
523            if state.next_due_at.is_none() && !state.exhausted {
524                if let Some(first) = schedule::initial_due(&trigger, now)? {
525                    let mut seeded = state.clone();
526                    seeded.next_due_at = Some(first);
527                    let _ = self
528                        .facade
529                        .with_store(|m| m.put_trigger_state(chain.root(), raw.as_deref(), &seeded))
530                        .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
531                    report.skipped_not_due += 1;
532                    continue;
533                }
534            }
535            if !schedule::is_due(&trigger, &state, now)? {
536                report.skipped_not_due += 1;
537                continue;
538            }
539            if state.leased(now) {
540                // The normal steady state with several nodes on one heartbeat,
541                // not a problem.
542                report.skipped_locked += 1;
543                continue;
544            }
545            if opts.dry_run {
546                report.claimed += 1;
547                continue;
548            }
549
550            match self.fire(&hash, &chain, &trigger, state, raw.as_deref(), now, opts) {
551                // Lost the claim between deciding it was free and taking it.
552                // Normal with several nodes on one heartbeat, and it must not
553                // be counted as work done.
554                Ok(outcome) if outcome.skipped_locked => report.skipped_locked += 1,
555                Ok(outcome) => {
556                    if !outcome.fired_items.is_empty() {
557                        if let Err(e) =
558                            self.record_for_composites(&hash, &outcome.fired_items, now, &mut chains)
559                        {
560                            report.errors.push(e.to_string());
561                        }
562                    }
563                    report.claimed += 1;
564                    report.items += outcome.items;
565                    report.duplicates += outcome.duplicates;
566                    report.runs_started += outcome.runs_started;
567                    report.ingested += outcome.ingested;
568                    report.unidentifiable += outcome.unidentifiable;
569                    for why in &outcome.failures {
570                        report.errors.push(format!("{hash}: {why}"));
571                    }
572                    // #129 — any start failure in this firing means the
573                    // cursor was deliberately left unmoved (see `fire`'s
574                    // release logic); surfaced apart from `errors` so an
575                    // operator sees WHY the same items come back next tick
576                    // rather than inferring it.
577                    if !outcome.failures.is_empty() {
578                        report.cursor_held.push(hash.clone());
579                    }
580                    if outcome.draining {
581                        report.draining.push(hash.clone());
582                    }
583                }
584                Err(e) => {
585                    // One broken trigger must not stop the others.
586                    report.errors.push(e.to_string());
587                }
588            }
589        }
590        Ok(report)
591    }
592
593    // `chain` (#128) is one more independent knob on an already knob-heavy
594    // internal call, in the same spirit as the other `too_many_arguments`
595    // allows in this codebase (areev-store's tuned-recall internals, the
596    // bindings' flat FFI surfaces) — splitting it into a struct would not
597    // make any single call site more readable.
598    #[allow(clippy::too_many_arguments)]
599    fn fire(
600        &self,
601        hash: &str,
602        chain: &Chain,
603        trigger: &Trigger,
604        state: TriggerState,
605        raw: Option<&str>,
606        now: i64,
607        opts: &EvalOptions,
608    ) -> Result<FireOutcome> {
609        // #128 — the claim/release CAS below is keyed on the chain ROOT, not
610        // `hash`. This is the hazard the fix has to get right: `fire` claims
611        // with one key and releases with the same key, so if the two ever
612        // disagreed about which key that is, the release's compare-and-swap
613        // would find no row, `released_ok` would be false, and every firing
614        // of a superseded trigger would permanently report `ClaimLost` — a
615        // trigger that could never usefully claim again. `root` is resolved
616        // exactly once here and reused for every claim/release call in this
617        // function.
618        let root = chain.root();
619
620        // Claim. Losing means another evaluator got here first.
621        let mut claimed = state.clone();
622        claimed.fence = claimed.fence.wrapping_add(1);
623        claimed.claimed_by = Some(opts.node.clone());
624        claimed.lease_until = Some(now + opts.lease.as_millis() as i64);
625        let won = self
626            .facade
627            .with_store(|m| m.put_trigger_state(root, raw, &claimed))
628            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
629        if !won {
630            return Ok(FireOutcome { skipped_locked: true, ..Default::default() });
631        }
632        // What a later release must present as `expected`.
633        let claimed_raw = self
634            .facade
635            .with_store(|m| m.trigger_state(root))
636            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
637            .map(|(_, r)| r);
638
639        let result = self.collect_and_start(hash, chain, trigger, &claimed, now, opts);
640
641        // Release under the fence, whatever happened.
642        let mut released = claimed.clone();
643        released.claimed_by = None;
644        released.lease_until = None;
645        match &result {
646            // Full success: every item that resolved an identity started (or
647            // was ingested, or was recognized as a duplicate) cleanly.
648            Ok(outcome) if outcome.failures.is_empty() => {
649                released.last_fired_at = Some(now);
650                released.consecutive_failures = 0;
651                released.last_error = None;
652                if let Some(c) = &outcome.cursor {
653                    released.cursor = Some(c.clone());
654                }
655                if let Some(c) = outcome.op_cursor {
656                    released.op_cursor = Some(c);
657                }
658                // A satisfied gate consumes its correlation key. Leaving it
659                // would re-fire the same set on every pass; Flink calls this
660                // the after-match skip strategy, and the conservative choice —
661                // clear only the key that fired — is the one that cannot
662                // swallow an unrelated in-flight match.
663                for k in &outcome.settled_keys {
664                    released.partials.remove(k);
665                }
666                prune_partials(&mut released, trigger, now);
667                released.exhausted = schedule::exhausts_after(trigger, now);
668                released.next_due_at = if outcome.draining {
669                    // A backlog drains at once rather than waiting out the
670                    // interval — a cold start should not take a day.
671                    Some(now)
672                } else {
673                    schedule::advance_after_firing(trigger, now, now)?
674                };
675            }
676            // #129 — at least one item failed to start a run: the Event add
677            // failed, declared context was unavailable, or the run itself
678            // refused (RUN-E018 and friends). All three mean the item was
679            // NOT processed, so this firing must not report itself clean —
680            // that is the opposite posture from the Err arm below, which
681            // this deliberately mirrors:
682            //   - the cursor / op_cursor are left at `claimed`'s values
683            //     (never assigned from `outcome`), so the next pass re-asks
684            //     the connector for the same page instead of skipping past
685            //     unprocessed work;
686            //   - `exhausted` is left at `claimed.exhausted` (false, since a
687            //     due trigger cannot already be exhausted) rather than
688            //     recomputed — a `once` trigger that fails must be allowed
689            //     to retry, not be marked as having had its one shot;
690            //   - `settled_keys` are NOT removed from `partials` — a
691            //     satisfied composite gate whose run refused must stay
692            //     satisfied so it retries, not lose its correlation.
693            // Re-polling the same page is safe BECAUSE run ids are keyed on
694            // the chain root (#128): whatever already started is protected
695            // by `start_run`'s dedup fence and comes back as `Duplicate`,
696            // never a second run. What DID happen is still real —
697            // `runs_started`/`ingested`/`duplicates` in the outcome are not
698            // discarded, only the durable cursor advance is withheld.
699            Ok(outcome) => {
700                released.last_fired_at = Some(now);
701                released.consecutive_failures = claimed.consecutive_failures.saturating_add(1);
702                released.last_error = Some(summarize_failures(&outcome.failures));
703                released.next_due_at =
704                    Some(schedule::backoff_until(trigger, released.consecutive_failures, now));
705            }
706            Err(e) => {
707                released.consecutive_failures = claimed.consecutive_failures.saturating_add(1);
708                released.last_error = Some(e.to_string());
709                released.next_due_at =
710                    Some(schedule::backoff_until(trigger, released.consecutive_failures, now));
711            }
712        }
713        let released_ok = self
714            .facade
715            .with_store(|m| m.put_trigger_state(root, claimed_raw.as_deref(), &released))
716            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
717        if !released_ok {
718            // Our lease expired mid-firing and someone else took over. Their
719            // state stands; ours is refused. The work we did is not lost — the
720            // runs we started are journaled and idempotent — but we must not
721            // write back over the new owner.
722            self.journal(hash, trigger, &FireOutcome::default(), Some("claim lost"), now)?;
723            return Err(TriggerError::ClaimLost { trigger: hash.to_string() });
724        }
725
726        let outcome = result?;
727        self.journal(hash, trigger, &outcome, None, now)?;
728        Ok(outcome)
729    }
730
731    /// Get the items for this firing and start a run per item.
732    fn collect_and_start(
733        &self,
734        hash: &str,
735        chain: &Chain,
736        trigger: &Trigger,
737        state: &TriggerState,
738        now: i64,
739        opts: &EvalOptions,
740    ) -> Result<FireOutcome> {
741        let mut outcome = FireOutcome::default();
742
743        let items: Vec<PollItem> = match trigger.kind {
744            TriggerKind::Polling => {
745                let response = self.poll(hash, trigger, state, opts)?;
746                outcome.cursor = response.cursor.clone();
747                outcome.draining = response.more;
748                // First contact seeds the cursor and fires nothing. Otherwise
749                // declaring a mailbox trigger starts a run for every message in
750                // history — Zapier's priming poll, and for the same reason.
751                if state.cursor.is_none() {
752                    outcome.seeded = true;
753                    return Ok(outcome);
754                }
755                response.items
756            }
757            // A clocked trigger with no external source fires once per
758            // occurrence, carrying the occurrence itself as the item.
759            TriggerKind::Interval | TriggerKind::Schedule | TriggerKind::Once => {
760                vec![PollItem {
761                    id: now.to_string(),
762                    payload: serde_json::json!({ "at_ms": now }),
763                    blobs: Vec::new(),
764                }]
765            }
766            TriggerKind::Memory => {
767                // Recorded so the journal says "seeded" rather than leaving a
768                // firing that produced nothing looking like a quiet memory.
769                outcome.seeded = state.op_cursor.is_none();
770                let (found, cursor) = self.scan_memory(hash, trigger, state, opts)?;
771                outcome.op_cursor = Some(cursor);
772                found
773            }
774            TriggerKind::Composite => {
775                let keys = self.satisfied_keys(hash, chain, trigger, now)?;
776                outcome.settled_keys = keys.clone();
777                keys.into_iter()
778                    .map(|k| PollItem {
779                        // The correlation value IS the identity: one firing per
780                        // correlated set, however many members contributed.
781                        id: k.clone(),
782                        payload: serde_json::json!({ "correlation": k }),
783                        blobs: Vec::new(),
784                    })
785                    .collect()
786            }
787            // These do not poll. The host owns the listener and hands the
788            // payload to `deliver`, which fires them on the same idempotency
789            // terms; an evaluation pass simply has nothing to do for one.
790            TriggerKind::Webhook | TriggerKind::Manual => Vec::new(),
791        };
792
793        // #93 — store connector blobs in the CAS and rewrite `"blob": "@N"`
794        // payload references to their addresses BEFORE identity resolution,
795        // so a `--dedup-key` pointer into a rewritten field sees the stable
796        // content address rather than a positional index. A contract
797        // violation fails the whole poll here (TRG-E011): fire() then leaves
798        // the cursor unmoved, so nothing is lost — the connector gets fixed
799        // and the same page is re-polled.
800        let mut items = items;
801        let item_refs = self.ingest_item_blobs(hash, &mut items, opts)?;
802
803        outcome.items = items.len();
804        for (item, refs) in items.into_iter().zip(item_refs) {
805            let Some(value) = dedup_value(&item, &trigger.dedup_key) else {
806                outcome.unidentifiable += 1;
807                continue;
808            };
809            outcome.fired_items.push(item.payload.clone());
810            // Keyed on the chain ROOT (#128): the same item seen again after
811            // this trigger has been re-pointed by `SUPERSEDE` still mints
812            // the id it always would have, so `start_run`'s dedup fence
813            // still recognizes it. `legacy_run_ids` is the compatibility
814            // path for a trigger already superseded BEFORE this fix — the
815            // id(s) it would have minted under an older head — and is empty
816            // (so free) for the common never-superseded case.
817            let root = chain.root();
818            let run_id = run_id_for(root, trigger.connector.as_deref(), &value);
819            let legacy_run_ids: Vec<String> = chain
820                .legacy()
821                .iter()
822                .map(|h| run_id_for(h, trigger.connector.as_deref(), &value))
823                .collect();
824            match self.start_run(trigger, &run_id, &legacy_run_ids, &item, hash, &refs) {
825                StartOutcome::Started => outcome.runs_started += 1,
826                StartOutcome::Ingested => outcome.ingested += 1,
827                StartOutcome::Duplicate => outcome.duplicates += 1,
828                StartOutcome::Failed(why) => outcome.failures.push(why),
829            }
830        }
831        Ok(outcome)
832    }
833
834    /// Note a member firing against every composite that declares it.
835    ///
836    /// The correlation value comes from the composite's own `correlate`
837    /// pointer applied to the member's item — so two composites may correlate
838    /// the same member on different fields, which is why this is resolved per
839    /// composite rather than once per member.
840    fn record_for_composites(
841        &self,
842        member: &str,
843        items: &[serde_json::Value],
844        now: i64,
845        chains: &mut HashMap<String, Chain>,
846    ) -> Result<()> {
847        for (chash, composite) in self.declarations()? {
848            let Some(alias) = composite.member_alias(member).map(str::to_string) else {
849                continue;
850            };
851            if composite.kind != TriggerKind::Composite {
852                continue;
853            }
854            // #128 — the composite's OWN chain, not the member's: state
855            // lives under the composite's root regardless of which member
856            // just fired. Shares the pass-wide cache, so a composite with
857            // several members touched this pass walks its chain once.
858            let chain = self.resolve_chain(&chash, chains)?;
859            let (mut state, raw) = self.resolve_state(&chain, true)?;
860
861            for item in items {
862                // No correlate pointer means one global match — every member
863                // firing counts toward the same gate.
864                let key = match &composite.correlate {
865                    Some(ptr) => match item.pointer(ptr) {
866                        Some(serde_json::Value::String(s)) => s.clone(),
867                        Some(other) => other.to_string(),
868                        // An item that cannot be correlated cannot join a
869                        // correlated gate. Dropping it is right: attributing it
870                        // to an arbitrary key would pair unrelated work.
871                        None => continue,
872                    },
873                    None => String::new(),
874                };
875                let entry = state.partials.entry(key).or_insert_with(|| {
876                    areev_store::PartialMatch { members: Default::default(), started_ms: now }
877                });
878                // Recorded under the ALIAS, because that is what the gate
879                // expression names.
880                entry.members.insert(alias.clone(), now);
881            }
882            prune_partials(&mut state, &composite, now);
883            self.facade
884                .with_store(|m| m.put_trigger_state(chain.root(), raw.as_deref(), &state))
885                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
886        }
887        Ok(())
888    }
889
890    /// Which correlation keys currently satisfy this composite's gate.
891    fn satisfied_keys(
892        &self,
893        hash: &str,
894        chain: &Chain,
895        trigger: &Trigger,
896        now: i64,
897    ) -> Result<Vec<String>> {
898        let predicate = trigger
899            .predicate
900            .as_ref()
901            .ok_or_else(|| TriggerError::Malformed {
902                what: format!("composite trigger {hash} has no predicate"),
903            })
904            .and_then(crate::predicate::from_value)?;
905
906        // Refuse a gate that names a member the declaration does not carry: it
907        // could never be satisfied, so the trigger would be silently dead.
908        for referenced in crate::predicate::referenced_members(&predicate) {
909            if !trigger.members.contains_key(&referenced) {
910                return Err(TriggerError::UnknownMember {
911                    trigger: hash.to_string(),
912                    member: referenced,
913                });
914            }
915        }
916
917        // #128 — this composite's own partials live under its chain root.
918        // No adoption dance needed here: by the time `fire` reaches
919        // `collect_and_start` for this trigger, `run()`'s loop has already
920        // resolved (and, if needed, adopted) this exact root's state row.
921        let (state, _) = self
922            .facade
923            .with_store(|m| m.trigger_state(chain.root()))
924            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
925            .unwrap_or_default();
926
927        let mut out = Vec::new();
928        for (key, partial) in &state.partials {
929            if let Some(w) = trigger.window_ms {
930                if now - partial.started_ms > w {
931                    continue;
932                }
933            }
934            let fired: Vec<String> = partial.members.keys().cloned().collect();
935            if crate::predicate::gate_satisfied(&predicate, &fired) {
936                out.push(key.clone());
937            }
938        }
939        Ok(out)
940    }
941
942    /// Grains matching the predicate that appeared since the stored op cursor.
943    ///
944    /// Built on the op-log rather than a scan: `changes_since` is an indexed
945    /// primary-key range read with a monotonic exclusive cursor, so cost is
946    /// proportional to what changed rather than to how much the memory holds.
947    ///
948    /// Considered and rejected: `PRAGMA data_version` as a cheap "did anything
949    /// change" pre-check. It is documented as unchanged for commits made on the
950    /// same connection — and Areev is single-writer with one connection, so our
951    /// own writes are exactly what it cannot see.
952    fn scan_memory(
953        &self,
954        hash: &str,
955        trigger: &Trigger,
956        state: &TriggerState,
957        opts: &EvalOptions,
958    ) -> Result<(Vec<PollItem>, i64)> {
959        let predicate = trigger
960            .predicate
961            .as_ref()
962            .ok_or_else(|| TriggerError::Malformed {
963                what: format!("memory trigger {hash} has no predicate"),
964            })
965            .and_then(crate::predicate::from_value)?;
966
967        // First contact seeds at the head of the log and matches nothing.
968        // Otherwise declaring a memory trigger on an established memory fires
969        // once for every historical grain that matches — the same reason a
970        // polling trigger primes rather than replaying a mailbox.
971        let Some(after) = state.op_cursor else {
972            let head = self
973                .facade
974                .with_store(|m| m.head_op_seq())
975                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
976            return Ok((Vec::new(), head));
977        };
978        let ops = self
979            .facade
980            .with_store(|m| m.changes_since(after, opts.max_items))
981            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
982
983        let mut items = Vec::new();
984        let mut cursor = after;
985        for op in ops {
986            cursor = cursor.max(op.op_seq);
987            // Additions and supersessions both mean "this is true now".
988            // A tombstone means the grain is gone, so there is nothing to match.
989            if op.op == areev_store::OP_FORGET {
990                continue;
991            }
992            let Ok(grain) = self.facade.with_store(|m| m.get(&op.hash)) else {
993                // A grain that vanished between the op-log read and the fetch
994                // (erasure, retention) is not an error — it simply no longer
995                // matches anything.
996                continue;
997            };
998            if !crate::predicate::grain_matches(&grain, &predicate) {
999                continue;
1000            }
1001            let hex = op.hash.to_hex();
1002            items.push(PollItem {
1003                // The content address IS the identity: the same grain can never
1004                // fire twice, and two evaluators seeing it agree without
1005                // coordinating.
1006                id: hex.clone(),
1007                payload: serde_json::json!({
1008                    "hash": hex,
1009                    "grain_type": grain.grain_type.as_str(),
1010                    "op_seq": op.op_seq,
1011                }),
1012                blobs: Vec::new(),
1013            });
1014        }
1015        Ok((items, cursor))
1016    }
1017
1018    /// Store an item's connector blobs in the CAS and rewrite payload
1019    /// references (#93). Returns one `ContentRef` list per item, parallel
1020    /// to `items`. Any contract violation — undecodable base64, a budget
1021    /// overrun, a dangling `"@N"` — refuses the WHOLE poll with TRG-E011,
1022    /// so the cursor stays put and nothing is silently dropped. `put_blob`
1023    /// is idempotent on content, so a re-polled page costs only the
1024    /// transfer.
1025    fn ingest_item_blobs(
1026        &self,
1027        hash: &str,
1028        items: &mut [PollItem],
1029        opts: &EvalOptions,
1030    ) -> Result<Vec<Vec<ContentRef>>> {
1031        let violation = |detail: String| TriggerError::BlobContract {
1032            trigger: short(hash, 12).to_string(),
1033            detail,
1034        };
1035        let mut response_total = 0usize;
1036        let mut all_refs = Vec::with_capacity(items.len());
1037        for (idx, item) in items.iter_mut().enumerate() {
1038            if item.blobs.is_empty() {
1039                all_refs.push(Vec::new());
1040                continue;
1041            }
1042            let mut item_total = 0usize;
1043            let mut refs = Vec::with_capacity(item.blobs.len());
1044            let mut uris = Vec::with_capacity(item.blobs.len());
1045            for (bi, blob) in item.blobs.iter().enumerate() {
1046                let name = blob.filename.as_deref().unwrap_or("unnamed");
1047                let bytes = areev_core::b64::decode(&blob.b64).ok_or_else(|| {
1048                    violation(format!("item {idx} blob {bi} ({name}) is not valid base64"))
1049                })?;
1050                item_total += bytes.len();
1051                response_total += bytes.len();
1052                if item_total > opts.max_blob_bytes_per_item {
1053                    return Err(violation(format!(
1054                        "item {idx} blobs exceed the per-item budget ({} bytes decoded)",
1055                        opts.max_blob_bytes_per_item
1056                    )));
1057                }
1058                if response_total > opts.max_blob_bytes_per_response {
1059                    return Err(violation(format!(
1060                        "response blobs exceed the per-response budget ({} bytes decoded)",
1061                        opts.max_blob_bytes_per_response
1062                    )));
1063                }
1064                let uri = self
1065                    .facade
1066                    .with_store(|m| m.put_blob(&bytes))
1067                    .map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
1068                refs.push(ContentRef {
1069                    uri: uri.clone(),
1070                    modality: None,
1071                    mime_type: blob.mime.clone(),
1072                    size_bytes: Some(bytes.len() as u64),
1073                    checksum: Some(uri.trim_start_matches("cas://").to_string()),
1074                    metadata: blob.filename.as_ref().map(|f| serde_json::json!({ "filename": f })),
1075                });
1076                uris.push(uri);
1077            }
1078            rewrite_blob_refs(&mut item.payload, &uris)
1079                .map_err(|d| violation(format!("item {idx}: {d}")))?;
1080            // The bytes are in the CAS now; drop the base64 so nothing
1081            // downstream can re-inline it into the Event or the run input.
1082            item.blobs.clear();
1083            all_refs.push(refs);
1084        }
1085        Ok(all_refs)
1086    }
1087
1088    fn poll(
1089        &self,
1090        hash: &str,
1091        trigger: &Trigger,
1092        state: &TriggerState,
1093        opts: &EvalOptions,
1094    ) -> Result<PollResponse> {
1095        let connector_name = trigger.connector.as_deref().unwrap_or_default();
1096        let Some(exec) = &self.connector else {
1097            return Err(TriggerError::NoConnector {
1098                trigger: hash.to_string(),
1099                connector: connector_name.to_string(),
1100            });
1101        };
1102        // Start a broker for this call, scoped to this trigger's allowlist. The
1103        // connector is handed its URL — never a token — so a compromised
1104        // connector has nothing to exfiltrate and nowhere undeclared to send
1105        // it. The broker stops when this call returns.
1106        let policy = areev_run::EgressPolicy::from_config(trigger.config.as_ref())
1107            .map_err(|what| TriggerError::Malformed { what })?;
1108        // One connector runs per pass, so there is no second caller to tell it
1109        // apart from: a default grant covers it. Its methods are whatever the
1110        // connector needs to poll, and the credentials are the ones this host
1111        // configured — naming one it was not given still fails.
1112        let grants = areev_run::EgressGrants::new().default_for_all(
1113            self.credentials.keys().fold(
1114                areev_run::CallerGrant::new()
1115                    .method("GET")
1116                    .method("POST")
1117                    .method("PUT")
1118                    .method("PATCH")
1119                    .method("DELETE"),
1120                |g, name| g.credential(name),
1121            ),
1122        );
1123        let broker = areev_run::Broker::start(
1124            policy,
1125            self.credentials.clone(),
1126            grants,
1127            "TRG-E009",
1128        )
1129        .map_err(|detail| TriggerError::Storage { detail })?;
1130
1131        let request = PollRequest {
1132            trigger: hash,
1133            connector: connector_name,
1134            scope: trigger.scope.as_deref(),
1135            cursor: state.cursor.as_deref(),
1136            max_items: opts.max_items,
1137            config: trigger.config.as_ref(),
1138        };
1139        let payload = serde_json::to_value(&request).map_err(|e| TriggerError::ConnectorFailed {
1140            trigger: hash.to_string(),
1141            detail: format!("serialize request: {e}"),
1142        })?;
1143        // The idempotency key ties this call to this occurrence, so a connector
1144        // that deduplicates on it sees a retry as a retry.
1145        let idem = format!("{hash}:{}", state.fence);
1146        // The connector reads AREEV_EGRESS_URL out of its environment. The
1147        // spawn seam scrubs whatever `--passphrase-env` and `--token-env` name,
1148        // so this is the only network affordance it has from us.
1149        //
1150        // The variable is PROCESS-GLOBAL, and `HostToolExecutor::execute` has no
1151        // per-call environment, so two evaluators in one process would race:
1152        // one could hand its broker's URL to the other's connector, pointing it
1153        // at the wrong allowlist. The lock makes the set→spawn→unset window
1154        // exclusive. Production runs one evaluator per process, so this
1155        // serialises nothing that was parallel; it closes a hazard that only
1156        // exists in-process (and that our own threaded tests could reach).
1157        static EGRESS_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
1158        let result = {
1159            let _guard = EGRESS_ENV.lock().unwrap_or_else(|e| e.into_inner());
1160            std::env::set_var("AREEV_EGRESS_URL", broker.url());
1161            // The broker authenticates its callers: loopback is not an
1162            // authorization, and without the token every call would 401.
1163            if let Some(t) = broker.token_for(connector_name) {
1164                std::env::set_var("AREEV_EGRESS_TOKEN", t);
1165            }
1166            let r = exec.execute(connector_name, hash, &payload, &idem);
1167            std::env::remove_var("AREEV_EGRESS_URL");
1168            std::env::remove_var("AREEV_EGRESS_TOKEN");
1169            r
1170        };
1171
1172        // Surface a refused destination as its own error rather than letting it
1173        // read as a generic connector failure — a connector reaching somewhere
1174        // undeclared is a different problem from one that crashed.
1175        let refused = broker.refusals();
1176        if !refused.is_empty() {
1177            return Err(TriggerError::EgressRefused {
1178                trigger: hash.to_string(),
1179                host: refused
1180                    .iter()
1181                    .map(|r| format!("{} ({})", r.destination, r.reason))
1182                    .collect::<Vec<_>>()
1183                    .join(", "),
1184            });
1185        }
1186        match result {
1187            areev_run::ExecResult::Ok(v) => {
1188                serde_json::from_value(v).map_err(|e| TriggerError::ConnectorFailed {
1189                    trigger: hash.to_string(),
1190                    detail: format!("response is not a poll response: {e}"),
1191                })
1192            }
1193            areev_run::ExecResult::Err { detail, .. } => {
1194                Err(TriggerError::ConnectorFailed { trigger: hash.to_string(), detail })
1195            }
1196        }
1197    }
1198
1199    /// Record the item as an Event and start the bound workflow.
1200    ///
1201    /// `legacy_run_ids` (#128) covers a trigger that was superseded BEFORE
1202    /// this fix shipped, when the run id was minted from the (then-current)
1203    /// head rather than the chain root: each entry is the id this same item
1204    /// would have gotten under an older head. Empty for a never-superseded
1205    /// trigger, so it costs nothing in the common case.
1206    fn start_run(
1207        &self,
1208        trigger: &Trigger,
1209        run_id: &str,
1210        legacy_run_ids: &[String],
1211        item: &PollItem,
1212        trigger_hash: &str,
1213        content_refs: &[ContentRef],
1214    ) -> StartOutcome {
1215        // Idempotency has to hold whether or not a runtime is wired in. With a
1216        // starter, the runtime's duplicate-run-id refusal is the guard; without
1217        // one, nothing else would stop the same item being re-ingested on every
1218        // poll forever, because an Event's `created_at` differs per firing and
1219        // so content addressing does not collapse them.
1220        let seen = self
1221            .facade
1222            .with_store(|m| m.run_grains(&self.ns, run_id, 0, 1))
1223            .map(|g| !g.is_empty())
1224            .unwrap_or(false);
1225        if seen {
1226            return StartOutcome::Duplicate;
1227        }
1228        // #128 compat — an item already processed under a pre-fix
1229        // (head-keyed) run id must still be recognized, or a trigger
1230        // superseded before this fix reprocesses its whole backlog the
1231        // first time it is evaluated after upgrading.
1232        for legacy_id in legacy_run_ids {
1233            let seen_legacy = self
1234                .facade
1235                .with_store(|m| m.run_grains(&self.ns, legacy_id, 0, 1))
1236                .map(|g| !g.is_empty())
1237                .unwrap_or(false);
1238            if seen_legacy {
1239                return StartOutcome::Duplicate;
1240            }
1241        }
1242
1243        // The ingest Event is built here but — #129 — WRITTEN only once the
1244        // outcome is one this dedup check should treat as "seen": Ingested
1245        // (no starter, so the Event itself IS the completed unit of work),
1246        // Started, or a starter-reported Duplicate (the runtime already has
1247        // it covered, possibly from elsewhere). A starter-reported `Failed`
1248        // writes NOTHING, so a retry's `seen` check above finds nothing and
1249        // genuinely retries `starter.start()` — before this, the Event was
1250        // always written first, so a failed start still left a `run_id`-
1251        // bearing grain behind that made every future retry of the SAME
1252        // item look like an already-processed duplicate, permanently
1253        // stranding it despite #129's cursor hold.
1254        let build_event = || {
1255            let mut event = Event::new(&item.payload.to_string())
1256                .namespace(&self.ns)
1257                .extra_field("trigger", serde_json::json!(trigger_hash))
1258                .extra_field("run_id", serde_json::json!(run_id));
1259            if let Some(c) = &trigger.connector {
1260                event = event.extra_field("connector", serde_json::json!(c));
1261            }
1262            // #93 — the item's stored blobs, referenced the same way a
1263            // host-driven ingest references them: through `content_refs`,
1264            // which is what keeps them alive through GC, carries them in
1265            // bundles, and lets erasure's sole-reference reclamation find
1266            // them.
1267            for cr in content_refs {
1268                event = event.content_ref(cr.clone());
1269            }
1270            event
1271        };
1272
1273        let Some(starter) = &self.starter else {
1274            // Ingest-only: the item is recorded, nothing is executed. Reported
1275            // as ingested rather than as a run, because claiming a run started
1276            // when none did would make the report lie.
1277            return match self.facade.with_store(|m| m.add(&build_event())) {
1278                Ok(_) => StartOutcome::Ingested,
1279                Err(e) => StartOutcome::Failed(format!("ingest: {e}")),
1280            };
1281        };
1282        let mut input = serde_json::json!({
1283            "trigger": trigger_hash,
1284            "connector": trigger.connector,
1285            "scope": trigger.scope,
1286            "item": item.payload,
1287        });
1288        // Declared context (#85): the EVALUATOR assembles it, because it is
1289        // the one party that already holds the memory — on the embedded
1290        // backend a tool inside the run cannot open the file its own run
1291        // locks. Fail closed: a trigger that declared context must not fire
1292        // blind, so a missing query or a failed read refuses the firing (the
1293        // evaluator's normal retry/backoff applies) rather than starting a
1294        // run without the context its declaration promised.
1295        if let Some(spec) = &trigger.context_query {
1296            match self.assemble_context(spec, &item.payload) {
1297                Ok(ctx) => {
1298                    input["context"] = ctx;
1299                }
1300                Err(why) => {
1301                    return StartOutcome::Failed(format!("context_query \"{spec}\": {why}"));
1302                }
1303            }
1304        }
1305        // Read through whatever scheme prefix the declaration spelled the
1306        // reference with (#73): `sha256:<hex>` validated, listed, and reported
1307        // `waiting` forever, then died here on `FMT-E001: invalid hex hash`.
1308        match starter.start(trigger.workflow_hash(), run_id, input) {
1309            // The run is durably underway (or already was, started elsewhere)
1310            // before we ever try to write the ingest Event, so a failure to
1311            // write it here does not need to be reported as a failed firing:
1312            // a retry would find `starter.start()` itself now reporting
1313            // `Duplicate` (the runtime's own dedup), never a second run. Best
1314            // effort is enough; the Event is audit/context linkage, not the
1315            // run's own record of itself.
1316            StartResult::Started => {
1317                let _ = self.facade.with_store(|m| m.add(&build_event()));
1318                StartOutcome::Started
1319            }
1320            StartResult::Duplicate => {
1321                let _ = self.facade.with_store(|m| m.add(&build_event()));
1322                StartOutcome::Duplicate
1323            }
1324            StartResult::Failed(why) => StartOutcome::Failed(why),
1325        }
1326    }
1327
1328    /// Run the trigger's saved query (`RUN "name"($p = v, …)`) against the
1329    /// facade this evaluator already holds and return the result payload.
1330    /// The executor re-validates the saved body as read-only at execution,
1331    /// and the destructive cap is off — a context query can never mutate.
1332    ///
1333    /// #92 — the declaration may bind the query's parameters from the
1334    /// firing item (`name($session = /session)`): each JSON pointer is
1335    /// resolved against the item's payload with `--dedup-key`'s machinery
1336    /// and semantics. Fail-closed like the rest of declared context: an
1337    /// unresolvable pointer, or one landing on a non-scalar, refuses the
1338    /// firing rather than running the query with a hole in it. Values ride
1339    /// as a parsed AST (`RunQueryStmt`), never inside CAL text, so an
1340    /// untrusted payload value cannot inject CAL.
1341    fn assemble_context(
1342        &self,
1343        spec: &str,
1344        payload: &serde_json::Value,
1345    ) -> std::result::Result<serde_json::Value, String> {
1346        let spec = crate::context::parse_context_query(spec)?;
1347        let mut bindings: Vec<(String, areev_cal::ast::Value)> = Vec::new();
1348        for (param, pointer) in &spec.bindings {
1349            let v = payload.pointer(pointer).ok_or_else(|| {
1350                format!("parameter ${param}: pointer {pointer} does not resolve against the firing item")
1351            })?;
1352            let value = match v {
1353                serde_json::Value::String(s) => {
1354                    areev_cal::ast::Value::String { value: s.clone() }
1355                }
1356                serde_json::Value::Number(n) => areev_cal::ast::Value::Number {
1357                    value: n.as_f64().ok_or_else(|| {
1358                        format!("parameter ${param}: {pointer} is not a representable number")
1359                    })?,
1360                },
1361                serde_json::Value::Bool(b) => areev_cal::ast::Value::Boolean { value: *b },
1362                other => {
1363                    return Err(format!(
1364                        "parameter ${param}: pointer {pointer} must land on a scalar, found {}",
1365                        match other {
1366                            serde_json::Value::Null => "null",
1367                            serde_json::Value::Array(_) => "an array",
1368                            _ => "an object",
1369                        }
1370                    ))
1371                }
1372            };
1373            bindings.push((param.clone(), value));
1374        }
1375        let ex = areev_cal::CalExecutor::new(areev_cal::CalExecutorConfig {
1376            allow_destructive_ops: false,
1377            ..areev_cal::CalExecutorConfig::default()
1378        });
1379        let query = areev_cal::ast::CalQuery {
1380            version: Default::default(),
1381            statement: areev_cal::ast::CalStatement::RunQuery(areev_cal::ast::RunQueryStmt {
1382                name: spec.name.clone(),
1383                bindings,
1384                span: None,
1385            }),
1386            pipeline: Vec::new(),
1387            with_options: Vec::new(),
1388            format: None,
1389            let_bindings: Vec::new(),
1390            user_vars: Default::default(),
1391            let_values: Default::default(),
1392            warnings: Vec::new(),
1393        };
1394        let res = ex
1395            .execute_parsed(query, &format!("RUN \"{}\"", spec.name), self.facade.as_ref())
1396            .map_err(|e| e.to_string())?;
1397        serde_json::to_value(&res.result).map_err(|e| e.to_string())
1398    }
1399
1400    /// One Observation per firing: what fired, what it produced, what it
1401    /// skipped. An unfired trigger is invisible otherwise.
1402    fn journal(
1403        &self,
1404        hash: &str,
1405        trigger: &Trigger,
1406        outcome: &FireOutcome,
1407        note: Option<&str>,
1408        now: i64,
1409    ) -> Result<()> {
1410        let mut obs = Observation::new("areev:trigger", "trigger:firing")
1411            .namespace(TRIGGER_NS)
1412            .created_at(now)
1413            .extra_field("trigger", serde_json::json!(hash))
1414            .extra_field("kind", serde_json::json!(trigger.kind.as_str()))
1415            .extra_field("workflow", serde_json::json!(trigger.workflow))
1416            .extra_field("items", serde_json::json!(outcome.items))
1417            .extra_field("runs_started", serde_json::json!(outcome.runs_started))
1418            .extra_field("duplicates", serde_json::json!(outcome.duplicates));
1419        // Without these, a firing where every item lacked the dedup key reads
1420        // as "items 5, runs_started 0, duplicates 0" -- and that last zero
1421        // actively misleads, saying the items were not skipped as duplicates
1422        // without saying why they were skipped at all. Emitted only when
1423        // non-zero, so an ordinary firing stays as small as it was.
1424        if outcome.unidentifiable > 0 {
1425            obs = obs.extra_field("unidentifiable", serde_json::json!(outcome.unidentifiable));
1426        }
1427        if outcome.ingested > 0 {
1428            obs = obs.extra_field("ingested", serde_json::json!(outcome.ingested));
1429        }
1430        if !outcome.failures.is_empty() {
1431            obs = obs.extra_field("failures", serde_json::json!(outcome.failures));
1432            // #129 — names WHY the cursor did not move this firing, so the
1433            // audit record does not require the reader to infer it from
1434            // `duplicates` staying flat on the next firing.
1435            obs = obs.extra_field("cursor_held", serde_json::json!(true));
1436        }
1437        if outcome.seeded {
1438            obs = obs.extra_field("seeded", serde_json::json!(true));
1439        }
1440        if let Some(n) = note {
1441            obs = obs.extra_field("note", serde_json::json!(n));
1442        }
1443        self.facade
1444            .with_store(|m| m.add_if_novel(&obs))
1445            .map(|_| ())
1446            .map_err(|e| TriggerError::Storage { detail: e.to_string() })
1447    }
1448
1449    /// Resolve (and cache) the supersession chain for one trigger's head
1450    /// hash within one evaluation pass (#128).
1451    ///
1452    /// `cache` lives on the CALLER's stack — a fresh `HashMap` per `run()`,
1453    /// `deliver()`, or `status()` invocation — so a chain is walked AT MOST
1454    /// ONCE per trigger per pass no matter how many items it fires or how
1455    /// many composites settle against it in that pass. Do not add a call
1456    /// site that walks the chain per item; thread the already-resolved
1457    /// [`Chain`] through instead, the way `fire`/`collect_and_start` do.
1458    fn resolve_chain(&self, hash: &str, cache: &mut HashMap<String, Chain>) -> Result<Chain> {
1459        if let Some(c) = cache.get(hash) {
1460            return Ok(c.clone());
1461        }
1462        let h = Hash::from_hex(hash).map_err(|e| TriggerError::Storage { detail: e.to_string() })?;
1463        let hashes: Vec<String> = self
1464            .facade
1465            .with_store(|m| m.supersession_chain(&h))
1466            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
1467            .into_iter()
1468            .map(|hh| hh.to_hex())
1469            .collect();
1470        let chain = Chain(hashes);
1471        cache.insert(hash.to_string(), chain.clone());
1472        Ok(chain)
1473    }
1474
1475    /// Read a trigger's evaluation state through its resolved chain, with
1476    /// compatibility for a trigger superseded BEFORE #128 shipped (its state
1477    /// was keyed on the head hash of the moment, not the chain root).
1478    ///
1479    /// Always tries the ROOT key first. For a trigger that has never been
1480    /// superseded this is the ONLY path ever taken — `chain.root() ==` the
1481    /// hash this crate always keyed state on — so the common case is a
1482    /// byte-identical lookup to before this feature existed.
1483    ///
1484    /// If the root row is absent, every other hash in the chain
1485    /// (`chain.legacy()`) is checked for a row written under an older head.
1486    /// Several may exist if the trigger was superseded more than once
1487    /// before upgrading; [`state_is_more_advanced`] picks whichever
1488    /// represents the most real progress, so the survivor is never the one
1489    /// that would silently throw away a further-along cursor.
1490    ///
1491    /// `adopt = true` persists the found legacy state under the root and
1492    /// deletes the legacy row — write the new row FIRST, delete the legacy
1493    /// row SECOND, so a crash in between leaves a harmless duplicate (the
1494    /// legacy row is simply never consulted again once the root row exists)
1495    /// rather than losing the cursor. Pass `true` only from a caller that
1496    /// already owns write access for real evaluation work (`run`,
1497    /// `deliver`, composite settlement); `status`/`show` pass `false` so a
1498    /// pure inspection command has no side effect. Adoption is best-effort
1499    /// against a read-only-opened memory: `STO-E004` is swallowed and the
1500    /// found state is still returned, just not yet persisted — the next
1501    /// pass with write access adopts it.
1502    fn resolve_state(&self, chain: &Chain, adopt: bool) -> Result<(TriggerState, Option<String>)> {
1503        let root = chain.root();
1504        if let Some((state, raw)) = self
1505            .facade
1506            .with_store(|m| m.trigger_state(root))
1507            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
1508        {
1509            return Ok((state, Some(raw)));
1510        }
1511
1512        let mut best: Option<(&str, TriggerState)> = None;
1513        for legacy_hash in chain.legacy() {
1514            if let Some((state, _raw)) = self
1515                .facade
1516                .with_store(|m| m.trigger_state(legacy_hash))
1517                .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
1518            {
1519                let better = best
1520                    .as_ref()
1521                    .is_none_or(|(_, current)| state_is_more_advanced(&state, current));
1522                if better {
1523                    best = Some((legacy_hash.as_str(), state));
1524                }
1525            }
1526        }
1527        let Some((legacy_hash, state)) = best else {
1528            // No row anywhere in the chain: a genuinely new trigger.
1529            return Ok((TriggerState::default(), None));
1530        };
1531        if !adopt {
1532            return Ok((state, None));
1533        }
1534
1535        // `expected=None` only claims an ABSENT row (`INSERT OR IGNORE`), so
1536        // losing a race to another evaluator adopting the same legacy row
1537        // concurrently is a no-op, never a clobber — either way the legacy
1538        // row is redundant now.
1539        match self.facade.with_store(|m| m.put_trigger_state(root, None, &state)) {
1540            Ok(_) => {}
1541            Err(AreevError::ReadOnly(_)) => return Ok((state, None)),
1542            Err(e) => return Err(TriggerError::Storage { detail: e.to_string() }),
1543        }
1544        if let Err(e) = self.facade.with_store(|m| m.clear_trigger_state(legacy_hash)) {
1545            if !matches!(e, AreevError::ReadOnly(_)) {
1546                return Err(TriggerError::Storage { detail: e.to_string() });
1547            }
1548        }
1549        let (state, raw) = self
1550            .facade
1551            .with_store(|m| m.trigger_state(root))
1552            .map_err(|e| TriggerError::Storage { detail: e.to_string() })?
1553            .ok_or_else(|| TriggerError::Storage {
1554                detail: format!(
1555                    "adopted trigger state for {root} vanished immediately after writing"
1556                ),
1557            })?;
1558        Ok((state, Some(raw)))
1559    }
1560}
1561
1562/// A trigger's supersession chain for the duration of one evaluation pass:
1563/// every content-address hash in its edit history, HEAD (the declaration's
1564/// current hash — what the caller resolved from) first, ROOT last (#128).
1565///
1566/// Evaluation state (`trg:<hash>`) and derived run ids are keyed on the
1567/// ROOT, never the head: a Trigger grain re-pointed by `SUPERSEDE` mints a
1568/// new head hash for the SAME standing rule (`docs/loop.md`'s
1569/// propose→approve→apply cycle re-points a trigger exactly this way), and
1570/// keying on the head alone would orphan the cursor and the dedup fence
1571/// every time a recommendation is applied. For a trigger that has never
1572/// been superseded, `root() == head`, so every key derived from it is
1573/// byte-identical to what this crate always computed — the no-op guarantee
1574/// the fix has to preserve.
1575#[derive(Debug, Clone)]
1576struct Chain(Vec<String>);
1577
1578impl Chain {
1579    /// The key evaluation state and run ids are actually resolved against.
1580    fn root(&self) -> &str {
1581        self.0.last().map(String::as_str).expect("a chain always has at least its head")
1582    }
1583
1584    /// Every hash besides the root — the identities a pre-#128 evaluator
1585    /// might have keyed state or a run id under. Empty for a
1586    /// never-superseded trigger, so the compatibility paths that walk this
1587    /// cost nothing in the common case.
1588    fn legacy(&self) -> &[String] {
1589        &self.0[..self.0.len() - 1]
1590    }
1591}
1592
1593/// Which of two legacy state rows found along a chain is "further along"
1594/// and should be adopted, when more than one exists (a trigger superseded
1595/// more than once before #128 shipped). `last_fired_at` is the most direct
1596/// evidence of real progress across every trigger kind — the row that
1597/// fired most recently is the row whose cursor best reflects what has
1598/// actually been processed. `op_cursor` (a `memory` trigger's position) and
1599/// then "has this row ever polled at all" break a tie where neither
1600/// candidate has ever fired.
1601fn state_is_more_advanced(candidate: &TriggerState, current_best: &TriggerState) -> bool {
1602    (candidate.last_fired_at, candidate.op_cursor, candidate.cursor.is_some())
1603        > (current_best.last_fired_at, current_best.op_cursor, current_best.cursor.is_some())
1604}
1605
1606/// One line for `released.last_error` (#129): several items in one firing
1607/// can fail for different reasons, and `last_error` is a single string.
1608fn summarize_failures(failures: &[String]) -> String {
1609    match failures {
1610        [one] => one.clone(),
1611        many => format!("{} item(s) failed to start: {}", many.len(), many.join("; ")),
1612    }
1613}
1614
1615/// Drop partial matches past their window.
1616///
1617/// Without this, a composite that never completes accumulates keys forever and
1618/// a stale half-match eventually pairs with unrelated work — the exact failure
1619/// Argo Events' reset cron exists to paper over.
1620fn prune_partials(state: &mut TriggerState, trigger: &Trigger, now: i64) {
1621    let Some(window) = trigger.window_ms else {
1622        return;
1623    };
1624    state.partials.retain(|_, p| now - p.started_ms <= window);
1625}
1626
1627#[derive(Debug, Default, Clone)]
1628struct FireOutcome {
1629    items: usize,
1630    runs_started: usize,
1631    ingested: usize,
1632    duplicates: usize,
1633    unidentifiable: usize,
1634    failures: Vec<String>,
1635    cursor: Option<String>,
1636    /// The payloads this firing produced, so a composite can pull its
1637    /// correlation value out of them.
1638    fired_items: Vec<serde_json::Value>,
1639    /// Correlation keys a composite consumed, cleared on release.
1640    settled_keys: Vec<String>,
1641    op_cursor: Option<i64>,
1642    draining: bool,
1643    seeded: bool,
1644    skipped_locked: bool,
1645}
1646
1647enum StartOutcome {
1648    Started,
1649    /// Recorded but not executed — no runtime is wired in.
1650    Ingested,
1651    Duplicate,
1652    Failed(String),
1653}
1654
1655/// The run id for one firing of one item.
1656///
1657/// Derived from `(trigger, connector, dedup value)`, so the same item seen
1658/// twice — connector replay, overlapping cursors, two nodes racing a lease
1659/// boundary — produces the same id. `areev run start` refuses a duplicate, so
1660/// the second attempt is a recorded skip rather than a second run. At-most-once
1661/// firing with no lease, no fencing token, and no clock assumption.
1662///
1663/// The connector is part of the identity because CloudEvents made that
1664/// normative for the same reason: `source` + `id` is unique, `id` alone is only
1665/// unique within a producer. Two connectors sharing an id space must not
1666/// collide.
1667///
1668/// **`trigger_hash` is the chain ROOT (#128), not necessarily the
1669/// declaration's current head.** A `Trigger` grain re-pointed by
1670/// `SUPERSEDE` mints a new head for the same standing rule, and this
1671/// function has no way to know that on its own — a caller that passed the
1672/// head would mint a fresh id for an item already processed under the old
1673/// head, defeating the very dedup fence this function exists to provide.
1674/// Every call site resolves the root once per pass (`Evaluator::
1675/// resolve_chain`) and passes that; this function's signature and hashing
1676/// are otherwise unchanged.
1677pub fn run_id_for(trigger_hash: &str, connector: Option<&str>, dedup_value: &str) -> String {
1678    let mut h = Sha256::new();
1679    h.update(b"areev-trigger:v1");
1680    h.update([0x1f]);
1681    h.update(connector.unwrap_or_default().as_bytes());
1682    h.update([0x1f]);
1683    h.update(dedup_value.as_bytes());
1684    let digest = hex(&h.finalize());
1685    format!("{}-{}", short(trigger_hash, 8), short(&digest, 12))
1686}
1687
1688fn hex(bytes: &[u8]) -> String {
1689    bytes.iter().map(|b| format!("{b:02x}")).collect()
1690}
1691
1692#[cfg(test)]
1693mod tests {
1694    use super::*;
1695
1696    #[test]
1697    fn a_run_id_is_stable_for_the_same_item() {
1698        let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1699        let b = run_id_for("abcdef1234", Some("gmail"), "m1");
1700        assert_eq!(a, b, "the same item must always mint the same id");
1701    }
1702
1703    #[test]
1704    fn different_items_get_different_ids() {
1705        let a = run_id_for("abcdef1234", Some("gmail"), "m1");
1706        let b = run_id_for("abcdef1234", Some("gmail"), "m2");
1707        assert_ne!(a, b);
1708    }
1709
1710    #[test]
1711    fn the_connector_is_part_of_the_identity() {
1712        // CloudEvents' rule: `id` alone is unique only within a producer, so two
1713        // connectors with a shared id space must not collide.
1714        let a = run_id_for("abcdef1234", Some("gmail"), "1");
1715        let b = run_id_for("abcdef1234", Some("stripe"), "1");
1716        assert_ne!(a, b);
1717    }
1718
1719    #[test]
1720    fn the_id_names_its_trigger_for_a_human_reading_a_run_list() {
1721        let id = run_id_for("abcdef1234567890", Some("gmail"), "m1");
1722        assert!(id.starts_with("abcdef12"), "{id}");
1723        assert!(id.len() < 32, "short enough to read: {id}");
1724    }
1725
1726    #[test]
1727    fn a_short_trigger_hash_does_not_panic() {
1728        let id = run_id_for("ab", None, "x");
1729        assert!(id.starts_with("ab-"));
1730    }
1731}