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