Skip to main content

harn_vm/triggers/test_util/
mod.rs

1use std::collections::{BTreeMap, HashMap};
2use std::fs;
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5use std::time::Duration as StdDuration;
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value as JsonValue};
10use time::OffsetDateTime;
11use tokio::sync::Notify;
12use uuid::Uuid;
13
14use crate::connectors::a2a_push::A2aPushConnector;
15use crate::connectors::cron::{CatchupMode, CronConnector, CronEventSink};
16use crate::connectors::webhook::{
17    GenericWebhookConnector, WebhookProviderProfile, WebhookSignatureVariant,
18};
19use crate::connectors::{
20    Connector, ConnectorCtx, ConnectorError, MetricsRegistry, RateLimitConfig, RateLimiterFactory,
21    RawInbound, TriggerBinding as ConnectorTriggerBinding,
22};
23use crate::event_log::{
24    install_default_for_base_dir, install_memory_for_current_thread, AnyEventLog, EventLog,
25    FileEventLog, LogEvent, MemoryEventLog, Topic,
26};
27use crate::secrets::{
28    RotationHandle, SecretBytes, SecretError, SecretId, SecretMeta, SecretProvider,
29};
30use crate::triggers::event::KnownProviderPayload;
31use crate::triggers::registry::{
32    TriggerBindingSnapshot, TriggerBindingSource, TriggerBindingSpec, TriggerDispatchOutcome,
33    TriggerHandlerSpec, TriggerState,
34};
35use crate::triggers::{
36    begin_in_flight, clear_trigger_registry, finish_in_flight, install_manifest_triggers,
37    snapshot_trigger_bindings, GenericWebhookPayload, InboxIndex, ProviderId, ProviderPayload,
38    SignatureStatus, TenantId, TriggerEvent, TriggerRetryConfig, DEFAULT_INBOX_RETENTION_DAYS,
39};
40
41use self::timing::TEST_DEFAULT_TIMEOUT;
42
43pub mod clock;
44pub mod clock_leak;
45pub mod timing;
46
47pub const TRIGGER_TEST_FIXTURES: &[&str] = &[
48    "cost_guard_short_circuits",
49    "crash_recovery_replays_in_flight_events",
50    "cron_fires_on_schedule",
51    "cron_30_days",
52    "dead_man_switch_alerts_on_silent_binding",
53    "dedupe_swallows_duplicate_key",
54    "dispatcher_retries_with_exponential_backoff",
55    "dlq_on_permanent_failure",
56    "a2a_push_completed",
57    "a2a_push_rejects_replay",
58    "manifest_hot_reload_preserves_in_flight",
59    "multi_tenant_isolation_stub",
60    "orchestrator_backpressure_ingest_saturation",
61    "orchestrator_circuit_breaker_trips",
62    "rate_limit_throttles",
63    "replay_binding_gc_fallback",
64    "replay_refires_from_dlq",
65    "scheduled_eval_suite",
66    "webhook_dedupe_blocks_duplicates",
67    "webhook_verifies_hmac",
68];
69
70const IN_FLIGHT_TOPIC: &str = "triggers.harness.inflight";
71
72#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
73pub struct TriggerHarnessAttempt {
74    pub attempt: u32,
75    pub at: String,
76    pub at_ms: u64,
77    pub status: String,
78    pub error: Option<String>,
79    pub backoff_ms: Option<u64>,
80    pub replay_of_event_id: Option<String>,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct TriggerHarnessDlqEntry {
85    pub id: String,
86    pub event_id: String,
87    pub binding_id: String,
88    pub state: String,
89    pub error: String,
90    pub attempts: u32,
91    pub replayed: bool,
92}
93
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95pub struct TriggerHarnessAlert {
96    pub kind: String,
97    pub binding_id: String,
98    pub at: String,
99    pub message: String,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
103pub struct RecordedConnectorEvent {
104    pub binding_id: String,
105    pub binding_version: u32,
106    pub provider: String,
107    pub kind: String,
108    pub dedupe_key: String,
109    pub tenant_id: Option<String>,
110    pub occurred_at: Option<String>,
111    pub received_at: String,
112    pub signature_state: String,
113    pub note: Option<String>,
114    pub replay_of_event_id: Option<String>,
115}
116
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
118pub struct TriggerHarnessResult {
119    pub fixture: String,
120    pub ok: bool,
121    pub stub: bool,
122    pub summary: String,
123    #[serde(default)]
124    pub emitted: Vec<RecordedConnectorEvent>,
125    #[serde(default)]
126    pub attempts: Vec<TriggerHarnessAttempt>,
127    #[serde(default)]
128    pub dlq: Vec<TriggerHarnessDlqEntry>,
129    #[serde(default)]
130    pub alerts: Vec<TriggerHarnessAlert>,
131    #[serde(default)]
132    pub bindings: Vec<TriggerBindingSnapshot>,
133    #[serde(default)]
134    pub notes: Vec<String>,
135    #[serde(default)]
136    pub details: JsonValue,
137}
138
139#[derive(Clone, Debug, Serialize, Deserialize)]
140struct PersistedInFlight {
141    event_id: String,
142    binding_id: String,
143    provider: String,
144    kind: String,
145    dedupe_key: String,
146    status: String,
147}
148
149#[derive(Clone, Default)]
150struct MockConnectorRegistry {
151    emitted: Arc<Mutex<Vec<RecordedConnectorEvent>>>,
152    alerts: Arc<Mutex<Vec<TriggerHarnessAlert>>>,
153}
154
155impl MockConnectorRegistry {
156    fn record_event(
157        &self,
158        binding_id: &str,
159        binding_version: u32,
160        event: &TriggerEvent,
161        note: Option<&str>,
162        replay_of_event_id: Option<String>,
163    ) {
164        self.emitted
165            .lock()
166            .expect("mock connector registry mutex poisoned")
167            .push(RecordedConnectorEvent {
168                binding_id: binding_id.to_string(),
169                binding_version,
170                provider: event.provider.as_str().to_string(),
171                kind: event.kind.clone(),
172                dedupe_key: event.dedupe_key.clone(),
173                tenant_id: event.tenant_id.as_ref().map(|tenant| tenant.0.clone()),
174                occurred_at: event.occurred_at.map(format_rfc3339),
175                received_at: format_rfc3339(event.received_at),
176                signature_state: signature_state_label(&event.signature_status).to_string(),
177                note: note.map(ToString::to_string),
178                replay_of_event_id,
179            });
180    }
181
182    fn record_alert(&self, alert: TriggerHarnessAlert) {
183        self.alerts
184            .lock()
185            .expect("mock connector alert mutex poisoned")
186            .push(alert);
187    }
188
189    fn emitted(&self) -> Vec<RecordedConnectorEvent> {
190        self.emitted
191            .lock()
192            .expect("mock connector registry mutex poisoned")
193            .clone()
194    }
195
196    fn alerts(&self) -> Vec<TriggerHarnessAlert> {
197        self.alerts
198            .lock()
199            .expect("mock connector alert mutex poisoned")
200            .clone()
201    }
202}
203
204struct TriggerTestHarness {
205    clock: Arc<clock::MockClock>,
206    connector_registry: MockConnectorRegistry,
207}
208
209impl TriggerTestHarness {
210    fn new(start: OffsetDateTime) -> Self {
211        Self {
212            clock: clock::MockClock::new(start),
213            connector_registry: MockConnectorRegistry::default(),
214        }
215    }
216
217    async fn run(self, fixture: &str) -> Result<TriggerHarnessResult, String> {
218        match fixture {
219            "cost_guard_short_circuits" => self.cost_guard_short_circuits().await,
220            "crash_recovery_replays_in_flight_events" => {
221                self.crash_recovery_replays_in_flight_events().await
222            }
223            "cron_fires_on_schedule" => self.cron_fires_on_schedule().await,
224            "cron_30_days" => self.cron_30_days().await,
225            "dead_man_switch_alerts_on_silent_binding" => {
226                self.dead_man_switch_alerts_on_silent_binding().await
227            }
228            "dedupe_swallows_duplicate_key" => self.dedupe_swallows_duplicate_key().await,
229            "dispatcher_retries_with_exponential_backoff" => {
230                self.dispatcher_retries_with_exponential_backoff().await
231            }
232            "dlq_on_permanent_failure" => self.dlq_on_permanent_failure().await,
233            "a2a_push_completed" => self.a2a_push_completed().await,
234            "a2a_push_rejects_replay" => self.a2a_push_rejects_replay().await,
235            "manifest_hot_reload_preserves_in_flight" => {
236                self.manifest_hot_reload_preserves_in_flight().await
237            }
238            "multi_tenant_isolation_stub" => self.multi_tenant_isolation_stub().await,
239            "orchestrator_backpressure_ingest_saturation" => {
240                self.orchestrator_backpressure_ingest_saturation().await
241            }
242            "orchestrator_circuit_breaker_trips" => self.orchestrator_circuit_breaker_trips().await,
243            "rate_limit_throttles" => self.rate_limit_throttles().await,
244            "replay_binding_gc_fallback" => self.replay_binding_gc_fallback().await,
245            "replay_refires_from_dlq" => self.replay_refires_from_dlq().await,
246            "scheduled_eval_suite" => self.scheduled_eval_suite().await,
247            "webhook_dedupe_blocks_duplicates" => self.webhook_dedupe_blocks_duplicates().await,
248            "webhook_verifies_hmac" => self.webhook_verifies_hmac().await,
249            _ => Err(format!(
250                "unknown trigger harness fixture '{fixture}' (known: {})",
251                TRIGGER_TEST_FIXTURES.join(", ")
252            )),
253        }
254    }
255
256    async fn cron_fires_on_schedule(self) -> Result<TriggerHarnessResult, String> {
257        self.clock.set(parse_rfc3339("2026-04-19T00:00:30Z")).await;
258        let _guard = clock::install_override(self.clock.clone());
259        let sink = Arc::new(RecordingCronSink {
260            binding_id: "cron.fixture".to_string(),
261            binding_version: 1,
262            registry: self.connector_registry.clone(),
263            notify: Arc::new(Notify::new()),
264        });
265        let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
266        let inbox = build_inbox(&log).await;
267        let mut connector = CronConnector::with_clock_and_sink(self.clock.clone(), sink.clone());
268        connector
269            .init(connector_ctx(log, Arc::new(EmptySecretProvider), inbox))
270            .await
271            .map_err(|error| error.to_string())?;
272        connector
273            .activate(&[cron_binding(
274                "cron.fixture",
275                "* * * * *",
276                "UTC",
277                CatchupMode::Skip,
278            )])
279            .await
280            .map_err(|error| error.to_string())?;
281        self.clock.advance_std(StdDuration::from_secs(30)).await;
282        let _ = tokio::time::timeout(TEST_DEFAULT_TIMEOUT, sink.wait_for_event()).await;
283        let emitted = self.connector_registry.emitted();
284        Ok(TriggerHarnessResult {
285            fixture: "cron_fires_on_schedule".to_string(),
286            ok: emitted.len() == 1
287                && emitted[0].provider == "cron"
288                && emitted[0].kind == "tick"
289                && emitted[0].occurred_at.as_deref() == Some("2026-04-19T00:01:00Z"),
290            stub: false,
291            summary: "cron connector emits a normalized tick on the scheduled boundary".to_string(),
292            emitted,
293            attempts: Vec::new(),
294            dlq: Vec::new(),
295            alerts: Vec::new(),
296            bindings: Vec::new(),
297            notes: Vec::new(),
298            details: json!({
299                "clock_ms": self.clock.monotonic_now().as_millis(),
300            }),
301        })
302    }
303
304    /// Simulate 30 daily cron ticks under a paused clock. The clock starts
305    /// at 2026-01-01T00:00:00Z; the daily schedule fires on each midnight
306    /// boundary, so the first tick lands at 2026-01-02T00:00:00Z and the
307    /// thirtieth at 2026-01-31T00:00:00Z.
308    async fn cron_30_days(self) -> Result<TriggerHarnessResult, String> {
309        self.clock.set(parse_rfc3339("2026-01-01T00:00:00Z")).await;
310        let _guard = clock::install_override(self.clock.clone());
311        let notify = Arc::new(Notify::new());
312        let sink = Arc::new(RecordingCronSink {
313            binding_id: "cron.30days".to_string(),
314            binding_version: 1,
315            registry: self.connector_registry.clone(),
316            notify: notify.clone(),
317        });
318        let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(64)));
319        let inbox = build_inbox(&log).await;
320        let mut connector = CronConnector::with_clock_and_sink(self.clock.clone(), sink.clone());
321        connector
322            .init(connector_ctx(log, Arc::new(EmptySecretProvider), inbox))
323            .await
324            .map_err(|error| error.to_string())?;
325        connector
326            .activate(&[cron_binding(
327                "cron.30days",
328                "0 0 * * *",
329                "UTC",
330                CatchupMode::Skip,
331            )])
332            .await
333            .map_err(|error| error.to_string())?;
334
335        for target in 1..=30usize {
336            self.clock.advance_std(StdDuration::from_hours(24)).await;
337            let _ = tokio::time::timeout(TEST_DEFAULT_TIMEOUT, async {
338                loop {
339                    // Arm the notified future BEFORE checking the count.
340                    // `notify_waiters` only wakes already-waiting tasks, so
341                    // checking the count first would lose a notification
342                    // that fires between the check and the await.
343                    let notified = notify.notified();
344                    tokio::pin!(notified);
345                    if self.connector_registry.emitted().len() >= target {
346                        return;
347                    }
348                    notified.await;
349                }
350            })
351            .await;
352        }
353
354        let emitted = self.connector_registry.emitted();
355        let ok = emitted.len() == 30
356            && emitted
357                .iter()
358                .all(|e| e.provider == "cron" && e.kind == "tick")
359            && emitted[0].occurred_at.as_deref() == Some("2026-01-02T00:00:00Z")
360            && emitted[29].occurred_at.as_deref() == Some("2026-01-31T00:00:00Z");
361        Ok(TriggerHarnessResult {
362            fixture: "cron_30_days".to_string(),
363            ok,
364            stub: false,
365            summary: "cron connector fires daily for 30 simulated days under a paused clock"
366                .to_string(),
367            emitted,
368            attempts: Vec::new(),
369            dlq: Vec::new(),
370            alerts: Vec::new(),
371            bindings: Vec::new(),
372            notes: Vec::new(),
373            details: json!({
374                "clock_elapsed_ms": self.clock.monotonic_now().as_millis(),
375            }),
376        })
377    }
378
379    async fn webhook_verifies_hmac(self) -> Result<TriggerHarnessResult, String> {
380        let _guard = clock::install_override(self.clock.clone());
381        let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
382        let inbox = build_inbox(&log).await;
383        let mut connector = GenericWebhookConnector::new();
384        connector
385            .init(connector_ctx(
386                log,
387                Arc::new(StaticSecretProvider::new(
388                    "webhook",
389                    BTreeMap::from([(
390                        SecretId::new("webhook", "test-signing-secret"),
391                        "It's a Secret to Everybody".to_string(),
392                    )]),
393                )),
394                inbox,
395            ))
396            .await
397            .map_err(|error| error.to_string())?;
398        connector
399            .activate(&[webhook_binding(WebhookSignatureVariant::GitHub, None)])
400            .await
401            .map_err(|error| error.to_string())?;
402
403        let event = connector
404            .normalize_inbound(github_raw_inbound())
405            .await
406            .map_err(|error| error.to_string())?;
407        self.connector_registry
408            .record_event("webhook.fixture", 1, &event, Some("verified"), None);
409        let emitted = self.connector_registry.emitted();
410        Ok(TriggerHarnessResult {
411            fixture: "webhook_verifies_hmac".to_string(),
412            ok: emitted.len() == 1
413                && emitted[0].signature_state == "verified"
414                && emitted[0].kind == "ping",
415            stub: false,
416            summary: "generic webhook connector verifies a GitHub-style HMAC delivery".to_string(),
417            emitted,
418            attempts: Vec::new(),
419            dlq: Vec::new(),
420            alerts: Vec::new(),
421            bindings: Vec::new(),
422            notes: Vec::new(),
423            details: json!({
424                "provider": event.provider.as_str(),
425            }),
426        })
427    }
428
429    async fn dispatcher_retries_with_exponential_backoff(
430        self,
431    ) -> Result<TriggerHarnessResult, String> {
432        let _guard = clock::install_override(self.clock.clone());
433        let event = synthetic_event("dispatcher.retry", "retry-key", None);
434        let mut attempts = Vec::new();
435        let mut backoff_ms = 100u64;
436        for attempt in 1..=3 {
437            let status = if attempt < 3 {
438                "retryable_error"
439            } else {
440                "dispatched"
441            };
442            attempts.push(TriggerHarnessAttempt {
443                attempt,
444                at: format_rfc3339(clock::now_utc()),
445                at_ms: self.clock.monotonic_now().as_millis() as u64,
446                status: status.to_string(),
447                error: (attempt < 3).then(|| "rate_limit".to_string()),
448                backoff_ms: (attempt < 3).then_some(backoff_ms),
449                replay_of_event_id: None,
450            });
451            if attempt < 3 {
452                self.clock
453                    .advance_std(StdDuration::from_millis(backoff_ms))
454                    .await;
455                backoff_ms = backoff_ms.saturating_mul(2);
456            }
457        }
458        self.connector_registry.record_event(
459            "dispatcher.retry",
460            1,
461            &event,
462            Some("dispatched_after_retry"),
463            None,
464        );
465        let emitted = self.connector_registry.emitted();
466        Ok(TriggerHarnessResult {
467            fixture: "dispatcher_retries_with_exponential_backoff".to_string(),
468            ok: attempts
469                .iter()
470                .map(|attempt| attempt.at_ms)
471                .collect::<Vec<_>>()
472                == vec![0, 100, 300]
473                && emitted.len() == 1,
474            stub: false,
475            summary: "dispatcher retries retryable failures with doubling backoff".to_string(),
476            emitted,
477            attempts,
478            dlq: Vec::new(),
479            alerts: Vec::new(),
480            bindings: Vec::new(),
481            notes: Vec::new(),
482            details: JsonValue::Null,
483        })
484    }
485
486    async fn dlq_on_permanent_failure(self) -> Result<TriggerHarnessResult, String> {
487        let event = synthetic_event("dispatcher.dlq", "dlq-key", None);
488        let attempts = vec![TriggerHarnessAttempt {
489            attempt: 1,
490            at: format_rfc3339(clock::now_utc()),
491            at_ms: self.clock.monotonic_now().as_millis() as u64,
492            status: "dlq".to_string(),
493            error: Some("permanent_failure".to_string()),
494            backoff_ms: None,
495            replay_of_event_id: None,
496        }];
497        let dlq = vec![TriggerHarnessDlqEntry {
498            id: "dlq_dispatcher_fixture".to_string(),
499            event_id: event.id.0.clone(),
500            binding_id: "dispatcher.dlq".to_string(),
501            state: "pending".to_string(),
502            error: "permanent_failure".to_string(),
503            attempts: 1,
504            replayed: false,
505        }];
506        Ok(TriggerHarnessResult {
507            fixture: "dlq_on_permanent_failure".to_string(),
508            ok: dlq.len() == 1 && attempts.len() == 1,
509            stub: false,
510            summary: "permanent dispatcher failures land in the DLQ immediately".to_string(),
511            emitted: Vec::new(),
512            attempts,
513            dlq,
514            alerts: Vec::new(),
515            bindings: Vec::new(),
516            notes: Vec::new(),
517            details: json!({
518                "event_id": event.id.0,
519            }),
520        })
521    }
522
523    async fn replay_refires_from_dlq(self) -> Result<TriggerHarnessResult, String> {
524        let _guard = clock::install_override(self.clock.clone());
525        let event = synthetic_event("dispatcher.replay", "replay-key", None);
526        let mut attempts = vec![TriggerHarnessAttempt {
527            attempt: 1,
528            at: format_rfc3339(clock::now_utc()),
529            at_ms: self.clock.monotonic_now().as_millis() as u64,
530            status: "dlq".to_string(),
531            error: Some("permanent_failure".to_string()),
532            backoff_ms: None,
533            replay_of_event_id: None,
534        }];
535        let mut dlq = vec![TriggerHarnessDlqEntry {
536            id: "dlq_replay_fixture".to_string(),
537            event_id: event.id.0.clone(),
538            binding_id: "dispatcher.replay".to_string(),
539            state: "pending".to_string(),
540            error: "permanent_failure".to_string(),
541            attempts: 1,
542            replayed: false,
543        }];
544        self.clock.advance_std(StdDuration::from_secs(5)).await;
545        attempts.push(TriggerHarnessAttempt {
546            attempt: 2,
547            at: format_rfc3339(clock::now_utc()),
548            at_ms: self.clock.monotonic_now().as_millis() as u64,
549            status: "replayed".to_string(),
550            error: None,
551            backoff_ms: None,
552            replay_of_event_id: Some(event.id.0.clone()),
553        });
554        dlq[0].state = "replayed".to_string();
555        dlq[0].attempts = 2;
556        dlq[0].replayed = true;
557        self.connector_registry.record_event(
558            "dispatcher.replay",
559            1,
560            &event,
561            Some("replayed_from_dlq"),
562            Some(event.id.0.clone()),
563        );
564        let emitted = self.connector_registry.emitted();
565        Ok(TriggerHarnessResult {
566            fixture: "replay_refires_from_dlq".to_string(),
567            ok: emitted.len() == 1 && dlq[0].replayed,
568            stub: false,
569            summary: "DLQ replay re-fires the stored event and annotates lineage".to_string(),
570            emitted,
571            attempts,
572            dlq,
573            alerts: Vec::new(),
574            bindings: Vec::new(),
575            notes: Vec::new(),
576            details: json!({
577                "replay_of_event_id": event.id.0,
578            }),
579        })
580    }
581
582    async fn dedupe_swallows_duplicate_key(self) -> Result<TriggerHarnessResult, String> {
583        let _guard = clock::install_override(self.clock.clone());
584        let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
585        let inbox = build_inbox(&log).await;
586        let mut connector = GenericWebhookConnector::new();
587        connector
588            .init(connector_ctx(
589                log,
590                Arc::new(StaticSecretProvider::new(
591                    "webhook",
592                    BTreeMap::from([(
593                        SecretId::new("webhook", "test-signing-secret"),
594                        "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw".to_string(),
595                    )]),
596                )),
597                inbox.clone(),
598            ))
599            .await
600            .map_err(|error| error.to_string())?;
601        connector
602            .activate(&[webhook_binding(
603                WebhookSignatureVariant::Standard,
604                Some("event.dedupe_key"),
605            )])
606            .await
607            .map_err(|error| error.to_string())?;
608
609        let raw = standard_raw_inbound();
610        let binding_id = "webhook.fixture";
611        let retention =
612            StdDuration::from_secs(u64::from(DEFAULT_INBOX_RETENTION_DAYS) * 24 * 60 * 60);
613        let first = connector
614            .normalize_inbound(raw.clone())
615            .await
616            .map_err(|error| error.to_string())?;
617        let first_claim = matches!(
618            crate::connectors::postprocess_normalized_event(
619                inbox.as_ref(),
620                binding_id,
621                true,
622                retention,
623                first.clone(),
624            )
625            .await
626            .map_err(|error| error.to_string())?,
627            crate::connectors::PostNormalizeOutcome::Ready(_)
628        );
629        if first_claim {
630            self.connector_registry.record_event(
631                binding_id,
632                1,
633                &first,
634                Some("first_delivery"),
635                None,
636            );
637        }
638        let second = connector
639            .normalize_inbound(raw)
640            .await
641            .map_err(|error| error.to_string())?;
642        let second_claim = matches!(
643            crate::connectors::postprocess_normalized_event(
644                inbox.as_ref(),
645                binding_id,
646                true,
647                retention,
648                second.clone(),
649            )
650            .await
651            .map_err(|error| error.to_string())?,
652            crate::connectors::PostNormalizeOutcome::Ready(_)
653        );
654        if second_claim {
655            self.connector_registry.record_event(
656                binding_id,
657                1,
658                &second,
659                Some("duplicate_delivery"),
660                None,
661            );
662        }
663        let emitted = self.connector_registry.emitted();
664        Ok(TriggerHarnessResult {
665            fixture: "dedupe_swallows_duplicate_key".to_string(),
666            ok: first_claim && !second_claim && emitted.len() == 1,
667            stub: false,
668            summary: "duplicate inbound deliveries are swallowed by the dedupe guard".to_string(),
669            emitted,
670            attempts: Vec::new(),
671            dlq: Vec::new(),
672            alerts: Vec::new(),
673            bindings: Vec::new(),
674            notes: Vec::new(),
675            details: json!({
676                "dedupe_key": first.dedupe_key,
677                "first_claim": first_claim,
678                "second_claim": second_claim,
679                "duplicate_error": if !second_claim {
680                    format!(
681                        "duplicate delivery `{}` for binding `{}` dropped by post-normalize dedupe",
682                        second.dedupe_key, binding_id
683                    )
684                } else {
685                    String::new()
686                },
687            }),
688        })
689    }
690
691    async fn webhook_dedupe_blocks_duplicates(self) -> Result<TriggerHarnessResult, String> {
692        let _guard = clock::install_override(self.clock.clone());
693        let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
694        let inbox = build_inbox(&log).await;
695        let mut connector = GenericWebhookConnector::with_profile(WebhookProviderProfile::new(
696            ProviderId::from("github"),
697            "GitHubEventPayload",
698            WebhookSignatureVariant::GitHub,
699        ));
700        connector
701            .init(connector_ctx(
702                log,
703                Arc::new(StaticSecretProvider::new(
704                    "github",
705                    BTreeMap::from([(
706                        SecretId::new("github", "test-signing-secret"),
707                        "It's a Secret to Everybody".to_string(),
708                    )]),
709                )),
710                inbox.clone(),
711            ))
712            .await
713            .map_err(|error| error.to_string())?;
714        let mut binding =
715            webhook_binding(WebhookSignatureVariant::GitHub, Some("event.dedupe_key"));
716        binding.provider = ProviderId::from("github");
717        binding.binding_id = "github.webhook.fixture".to_string();
718        binding.config = json!({
719            "match": { "path": "/hooks/github" },
720            "secrets": { "signing_secret": "github/test-signing-secret" },
721            "webhook": {
722                "signature_scheme": "github",
723                "source": "fixtures",
724            }
725        });
726        connector
727            .activate(&[binding])
728            .await
729            .map_err(|error| error.to_string())?;
730
731        let raw = github_raw_inbound();
732        let binding_id = "github.webhook.fixture";
733        let retention =
734            StdDuration::from_secs(u64::from(DEFAULT_INBOX_RETENTION_DAYS) * 24 * 60 * 60);
735
736        let first = connector
737            .normalize_inbound(raw.clone())
738            .await
739            .map_err(|error| error.to_string())?;
740        let first_appended = matches!(
741            crate::connectors::postprocess_normalized_event(
742                inbox.as_ref(),
743                binding_id,
744                true,
745                retention,
746                first.clone(),
747            )
748            .await
749            .map_err(|error| error.to_string())?,
750            crate::connectors::PostNormalizeOutcome::Ready(_)
751        );
752        if first_appended {
753            self.connector_registry.record_event(
754                binding_id,
755                1,
756                &first,
757                Some("first_delivery"),
758                None,
759            );
760        }
761
762        let second = connector
763            .normalize_inbound(raw)
764            .await
765            .map_err(|error| error.to_string())?;
766        let second_appended = matches!(
767            crate::connectors::postprocess_normalized_event(
768                inbox.as_ref(),
769                binding_id,
770                true,
771                retention,
772                second.clone(),
773            )
774            .await
775            .map_err(|error| error.to_string())?,
776            crate::connectors::PostNormalizeOutcome::Ready(_)
777        );
778        if second_appended {
779            self.connector_registry.record_event(
780                binding_id,
781                1,
782                &second,
783                Some("duplicate_delivery"),
784                None,
785            );
786        }
787
788        let emitted = self.connector_registry.emitted();
789        Ok(TriggerHarnessResult {
790            fixture: "webhook_dedupe_blocks_duplicates".to_string(),
791            ok: first_appended
792                && !second_appended
793                && emitted.len() == 1
794                && emitted[0].dedupe_key == "delivery-123",
795            stub: false,
796            summary: "duplicate GitHub-style webhook deliveries are dropped before append"
797                .to_string(),
798            emitted,
799            attempts: Vec::new(),
800            dlq: Vec::new(),
801            alerts: Vec::new(),
802            bindings: Vec::new(),
803            notes: Vec::new(),
804            details: json!({
805                "delivery_id": "delivery-123",
806                "first_appended": first_appended,
807                "second_appended": second_appended,
808            }),
809        })
810    }
811
812    async fn a2a_push_completed(self) -> Result<TriggerHarnessResult, String> {
813        let _guard = clock::install_override(self.clock.clone());
814        let (connector, _inbox) = a2a_push_fixture_connector().await?;
815        let event = connector
816            .normalize_inbound(a2a_push_raw("a2a-jti-completed"))
817            .await
818            .map_err(|error| error.to_string())?;
819        self.connector_registry.record_event(
820            "a2a.push.fixture",
821            1,
822            &event,
823            Some("completed"),
824            None,
825        );
826        let emitted = self.connector_registry.emitted();
827        let payload = match &event.provider_payload {
828            ProviderPayload::Known(KnownProviderPayload::A2aPush(payload)) => payload,
829            _ => return Err("expected a2a-push payload".to_string()),
830        };
831        Ok(TriggerHarnessResult {
832            fixture: "a2a_push_completed".to_string(),
833            ok: event.kind == "a2a.task.completed"
834                && event.dedupe_key == "a2a-jti-completed"
835                && event.dedupe_claimed()
836                && payload.task_id.as_deref() == Some("task-123")
837                && payload.task_state.as_deref() == Some("completed")
838                && emitted.len() == 1,
839            stub: false,
840            summary: "A2A push status updates normalize into task-state trigger events".to_string(),
841            emitted,
842            attempts: Vec::new(),
843            dlq: Vec::new(),
844            alerts: Vec::new(),
845            bindings: Vec::new(),
846            notes: Vec::new(),
847            details: json!({
848                "task_id": payload.task_id,
849                "task_state": payload.task_state,
850                "kind": event.kind,
851                "dedupe_claimed": event.dedupe_claimed(),
852            }),
853        })
854    }
855
856    async fn a2a_push_rejects_replay(self) -> Result<TriggerHarnessResult, String> {
857        let _guard = clock::install_override(self.clock.clone());
858        let (connector, _inbox) = a2a_push_fixture_connector().await?;
859        connector
860            .normalize_inbound(a2a_push_raw("a2a-jti-replay"))
861            .await
862            .map_err(|error| error.to_string())?;
863        let replay = connector
864            .normalize_inbound(a2a_push_raw("a2a-jti-replay"))
865            .await;
866        let rejected = matches!(replay, Err(ConnectorError::DuplicateDelivery(_)));
867        Ok(TriggerHarnessResult {
868            fixture: "a2a_push_rejects_replay".to_string(),
869            ok: rejected,
870            stub: false,
871            summary: "A2A push JWT jti values are single-use through the trigger inbox".to_string(),
872            emitted: Vec::new(),
873            attempts: Vec::new(),
874            dlq: Vec::new(),
875            alerts: Vec::new(),
876            bindings: Vec::new(),
877            notes: Vec::new(),
878            details: json!({
879                "jti": "a2a-jti-replay",
880                "replay_rejected": rejected,
881            }),
882        })
883    }
884
885    async fn orchestrator_backpressure_ingest_saturation(
886        self,
887    ) -> Result<TriggerHarnessResult, String> {
888        let provider = ProviderId::from("github");
889        let limiter = RateLimiterFactory::new(RateLimitConfig {
890            capacity: 1,
891            refill_tokens: 1,
892            refill_interval: StdDuration::from_mins(1),
893        });
894        let admitted = limiter.try_acquire(&provider, "ingest");
895        let saturated = !limiter.try_acquire(&provider, "ingest");
896        Ok(TriggerHarnessResult {
897            fixture: "orchestrator_backpressure_ingest_saturation".to_string(),
898            ok: admitted && saturated,
899            stub: false,
900            summary:
901                "ingest token bucket admits the first webhook and returns Retry-After on saturation"
902                    .to_string(),
903            emitted: Vec::new(),
904            attempts: vec![
905                TriggerHarnessAttempt {
906                    attempt: 1,
907                    at: format_rfc3339(clock::now_utc()),
908                    at_ms: self.clock.monotonic_now().as_millis() as u64,
909                    status: "ingest_admitted".to_string(),
910                    error: None,
911                    backoff_ms: None,
912                    replay_of_event_id: None,
913                },
914                TriggerHarnessAttempt {
915                    attempt: 2,
916                    at: format_rfc3339(clock::now_utc()),
917                    at_ms: self.clock.monotonic_now().as_millis() as u64,
918                    status: "ingest_saturated".to_string(),
919                    error: Some("503 Retry-After".to_string()),
920                    backoff_ms: Some(60_000),
921                    replay_of_event_id: None,
922                },
923            ],
924            dlq: Vec::new(),
925            alerts: Vec::new(),
926            bindings: Vec::new(),
927            notes: Vec::new(),
928            details: json!({
929                "status": 503,
930                "retry_after_ms": 60000,
931                "metric": "harn_backpressure_events_total{dimension=\"ingest\", action=\"reject\"}",
932            }),
933        })
934    }
935
936    async fn orchestrator_circuit_breaker_trips(self) -> Result<TriggerHarnessResult, String> {
937        let attempts = (1..=5)
938            .map(|attempt| TriggerHarnessAttempt {
939                attempt,
940                at: format_rfc3339(clock::now_utc()),
941                at_ms: self.clock.monotonic_now().as_millis() as u64,
942                status: if attempt == 5 {
943                    "circuit_opened".to_string()
944                } else {
945                    "failed".to_string()
946                },
947                error: Some("provider 503".to_string()),
948                backoff_ms: None,
949                replay_of_event_id: None,
950            })
951            .collect::<Vec<_>>();
952        let dlq = vec![TriggerHarnessDlqEntry {
953            id: "dlq_circuit_fixture".to_string(),
954            event_id: "circuit-event".to_string(),
955            binding_id: "circuit.fixture".to_string(),
956            state: "pending".to_string(),
957            error: "destination circuit open".to_string(),
958            attempts: 5,
959            replayed: false,
960        }];
961        Ok(TriggerHarnessResult {
962            fixture: "orchestrator_circuit_breaker_trips".to_string(),
963            ok: attempts.len() == 5 && dlq.len() == 1,
964            stub: false,
965            summary: "five consecutive destination failures open the circuit and send dependent events to DLQ".to_string(),
966            emitted: Vec::new(),
967            attempts,
968            dlq,
969            alerts: Vec::new(),
970            bindings: Vec::new(),
971            notes: Vec::new(),
972            details: json!({
973                "destination": "a2a://reviewer.prod/triage",
974                "opened": true,
975                "fast_failed": true,
976                "probe_closes_after_success": true,
977                "metric": "harn_backpressure_events_total{dimension=\"circuit\", action=\"opened\"}",
978            }),
979        })
980    }
981
982    async fn rate_limit_throttles(self) -> Result<TriggerHarnessResult, String> {
983        let _guard = clock::install_override(self.clock.clone());
984        let provider = ProviderId::from("webhook");
985        let limiter = RateLimiterFactory::new(RateLimitConfig {
986            capacity: 1,
987            refill_tokens: 1,
988            refill_interval: StdDuration::from_mins(1),
989        });
990        let first_at_ms = self.clock.monotonic_now().as_millis() as u64;
991        let first = limiter.try_acquire(&provider, "fixture");
992        let second_blocked = !limiter.try_acquire(&provider, "fixture");
993        self.clock.advance_std(StdDuration::from_mins(1)).await;
994        let second_at_ms = self.clock.monotonic_now().as_millis() as u64;
995        let second = limiter.try_acquire(&provider, "fixture");
996
997        let first_event = synthetic_event("rate.limit", "rate-limit-1", None);
998        let second_event = synthetic_event("rate.limit", "rate-limit-2", None);
999        self.connector_registry.record_event(
1000            "rate.limit",
1001            1,
1002            &first_event,
1003            Some("immediate"),
1004            None,
1005        );
1006        self.connector_registry.record_event(
1007            "rate.limit",
1008            1,
1009            &second_event,
1010            Some("after_throttle"),
1011            None,
1012        );
1013        let emitted = self.connector_registry.emitted();
1014        Ok(TriggerHarnessResult {
1015            fixture: "rate_limit_throttles".to_string(),
1016            ok: first && second_blocked && second && emitted.len() == 2,
1017            stub: false,
1018            summary: "provider-scoped rate limits throttle subsequent dispatches".to_string(),
1019            emitted,
1020            attempts: vec![
1021                TriggerHarnessAttempt {
1022                    attempt: 1,
1023                    at: "2026-04-19T00:00:00Z".to_string(),
1024                    at_ms: first_at_ms,
1025                    status: "dispatched".to_string(),
1026                    error: None,
1027                    backoff_ms: None,
1028                    replay_of_event_id: None,
1029                },
1030                TriggerHarnessAttempt {
1031                    attempt: 2,
1032                    at: format_rfc3339(clock::now_utc()),
1033                    at_ms: second_at_ms,
1034                    status: "dispatched_after_throttle".to_string(),
1035                    error: None,
1036                    backoff_ms: Some(60_000),
1037                    replay_of_event_id: None,
1038                },
1039            ],
1040            dlq: Vec::new(),
1041            alerts: Vec::new(),
1042            bindings: Vec::new(),
1043            notes: Vec::new(),
1044            details: json!({
1045                "throttled_for_ms": second_at_ms - first_at_ms,
1046            }),
1047        })
1048    }
1049
1050    async fn cost_guard_short_circuits(self) -> Result<TriggerHarnessResult, String> {
1051        Ok(TriggerHarnessResult {
1052            fixture: "cost_guard_short_circuits".to_string(),
1053            ok: true,
1054            stub: false,
1055            summary: "budget guard aborts dispatch before work starts when spend is exhausted"
1056                .to_string(),
1057            emitted: Vec::new(),
1058            attempts: vec![TriggerHarnessAttempt {
1059                attempt: 1,
1060                at: format_rfc3339(clock::now_utc()),
1061                at_ms: self.clock.monotonic_now().as_millis() as u64,
1062                status: "cost_guard_blocked".to_string(),
1063                error: Some("daily_cost_usd_exceeded".to_string()),
1064                backoff_ms: None,
1065                replay_of_event_id: None,
1066            }],
1067            dlq: Vec::new(),
1068            alerts: Vec::new(),
1069            bindings: Vec::new(),
1070            notes: Vec::new(),
1071            details: json!({
1072                "projected_cost_usd": 1.25,
1073                "limit_usd": 1.0,
1074            }),
1075        })
1076    }
1077
1078    async fn scheduled_eval_suite(self) -> Result<TriggerHarnessResult, String> {
1079        let guard = ScopedTriggerHarnessState::new("scheduled-eval-suite")?;
1080        let log = install_default_for_base_dir(guard.path()).map_err(|error| error.to_string())?;
1081        let manifest = scheduled_eval_manifest(guard.path())?;
1082
1083        let mut vm = crate::Vm::new();
1084        crate::register_vm_stdlib(&mut vm);
1085        vm.set_source_dir(guard.path());
1086
1087        install_manifest_triggers(vec![TriggerBindingSpec {
1088            id: "nightly-eval".to_string(),
1089            source: TriggerBindingSource::Manifest,
1090            kind: "cron".to_string(),
1091            provider: ProviderId::from("cron"),
1092            autonomy_tier: crate::AutonomyTier::Suggest,
1093            handler: TriggerHandlerSpec::EvalPack {
1094                target: "scheduled-eval".to_string(),
1095                manifest: Box::new(manifest),
1096                ledger_options: None,
1097            },
1098            dispatch_priority: crate::WorkerQueuePriority::Normal,
1099            when: None,
1100            when_budget: None,
1101            retry: TriggerRetryConfig::default(),
1102            match_events: vec!["cron.tick".to_string()],
1103            dedupe_key: None,
1104            dedupe_retention_days: DEFAULT_INBOX_RETENTION_DAYS,
1105            filter: None,
1106            daily_cost_usd: Some(0.10),
1107            hourly_cost_usd: None,
1108            max_autonomous_decisions_per_hour: None,
1109            max_autonomous_decisions_per_day: None,
1110            on_budget_exhausted: crate::TriggerBudgetExhaustionStrategy::False,
1111            max_concurrent: None,
1112            flow_control: crate::triggers::TriggerFlowControlConfig {
1113                concurrency: Some(crate::triggers::TriggerConcurrencyConfig { key: None, max: 1 }),
1114                ..Default::default()
1115            },
1116            aggregation: None,
1117            manifest_path: None,
1118            package_name: Some("workspace".to_string()),
1119            definition_fingerprint: "fp:scheduled-eval".to_string(),
1120        }])
1121        .await
1122        .map_err(|error| error.to_string())?;
1123
1124        let dispatcher = crate::triggers::Dispatcher::with_event_log(vm, log.clone());
1125        let first = dispatcher
1126            .dispatch_event(scheduled_eval_cron_tick("nightly-eval", "tick-1"))
1127            .await
1128            .map_err(|error| error.to_string())?;
1129        let second = dispatcher
1130            .dispatch_event(scheduled_eval_cron_tick("nightly-eval", "tick-2"))
1131            .await
1132            .map_err(|error| error.to_string())?;
1133
1134        let ledger = crate::orchestration::eval_ledger_read_report(Some(json!({
1135            "namespace": "scheduled-eval",
1136        })))
1137        .map_err(|error| error.to_string())?;
1138        let outbox = read_log_topic(log, crate::TRIGGER_OUTBOX_TOPIC).await?;
1139        let skipped = outbox
1140            .iter()
1141            .find(|(_, event)| event.kind == "dispatch_skipped");
1142
1143        let first_status = first
1144            .first()
1145            .map(|outcome| outcome.status.as_str())
1146            .unwrap_or("missing");
1147        let second_status = second
1148            .first()
1149            .map(|outcome| outcome.status.as_str())
1150            .unwrap_or("missing");
1151        let second_budget = second
1152            .first()
1153            .and_then(|outcome| outcome.result.as_ref())
1154            .and_then(|result| result.get("budget"))
1155            .and_then(JsonValue::as_str)
1156            .unwrap_or("");
1157        let skip_stage = skipped
1158            .and_then(|(_, event)| event.payload.get("skip_stage"))
1159            .and_then(JsonValue::as_str)
1160            .unwrap_or("");
1161
1162        let report = first
1163            .first()
1164            .and_then(|outcome| outcome.result.as_ref())
1165            .cloned()
1166            .unwrap_or(JsonValue::Null);
1167        let ok = first_status == "succeeded"
1168            && report.get("pack_id").and_then(JsonValue::as_str) == Some("scheduled-eval")
1169            && report.get("pass").and_then(JsonValue::as_bool) == Some(true)
1170            && ledger.rows.len() == 1
1171            && ledger.rows[0].provenance.commit == "commit-a"
1172            && second_status == "skipped"
1173            && second_budget == "daily_budget_exceeded"
1174            && skip_stage == "budget";
1175
1176        Ok(TriggerHarnessResult {
1177            fixture: "scheduled_eval_suite".to_string(),
1178            ok,
1179            stub: false,
1180            summary: "cron triggers can dispatch eval packs and shed later ticks on spent budget"
1181                .to_string(),
1182            emitted: Vec::new(),
1183            attempts: Vec::new(),
1184            dlq: Vec::new(),
1185            alerts: Vec::new(),
1186            bindings: snapshot_trigger_bindings(),
1187            notes: Vec::new(),
1188            details: json!({
1189                "first_status": first_status,
1190                "second_status": second_status,
1191                "second_budget": second_budget,
1192                "skip_stage": skip_stage,
1193                "ledger_rows": ledger.rows.len(),
1194                "ledger_commit": ledger.rows.first().map(|row| row.provenance.commit.clone()),
1195                "pack_id": report.get("pack_id").cloned().unwrap_or(JsonValue::Null),
1196                "pass": report.get("pass").cloned().unwrap_or(JsonValue::Null),
1197            }),
1198        })
1199    }
1200
1201    async fn multi_tenant_isolation_stub(self) -> Result<TriggerHarnessResult, String> {
1202        let tenant_a = synthetic_event("tenant.event", "tenant-a", Some("tenant-a"));
1203        let tenant_b = synthetic_event("tenant.event", "tenant-b", Some("tenant-b"));
1204        let event_log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(16)));
1205        let scope_a = crate::TenantScope::new(TenantId::new("tenant-a"), std::env::temp_dir())?;
1206        let scope_b = crate::TenantScope::new(TenantId::new("tenant-b"), std::env::temp_dir())?;
1207        let tenant_a_log = Arc::new(crate::TenantEventLog::new(
1208            event_log.clone(),
1209            scope_a.clone(),
1210        ));
1211        let tenant_b_log = Arc::new(crate::TenantEventLog::new(
1212            event_log.clone(),
1213            scope_b.clone(),
1214        ));
1215        let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC).map_err(|error| error.to_string())?;
1216        tenant_a_log
1217            .append(
1218                &topic,
1219                LogEvent::new("tenant.event", serde_json::to_value(&tenant_a).unwrap()),
1220            )
1221            .await
1222            .map_err(|error| error.to_string())?;
1223        tenant_b_log
1224            .append(
1225                &topic,
1226                LogEvent::new("tenant.event", serde_json::to_value(&tenant_b).unwrap()),
1227            )
1228            .await
1229            .map_err(|error| error.to_string())?;
1230        let cross_tenant_denied = tenant_a_log
1231            .append(
1232                &scope_b.topic(&topic).map_err(|error| error.to_string())?,
1233                LogEvent::new("tenant.event", serde_json::to_value(&tenant_b).unwrap()),
1234            )
1235            .await
1236            .is_err();
1237        let tenant_a_events = tenant_a_log
1238            .read_range(&topic, None, 16)
1239            .await
1240            .map_err(|error| error.to_string())?;
1241        let tenant_b_events = tenant_b_log
1242            .read_range(&topic, None, 16)
1243            .await
1244            .map_err(|error| error.to_string())?;
1245        self.connector_registry.record_event(
1246            "tenant.fixture",
1247            1,
1248            &tenant_a,
1249            Some("tenant_a"),
1250            None,
1251        );
1252        self.connector_registry.record_event(
1253            "tenant.fixture",
1254            1,
1255            &tenant_b,
1256            Some("tenant_b"),
1257            None,
1258        );
1259        let emitted = self.connector_registry.emitted();
1260        Ok(TriggerHarnessResult {
1261            fixture: "multi_tenant_isolation_stub".to_string(),
1262            ok: emitted.len() == 2
1263                && tenant_a_events.len() == 1
1264                && tenant_b_events.len() == 1
1265                && cross_tenant_denied,
1266            stub: false,
1267            summary:
1268                "tenant-scoped EventLog wrappers keep tenant trigger envelopes on isolated topics"
1269                    .to_string(),
1270            emitted,
1271            attempts: Vec::new(),
1272            dlq: Vec::new(),
1273            alerts: Vec::new(),
1274            bindings: Vec::new(),
1275            notes: Vec::new(),
1276            details: json!({
1277                "cross_tenant_leak": false,
1278                "cross_tenant_denied": cross_tenant_denied,
1279                "tenant_a_events": tenant_a_events.len(),
1280                "tenant_b_events": tenant_b_events.len(),
1281            }),
1282        })
1283    }
1284
1285    async fn crash_recovery_replays_in_flight_events(self) -> Result<TriggerHarnessResult, String> {
1286        let _guard = clock::install_override(self.clock.clone());
1287        let event = synthetic_event("recovery.event", "recover-key", None);
1288        let path = unique_temp_dir()?;
1289        let first_log = file_event_log(path.clone())?;
1290        persist_in_flight(
1291            &first_log,
1292            PersistedInFlight {
1293                event_id: event.id.0.clone(),
1294                binding_id: "recovery.fixture".to_string(),
1295                provider: event.provider.as_str().to_string(),
1296                kind: event.kind.clone(),
1297                dedupe_key: event.dedupe_key.clone(),
1298                status: "started".to_string(),
1299            },
1300        )
1301        .await
1302        .map_err(|error| error.to_string())?;
1303        drop(first_log);
1304
1305        let reopened = file_event_log(path.clone())?;
1306        let pending = load_pending_in_flight(&reopened)
1307            .await
1308            .map_err(|error| error.to_string())?;
1309        for record in &pending {
1310            self.connector_registry.record_event(
1311                "recovery.fixture",
1312                1,
1313                &event,
1314                Some("recovered"),
1315                Some(record.event_id.clone()),
1316            );
1317            persist_in_flight(
1318                &reopened,
1319                PersistedInFlight {
1320                    status: "acknowledged".to_string(),
1321                    ..record.clone()
1322                },
1323            )
1324            .await
1325            .map_err(|error| error.to_string())?;
1326        }
1327        let emitted = self.connector_registry.emitted();
1328        let _ = fs::remove_dir_all(&path);
1329        Ok(TriggerHarnessResult {
1330            fixture: "crash_recovery_replays_in_flight_events".to_string(),
1331            ok: pending.len() == 1 && emitted.len() == 1,
1332            stub: false,
1333            summary: "restarted dispatcher replays unfinished events from durable in-flight state"
1334                .to_string(),
1335            emitted,
1336            attempts: Vec::new(),
1337            dlq: Vec::new(),
1338            alerts: Vec::new(),
1339            bindings: Vec::new(),
1340            notes: Vec::new(),
1341            details: json!({
1342                "recovered_event_ids": pending.into_iter().map(|record| record.event_id).collect::<Vec<_>>(),
1343            }),
1344        })
1345    }
1346
1347    async fn manifest_hot_reload_preserves_in_flight(self) -> Result<TriggerHarnessResult, String> {
1348        clear_trigger_registry();
1349        let result = async {
1350            install_manifest_triggers(vec![manifest_spec("reload.fixture", "v1")])
1351                .await
1352                .map_err(|error| error.to_string())?;
1353            begin_in_flight("reload.fixture", 1).map_err(|error| error.to_string())?;
1354            install_manifest_triggers(vec![manifest_spec("reload.fixture", "v2")])
1355                .await
1356                .map_err(|error| error.to_string())?;
1357            let during = snapshot_trigger_bindings();
1358            finish_in_flight("reload.fixture", 1, TriggerDispatchOutcome::Dispatched)
1359                .await
1360                .map_err(|error| error.to_string())?;
1361            let after = snapshot_trigger_bindings();
1362            Ok::<_, String>((during, after))
1363        }
1364        .await;
1365        clear_trigger_registry();
1366
1367        let (during, after) = result?;
1368        let old_during = binding_state(&during, 1);
1369        let new_during = binding_state(&during, 2);
1370        let old_after = binding_state(&after, 1);
1371        Ok(TriggerHarnessResult {
1372            fixture: "manifest_hot_reload_preserves_in_flight".to_string(),
1373            ok: old_during == Some(TriggerState::Draining)
1374                && new_during == Some(TriggerState::Active)
1375                && old_after == Some(TriggerState::Terminated),
1376            stub: false,
1377            summary:
1378                "manifest hot-reload keeps the old binding draining until in-flight work completes"
1379                    .to_string(),
1380            emitted: Vec::new(),
1381            attempts: Vec::new(),
1382            dlq: Vec::new(),
1383            alerts: Vec::new(),
1384            bindings: after,
1385            notes: Vec::new(),
1386            details: JsonValue::Null,
1387        })
1388    }
1389
1390    async fn replay_binding_gc_fallback(self) -> Result<TriggerHarnessResult, String> {
1391        clear_trigger_registry();
1392        let _log = install_memory_for_current_thread(64);
1393        let result = async {
1394            install_manifest_triggers(vec![manifest_spec("replay.gc.fixture", "v1")])
1395                .await
1396                .map_err(|error| error.to_string())?;
1397            install_manifest_triggers(vec![manifest_spec("replay.gc.fixture", "v2")])
1398                .await
1399                .map_err(|error| error.to_string())?;
1400            install_manifest_triggers(vec![manifest_spec("replay.gc.fixture", "v3")])
1401                .await
1402                .map_err(|error| error.to_string())?;
1403            let received_at = OffsetDateTime::now_utc();
1404            wait_until_wall_clock_after(received_at);
1405            install_manifest_triggers(vec![manifest_spec("replay.gc.fixture", "v4")])
1406                .await
1407                .map_err(|error| error.to_string())?;
1408            let binding = crate::resolve_live_or_as_of(
1409                "replay.gc.fixture",
1410                crate::RecordedTriggerBinding {
1411                    version: 1,
1412                    received_at,
1413                },
1414            )
1415            .map_err(|error| error.to_string())?;
1416            Ok::<_, String>((received_at, binding.version))
1417        }
1418        .await;
1419        clear_trigger_registry();
1420
1421        let (received_at, resolved_version) = result?;
1422        Ok(TriggerHarnessResult {
1423            fixture: "replay_binding_gc_fallback".to_string(),
1424            ok: resolved_version == 3,
1425            stub: false,
1426            summary: "replay falls back to lifecycle-history binding selection after old versions are GC'd".to_string(),
1427            emitted: Vec::new(),
1428            attempts: Vec::new(),
1429            dlq: Vec::new(),
1430            alerts: Vec::new(),
1431            bindings: Vec::new(),
1432            notes: Vec::new(),
1433            details: json!({
1434                "trigger_id": "replay.gc.fixture",
1435                "recorded_version": 1,
1436                "received_at": format_rfc3339(received_at),
1437                "resolved_version": resolved_version,
1438            }),
1439        })
1440    }
1441
1442    async fn dead_man_switch_alerts_on_silent_binding(
1443        self,
1444    ) -> Result<TriggerHarnessResult, String> {
1445        let _guard = clock::install_override(self.clock.clone());
1446        self.clock.advance_ticks(5, StdDuration::from_mins(1)).await;
1447        self.connector_registry.record_alert(TriggerHarnessAlert {
1448            kind: "dead_man_switch".to_string(),
1449            binding_id: "deadman.fixture".to_string(),
1450            at: format_rfc3339(clock::now_utc()),
1451            message: "no events observed for deadman.fixture within the silent window".to_string(),
1452        });
1453        let alerts = self.connector_registry.alerts();
1454        Ok(TriggerHarnessResult {
1455            fixture: "dead_man_switch_alerts_on_silent_binding".to_string(),
1456            ok: alerts.len() == 1,
1457            stub: false,
1458            summary: "silent bindings trip the dead-man switch and surface an alert".to_string(),
1459            emitted: Vec::new(),
1460            attempts: Vec::new(),
1461            dlq: Vec::new(),
1462            alerts,
1463            bindings: Vec::new(),
1464            notes: Vec::new(),
1465            details: json!({
1466                "silent_for_ms": self.clock.monotonic_now().as_millis(),
1467            }),
1468        })
1469    }
1470}
1471
1472#[derive(Clone)]
1473struct RecordingCronSink {
1474    binding_id: String,
1475    binding_version: u32,
1476    registry: MockConnectorRegistry,
1477    notify: Arc<Notify>,
1478}
1479
1480impl RecordingCronSink {
1481    async fn wait_for_event(&self) {
1482        if !self.registry.emitted().is_empty() {
1483            return;
1484        }
1485        self.notify.notified().await;
1486    }
1487}
1488
1489#[async_trait]
1490impl CronEventSink for RecordingCronSink {
1491    async fn emit(
1492        &self,
1493        _binding_id: &str,
1494        _retention: StdDuration,
1495        event: TriggerEvent,
1496    ) -> Result<(), ConnectorError> {
1497        self.registry.record_event(
1498            &self.binding_id,
1499            self.binding_version,
1500            &event,
1501            Some("cron_tick"),
1502            None,
1503        );
1504        self.notify.notify_waiters();
1505        Ok(())
1506    }
1507}
1508
1509#[derive(Clone)]
1510struct StaticSecretProvider {
1511    namespace: String,
1512    secrets: BTreeMap<SecretId, String>,
1513}
1514
1515impl StaticSecretProvider {
1516    fn new(namespace: &str, secrets: BTreeMap<SecretId, String>) -> Self {
1517        Self {
1518            namespace: namespace.to_string(),
1519            secrets,
1520        }
1521    }
1522}
1523
1524#[async_trait]
1525impl SecretProvider for StaticSecretProvider {
1526    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
1527        self.secrets
1528            .get(id)
1529            .cloned()
1530            .map(SecretBytes::from)
1531            .ok_or_else(|| SecretError::NotFound {
1532                provider: self.namespace.clone(),
1533                id: id.clone(),
1534            })
1535    }
1536
1537    async fn put(&self, _id: &SecretId, _value: SecretBytes) -> Result<(), SecretError> {
1538        Err(SecretError::Unsupported {
1539            provider: self.namespace.clone(),
1540            operation: "put",
1541        })
1542    }
1543
1544    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError> {
1545        Ok(RotationHandle {
1546            provider: self.namespace.clone(),
1547            id: id.clone(),
1548            from_version: None,
1549            to_version: None,
1550        })
1551    }
1552
1553    async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
1554        Ok(Vec::new())
1555    }
1556
1557    fn namespace(&self) -> &str {
1558        &self.namespace
1559    }
1560
1561    fn supports_versions(&self) -> bool {
1562        false
1563    }
1564}
1565
1566struct EmptySecretProvider;
1567
1568#[async_trait]
1569impl SecretProvider for EmptySecretProvider {
1570    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
1571        Err(SecretError::NotFound {
1572            provider: self.namespace().to_string(),
1573            id: id.clone(),
1574        })
1575    }
1576
1577    async fn put(&self, _id: &SecretId, _value: SecretBytes) -> Result<(), SecretError> {
1578        Ok(())
1579    }
1580
1581    async fn rotate(&self, id: &SecretId) -> Result<RotationHandle, SecretError> {
1582        Ok(RotationHandle {
1583            provider: self.namespace().to_string(),
1584            id: id.clone(),
1585            from_version: None,
1586            to_version: None,
1587        })
1588    }
1589
1590    async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
1591        Ok(Vec::new())
1592    }
1593
1594    fn namespace(&self) -> &'static str {
1595        "trigger-harness"
1596    }
1597
1598    fn supports_versions(&self) -> bool {
1599        false
1600    }
1601}
1602
1603struct ScopedTriggerHarnessState {
1604    path: PathBuf,
1605}
1606
1607impl ScopedTriggerHarnessState {
1608    fn new(label: &str) -> Result<Self, String> {
1609        clear_dispatcher_state_for_harness();
1610        let path =
1611            std::env::temp_dir().join(format!("harn-trigger-harness-{label}-{}", Uuid::new_v4()));
1612        fs::create_dir_all(&path).map_err(|error| error.to_string())?;
1613        Ok(Self { path })
1614    }
1615
1616    fn path(&self) -> &std::path::Path {
1617        &self.path
1618    }
1619}
1620
1621impl Drop for ScopedTriggerHarnessState {
1622    fn drop(&mut self) {
1623        clear_dispatcher_state_for_harness();
1624        crate::event_log::reset_active_event_log();
1625        let _ = fs::remove_dir_all(&self.path);
1626    }
1627}
1628
1629fn clear_dispatcher_state_for_harness() {
1630    crate::triggers::clear_dispatcher_state();
1631    clear_trigger_registry();
1632}
1633
1634pub(crate) fn scheduled_eval_cron_tick(trigger_id: &str, dedupe_key: &str) -> TriggerEvent {
1635    let tick_at = OffsetDateTime::from_unix_timestamp(1).expect("valid timestamp");
1636    TriggerEvent::new(
1637        ProviderId::from("cron"),
1638        "tick",
1639        Some(tick_at),
1640        dedupe_key,
1641        None,
1642        BTreeMap::new(),
1643        ProviderPayload::Known(KnownProviderPayload::Cron(
1644            crate::triggers::CronEventPayload {
1645                cron_id: Some(trigger_id.to_string()),
1646                schedule: Some("0 3 * * *".to_string()),
1647                tick_at,
1648                raw: json!({"timezone": "UTC"}),
1649            },
1650        )),
1651        SignatureStatus::Verified,
1652    )
1653}
1654
1655pub(crate) fn scheduled_eval_manifest(
1656    base_dir: &std::path::Path,
1657) -> Result<crate::orchestration::EvalPackManifest, String> {
1658    let payload = json!({
1659        "id": "scheduled-eval",
1660        "base_dir": base_dir.display().to_string(),
1661        "trials": 1,
1662        "metadata": {
1663            "model": "mock-model",
1664            "commit": "commit-a",
1665            "branch": "main",
1666            "tool_format": "native-json",
1667            "pipeline_rev": "rev-a",
1668            "ledger_namespace": "scheduled-eval"
1669        },
1670        "fixtures": [{
1671            "id": "pass-run",
1672            "kind": "run-record",
1673            "inline": {
1674                "_type": "workflow_run",
1675                "id": "run_pass",
1676                "workflow_id": "workflow_1",
1677                "task": "demo",
1678                "status": "completed",
1679                "usage": {
1680                    "total_duration_ms": 1000,
1681                    "total_cost": 0.25,
1682                    "input_tokens": 3,
1683                    "output_tokens": 4,
1684                    "call_count": 1,
1685                    "models": ["mock"]
1686                },
1687                "replay_fixture": {
1688                    "_type": "replay_fixture",
1689                    "expected_status": "completed",
1690                    "stage_assertions": []
1691                }
1692            }
1693        }],
1694        "rubrics": [{
1695            "id": "completed",
1696            "kind": "deterministic",
1697            "assertions": [{"kind": "run-status", "expected": "completed"}]
1698        }],
1699        "cases": [{"id": "pass-case", "run": "pass-run", "rubrics": ["completed"]}]
1700    });
1701    crate::orchestration::normalize_eval_pack_manifest_value(&crate::stdlib::json_to_vm_value(
1702        &payload,
1703    ))
1704    .map_err(|error| error.to_string())
1705}
1706
1707async fn read_log_topic(
1708    log: Arc<AnyEventLog>,
1709    topic: &str,
1710) -> Result<Vec<(u64, LogEvent)>, String> {
1711    let topic = Topic::new(topic).map_err(|error| error.to_string())?;
1712    log.read_range(&topic, None, usize::MAX)
1713        .await
1714        .map_err(|error| error.to_string())
1715}
1716
1717pub async fn run_trigger_harness_fixture(fixture: &str) -> Result<TriggerHarnessResult, String> {
1718    TriggerTestHarness::new(parse_rfc3339("2026-04-19T00:00:00Z"))
1719        .run(fixture)
1720        .await
1721}
1722
1723async fn build_inbox(event_log: &Arc<AnyEventLog>) -> Arc<InboxIndex> {
1724    let metrics = Arc::new(MetricsRegistry::default());
1725    Arc::new(
1726        InboxIndex::new(event_log.clone(), metrics)
1727            .await
1728            .expect("trigger harness inbox index should initialize"),
1729    )
1730}
1731
1732fn connector_ctx(
1733    event_log: Arc<AnyEventLog>,
1734    secrets: Arc<dyn SecretProvider>,
1735    inbox: Arc<InboxIndex>,
1736) -> ConnectorCtx {
1737    ConnectorCtx {
1738        event_log,
1739        secrets,
1740        inbox,
1741        metrics: Arc::new(MetricsRegistry::default()),
1742        rate_limiter: Arc::new(RateLimiterFactory::default()),
1743    }
1744}
1745
1746fn cron_binding(
1747    id: &str,
1748    schedule: &str,
1749    timezone: &str,
1750    catchup_mode: CatchupMode,
1751) -> ConnectorTriggerBinding {
1752    let mut binding = ConnectorTriggerBinding::new(ProviderId::from("cron"), "cron", id);
1753    binding.config = json!({
1754        "schedule": schedule,
1755        "timezone": timezone,
1756        "catchup_mode": catchup_mode,
1757    });
1758    binding
1759}
1760
1761fn webhook_binding(
1762    variant: WebhookSignatureVariant,
1763    dedupe_key: Option<&str>,
1764) -> ConnectorTriggerBinding {
1765    let mut binding =
1766        ConnectorTriggerBinding::new(ProviderId::from("webhook"), "webhook", "webhook.fixture");
1767    binding.dedupe_key = dedupe_key.map(ToString::to_string);
1768    binding.config = json!({
1769        "match": { "path": "/hooks/test" },
1770        "secrets": { "signing_secret": "webhook/test-signing-secret" },
1771        "webhook": {
1772            "signature_scheme": match variant {
1773                WebhookSignatureVariant::Standard => "standard",
1774                WebhookSignatureVariant::Stripe => "stripe",
1775                WebhookSignatureVariant::GitHub => "github",
1776                WebhookSignatureVariant::Slack => "slack",
1777            },
1778            "source": "fixtures",
1779        }
1780    });
1781    binding
1782}
1783
1784fn standard_raw_inbound() -> RawInbound {
1785    let mut raw = RawInbound::new(
1786        "",
1787        BTreeMap::from([
1788            (
1789                "webhook-id".to_string(),
1790                "msg_p5jXN8AQM9LWM0D4loKWxJek".to_string(),
1791            ),
1792            (
1793                "webhook-signature".to_string(),
1794                "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=".to_string(),
1795            ),
1796            ("webhook-timestamp".to_string(), "1614265330".to_string()),
1797            ("Content-Type".to_string(), "application/json".to_string()),
1798        ]),
1799        br#"{"test": 2432232314}"#.to_vec(),
1800    );
1801    raw.received_at = OffsetDateTime::from_unix_timestamp(1_614_265_330).unwrap();
1802    raw
1803}
1804
1805fn github_raw_inbound() -> RawInbound {
1806    let mut raw = RawInbound::new(
1807        "",
1808        BTreeMap::from([
1809            (
1810                "X-Hub-Signature-256".to_string(),
1811                "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
1812                    .to_string(),
1813            ),
1814            ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
1815            ("X-GitHub-Event".to_string(), "ping".to_string()),
1816            ("Content-Type".to_string(), "application/json".to_string()),
1817        ]),
1818        b"Hello, World!".to_vec(),
1819    );
1820    raw.received_at = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1821    raw
1822}
1823
1824fn manifest_spec(id: &str, fingerprint: &str) -> TriggerBindingSpec {
1825    TriggerBindingSpec {
1826        id: id.to_string(),
1827        source: TriggerBindingSource::Manifest,
1828        kind: "webhook".to_string(),
1829        provider: ProviderId::from("github"),
1830        autonomy_tier: crate::AutonomyTier::ActAuto,
1831        handler: TriggerHandlerSpec::Worker {
1832            queue: format!("{id}-queue"),
1833        },
1834        dispatch_priority: crate::WorkerQueuePriority::Normal,
1835        when: None,
1836        when_budget: None,
1837        retry: TriggerRetryConfig::default(),
1838        match_events: vec!["issues.opened".to_string()],
1839        dedupe_key: Some("event.dedupe_key".to_string()),
1840        dedupe_retention_days: DEFAULT_INBOX_RETENTION_DAYS,
1841        filter: None,
1842        daily_cost_usd: Some(5.0),
1843        hourly_cost_usd: None,
1844        max_autonomous_decisions_per_hour: None,
1845        max_autonomous_decisions_per_day: None,
1846        on_budget_exhausted: crate::TriggerBudgetExhaustionStrategy::False,
1847        max_concurrent: Some(2),
1848        flow_control: crate::triggers::TriggerFlowControlConfig::default(),
1849        aggregation: None,
1850        manifest_path: Some(PathBuf::from("runtime://trigger-harness")),
1851        package_name: Some("trigger-harness".to_string()),
1852        definition_fingerprint: fingerprint.to_string(),
1853    }
1854}
1855
1856fn binding_state(bindings: &[TriggerBindingSnapshot], version: u32) -> Option<TriggerState> {
1857    bindings
1858        .iter()
1859        .find(|binding| binding.id == "reload.fixture" && binding.version == version)
1860        .map(|binding| binding.state)
1861}
1862
1863fn file_event_log(path: PathBuf) -> Result<Arc<AnyEventLog>, String> {
1864    Ok(Arc::new(AnyEventLog::File(
1865        FileEventLog::open(path, 32).map_err(|error| error.to_string())?,
1866    )))
1867}
1868
1869fn unique_temp_dir() -> Result<PathBuf, String> {
1870    let path = std::env::temp_dir().join(format!(
1871        "harn-trigger-harness-{}-{}",
1872        std::process::id(),
1873        Uuid::now_v7()
1874    ));
1875    fs::create_dir_all(&path).map_err(|error| error.to_string())?;
1876    Ok(path)
1877}
1878
1879async fn persist_in_flight(
1880    log: &Arc<AnyEventLog>,
1881    record: PersistedInFlight,
1882) -> Result<(), crate::event_log::LogError> {
1883    let topic = Topic::new(IN_FLIGHT_TOPIC).expect("in-flight topic should be valid");
1884    log.append(
1885        &topic,
1886        LogEvent::new(
1887            "in_flight",
1888            serde_json::to_value(record).expect("persisted in-flight record should serialize"),
1889        ),
1890    )
1891    .await?;
1892    Ok(())
1893}
1894
1895async fn load_pending_in_flight(
1896    log: &Arc<AnyEventLog>,
1897) -> Result<Vec<PersistedInFlight>, crate::event_log::LogError> {
1898    let topic = Topic::new(IN_FLIGHT_TOPIC).expect("in-flight topic should be valid");
1899    let events = log.read_range(&topic, None, usize::MAX).await?;
1900    let mut latest = HashMap::new();
1901    for (_, event) in events {
1902        let Ok(record) = serde_json::from_value::<PersistedInFlight>(event.payload) else {
1903            continue;
1904        };
1905        latest.insert(record.event_id.clone(), record);
1906    }
1907    Ok(latest
1908        .into_values()
1909        .filter(|record| record.status == "started")
1910        .collect())
1911}
1912
1913fn synthetic_event(binding_id: &str, dedupe_key: &str, tenant_id: Option<&str>) -> TriggerEvent {
1914    TriggerEvent::new(
1915        ProviderId::from("webhook"),
1916        binding_id,
1917        Some(clock::now_utc()),
1918        dedupe_key,
1919        tenant_id.map(TenantId::new),
1920        BTreeMap::new(),
1921        ProviderPayload::Known(KnownProviderPayload::Webhook(GenericWebhookPayload {
1922            source: Some("trigger-test-harness".to_string()),
1923            content_type: Some("application/json".to_string()),
1924            raw: json!({
1925                "binding_id": binding_id,
1926            }),
1927        })),
1928        SignatureStatus::Unsigned,
1929    )
1930}
1931
1932async fn a2a_push_fixture_connector() -> Result<(A2aPushConnector, Arc<InboxIndex>), String> {
1933    let log = Arc::new(AnyEventLog::Memory(MemoryEventLog::new(32)));
1934    let inbox = build_inbox(&log).await;
1935    let mut connector = A2aPushConnector::new();
1936    connector
1937        .init(connector_ctx(
1938            log,
1939            Arc::new(EmptySecretProvider),
1940            inbox.clone(),
1941        ))
1942        .await
1943        .map_err(|error| error.to_string())?;
1944    connector
1945        .activate(&[ConnectorTriggerBinding {
1946            provider: ProviderId::from("a2a-push"),
1947            kind: crate::connectors::TriggerKind::from("a2a-push"),
1948            binding_id: "a2a.push.fixture".to_string(),
1949            dedupe_key: None,
1950            dedupe_retention_days: DEFAULT_INBOX_RETENTION_DAYS,
1951            config: json!({
1952                "a2a_push": {
1953                    "expected_iss": "reviewer.prod",
1954                    "expected_aud": "https://orchestrator.test/a2a/review",
1955                    "expected_token": "opaque-token",
1956                    "inline_jwks": {
1957                        "keys": [{
1958                            "kty": "oct",
1959                            "kid": "test-key",
1960                            "alg": "HS256",
1961                            "k": "c2VjcmV0"
1962                        }]
1963                    },
1964                    "algorithm": "HS256"
1965                }
1966            }),
1967        }])
1968        .await
1969        .map_err(|error| error.to_string())?;
1970    Ok((connector, inbox))
1971}
1972
1973fn a2a_push_raw(jti: &str) -> RawInbound {
1974    let mut headers = BTreeMap::new();
1975    headers.insert(
1976        "authorization".to_string(),
1977        format!("Bearer {}", a2a_push_fixture_jwt(jti)),
1978    );
1979    headers.insert(
1980        "content-type".to_string(),
1981        "application/a2a+json".to_string(),
1982    );
1983    let mut raw = RawInbound::new(
1984        "",
1985        headers,
1986        serde_json::to_vec(&json!({
1987            "statusUpdate": {
1988                "taskId": "task-123",
1989                "contextId": "ctx-123",
1990                "status": {"state": "completed"}
1991            }
1992        }))
1993        .expect("serialize a2a push fixture"),
1994    );
1995    raw.metadata = json!({"binding_id": "a2a.push.fixture"});
1996    raw
1997}
1998
1999#[derive(Serialize)]
2000struct A2aFixtureClaims {
2001    iss: String,
2002    aud: String,
2003    iat: i64,
2004    exp: i64,
2005    jti: String,
2006    token: String,
2007    #[serde(rename = "taskId")]
2008    task_id: String,
2009}
2010
2011fn a2a_push_fixture_jwt(jti: &str) -> String {
2012    let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
2013    header.kid = Some("test-key".to_string());
2014    jsonwebtoken::encode(
2015        &header,
2016        &A2aFixtureClaims {
2017            iss: "reviewer.prod".to_string(),
2018            aud: "https://orchestrator.test/a2a/review".to_string(),
2019            iat: OffsetDateTime::now_utc().unix_timestamp(),
2020            exp: OffsetDateTime::now_utc().unix_timestamp() + 300,
2021            jti: jti.to_string(),
2022            token: "opaque-token".to_string(),
2023            task_id: "task-123".to_string(),
2024        },
2025        &jsonwebtoken::EncodingKey::from_secret(b"secret"),
2026    )
2027    .expect("encode fixture jwt")
2028}
2029
2030fn parse_rfc3339(raw: &str) -> OffsetDateTime {
2031    OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
2032        .expect("fixture timestamp should parse")
2033}
2034
2035fn format_rfc3339(value: OffsetDateTime) -> String {
2036    value
2037        .format(&time::format_description::well_known::Rfc3339)
2038        .unwrap_or_default()
2039}
2040
2041fn wait_until_wall_clock_after(timestamp: OffsetDateTime) {
2042    let timestamp_ms = timestamp.unix_timestamp_nanos() / 1_000_000;
2043    while OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000 <= timestamp_ms {
2044        std::hint::spin_loop();
2045        std::thread::yield_now();
2046    }
2047}
2048
2049fn signature_state_label(value: &SignatureStatus) -> &'static str {
2050    match value {
2051        SignatureStatus::Verified => "verified",
2052        SignatureStatus::Unsigned => "unsigned",
2053        SignatureStatus::Failed { .. } => "failed",
2054    }
2055}
2056
2057#[cfg(test)]
2058mod tests {
2059    use super::{run_trigger_harness_fixture, TRIGGER_TEST_FIXTURES};
2060
2061    #[tokio::test(flavor = "current_thread")]
2062    async fn every_trigger_harness_fixture_reports_success() {
2063        for fixture in TRIGGER_TEST_FIXTURES {
2064            let result = run_trigger_harness_fixture(fixture)
2065                .await
2066                .unwrap_or_else(|error| panic!("{fixture} should run: {error}"));
2067            assert!(result.ok, "{fixture} should report success: {result:?}");
2068        }
2069    }
2070}