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}