Skip to main content

cordis/
events.rs

1use parking_lot::{Mutex, RwLock};
2use std::collections::{BTreeMap, HashMap};
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7
8use crate::effect::Disposable;
9use crate::service::{CordisError, Service};
10use crate::EventId;
11
12/// Collected failures from one parallel event dispatch.
13///
14/// `Dispatch::Parallel` fans out to every registered listener and joins all
15/// tasks; instead of surfacing only the first error, every failure is
16/// recorded here as a `(listener name, message)` pair. Rendering goes through
17/// [`summarize_listener_errors`], and the dispatch result carries the summary
18/// text wrapped in [`CordisError::Internal`].
19#[derive(Debug)]
20pub struct AggregateError {
21    pub errors: Vec<(String, String)>,
22}
23
24impl std::fmt::Display for AggregateError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "{}", format_listener_errors(&self.errors))
27    }
28}
29
30impl std::error::Error for AggregateError {}
31
32/// Pure formatter shared by [`AggregateError`] and the parallel-dispatch warn
33/// line: `"N listener failures: name: message; name: message"` (singular
34/// wording for exactly one entry).
35pub fn summarize_listener_errors(errors: Vec<(String, String)>) -> String {
36    format_listener_errors(&errors)
37}
38
39fn format_listener_errors(errors: &[(String, String)]) -> String {
40    if errors.is_empty() {
41        return "0 listener failures".to_string();
42    }
43    let joined = errors
44        .iter()
45        .map(|(name, message)| format!("{name}: {message}"))
46        .collect::<Vec<_>>()
47        .join("; ");
48    if errors.len() == 1 {
49        format!("1 listener failure: {joined}")
50    } else {
51        format!("{} listener failures: {joined}", errors.len())
52    }
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum Dispatch {
57    Emit,
58    Parallel,
59    Serial,
60    Bail,
61    Waterfall,
62}
63
64/// Registration options for a flat listener, mirroring the reference-kernel
65/// `EventOptions` shape.
66///
67/// * `prepend: true` inserts the listener at the FRONT of the dispatch-order
68///   list (upstream `unshift`), so it runs before previously registered
69///   listeners of the same event.
70/// * `global: true` marks the listener as realm-agnostic: context filters
71///   ([`EventsService::emit_filtered`]) never exclude it.
72///
73/// The historical [`EventsService::on`] / [`EventsService::once`] paths
74/// delegate with `EventOptions::default()` (both `false`).
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub struct EventOptions {
77    pub prepend: bool,
78    pub global: bool,
79}
80
81/// Per-listener participation predicate for [`EventsService::emit_filtered`].
82///
83/// Receives the listener's registration [`EventOptions`] — the only
84/// per-listener metadata this kernel records — and decides whether that
85/// non-global listener participates in the filtered dispatch. Global
86/// listeners bypass the filter entirely and never invoke it.
87pub type ListenerFilter = Box<dyn Fn(&EventOptions) -> bool + Send + Sync>;
88
89/// Kernel intercept meta-events (C1): five veto points plus the internal
90/// dispatch observer. These are kernel-internal contracts — they do not
91/// join the product event catalog, so catalog validation bypasses them
92/// exactly like the mechanics test events below.
93pub const INTERNAL_GET_EVENT: &str = "internal/get";
94pub const INTERNAL_SET_EVENT: &str = "internal/set";
95pub const INTERNAL_CONFIG_EVENT: &str = "internal/config";
96pub const INTERNAL_UPDATE_EVENT: &str = "internal/update";
97pub const INTERNAL_LISTENER_EVENT: &str = "internal/listener";
98pub const INTERNAL_DISPATCH_EVENT: &str = "internal/dispatch";
99
100/// True when `event` names one of the kernel intercept meta-events.
101pub fn is_internal_meta_event(event: &str) -> bool {
102    matches!(
103        event,
104        INTERNAL_GET_EVENT
105            | INTERNAL_SET_EVENT
106            | INTERNAL_CONFIG_EVENT
107            | INTERNAL_UPDATE_EVENT
108            | INTERNAL_LISTENER_EVENT
109            | INTERNAL_DISPATCH_EVENT
110    )
111}
112
113/// Payload carried by the `internal/dispatch` observer: fires pre-dispatch
114/// on every NON-internal dispatch with the observed mode, name, and args.
115#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
116pub struct InternalDispatchPayload {
117    /// Rendering of [`Dispatch`] (`"emit"`, `"bail"`, `"waterfall"`, …).
118    pub mode: String,
119    pub name: String,
120    pub args: serde_json::Value,
121}
122
123/// Synthetic event names used by this crate's own unit tests to exercise
124/// dispatch mechanics (ordering, bail, disposal). They are not product
125/// contracts and bypass catalog validation when built for tests.
126const MECHANICS_TEST_EVENTS: &[&str] = &[
127    "test",
128    "test.event",
129    "gone",
130    "gone.wf",
131    "parallel.result",
132    "serial.bail",
133    "serial.identity",
134    "serial.test",
135    "bail.test",
136    "emit.test",
137    "emit.counter",
138    "wf.next",
139    "wf.short",
140    "wf.empty",
141    "par.test",
142    "par2.test",
143    "par.agg",
144    "par.solo",
145    "around.empty",
146    "around.wrap",
147    "around.short",
148    "once.test",
149    "once.bail",
150    // C1 EventOptions mechanics tests.
151    "prepend.test",
152    "filtered.test",
153    "global.test",
154    // C1 intercept meta-event tests (synthetic target events).
155    "blocked.event",
156    "allowed.event",
157    "another.event",
158    "observed.a",
159    "observed.b",
160    "observed.c",
161    "wf.filtered",
162];
163
164fn bypasses_catalog(event: &str) -> bool {
165    (cfg!(test) && MECHANICS_TEST_EVENTS.contains(&event)) || is_internal_meta_event(event)
166}
167
168/// Debug-only contract enforcement. Compiles out in release builds.
169fn debug_enforce_dispatch(event: &EventId, mode: Dispatch) {
170    if bypasses_catalog(event) {
171        return;
172    }
173    if let Err(msg) = crate::events_catalog::validate_dispatch(event, mode) {
174        debug_assert!(false, "{msg}");
175    }
176}
177
178/// Debug-only listener-registry enforcement. Compiles out in release builds.
179fn debug_enforce_listener(event: &EventId, waterfall_registration: bool) {
180    if bypasses_catalog(event) {
181        return;
182    }
183    if let Err(msg) = crate::events_catalog::validate_listener(event, waterfall_registration) {
184        debug_assert!(false, "{msg}");
185    }
186}
187
188impl std::fmt::Display for Dispatch {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        let name = match self {
191            Dispatch::Emit => "emit",
192            Dispatch::Parallel => "parallel",
193            Dispatch::Serial => "serial",
194            Dispatch::Bail => "bail",
195            Dispatch::Waterfall => "waterfall",
196        };
197        f.write_str(name)
198    }
199}
200
201type Handler = Arc<
202    dyn Fn(
203            serde_json::Value,
204        ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
205        + Send
206        + Sync,
207>;
208
209/// The `next` continuation handed to a waterfall handler.  It advances to the
210/// next registered waterfall handler, or returns the passed payload unchanged once
211/// the chain is exhausted.  It is `FnOnce`: a handler may call `next` at most once,
212/// mirroring Cordis `next()` semantics.
213pub type WaterfallNext = Box<
214    dyn FnOnce(
215            serde_json::Value,
216        )
217            -> Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
218        + Send,
219>;
220
221/// A Cordis `waterfall` around-middleware handler.  It receives the current payload
222/// plus a `next` continuation.  Calling `next(payload)` runs the downstream chain and
223/// yields its result for further transformation; choosing NOT to call `next`
224/// short-circuits the chain (any later handlers do not run).
225type WaterfallHandler = Arc<
226    dyn Fn(
227            serde_json::Value,
228            WaterfallNext,
229        ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
230        + Send
231        + Sync,
232>;
233
234#[derive(Clone)]
235struct HandlerSlot {
236    cancelled: Arc<AtomicBool>,
237    /// Full registration [`EventOptions`]. `global` exempts the listener from
238    /// filtered-dispatch exclusion outright; the whole option set is what
239    /// [`EventsService::emit_filtered`]'s filter gets to inspect.
240    options: EventOptions,
241    handler: Handler,
242}
243
244#[derive(Clone)]
245struct WaterfallSlot {
246    cancelled: Arc<AtomicBool>,
247    /// Full registration [`EventOptions`] so per-dispatch filters can decide
248    /// participation exactly like the flat registry. Historical
249    /// registrations default to both flags off.
250    options: EventOptions,
251    handler: WaterfallHandler,
252}
253
254pub struct EventsService {
255    handlers: RwLock<HashMap<EventId, Vec<HandlerSlot>>>,
256    waterfall_handlers: RwLock<HashMap<EventId, Vec<WaterfallSlot>>>,
257    bus: tokio::sync::broadcast::Sender<(EventId, serde_json::Value)>,
258    /// Per-event dispatch counter (every mode, every dispatch path).
259    dispatch_counts: Mutex<BTreeMap<String, u64>>,
260}
261
262impl EventsService {
263    pub fn new() -> Self {
264        let (tx, _rx) = tokio::sync::broadcast::channel(32);
265        let svc = Self {
266            handlers: RwLock::new(HashMap::new()),
267            waterfall_handlers: RwLock::new(HashMap::new()),
268            bus: tx,
269            dispatch_counts: Mutex::new(BTreeMap::new()),
270        };
271        svc.register_default_admit_handler();
272        svc
273    }
274
275    fn register_default_admit_handler(&self) {
276        let cancelled = Arc::new(AtomicBool::new(false));
277        let slot = HandlerSlot {
278            cancelled,
279            options: EventOptions::default(),
280            handler: Arc::new(|payload| Box::pin(async move { Ok(default_agent_admit(payload)) })),
281        };
282        self.handlers
283            .write()
284            .entry("agent.admit".into())
285            .or_default()
286            .push(slot);
287    }
288
289    /// Count of non-cancelled listeners registered on `event`, across BOTH
290    /// registries (flat + waterfall). Read-only — no pruning writes — so the
291    /// zero-cost gate for every interception point is one pair of map
292    /// lookups.
293    pub fn listener_count(&self, event: &str) -> usize {
294        let flat = self
295            .handlers
296            .read()
297            .get(event)
298            .map(|slots| {
299                slots
300                    .iter()
301                    .filter(|slot| !slot.cancelled.load(Ordering::SeqCst))
302                    .count()
303            })
304            .unwrap_or(0);
305        let waterfall = self
306            .waterfall_handlers
307            .read()
308            .get(event)
309            .map(|slots| {
310                slots
311                    .iter()
312                    .filter(|slot| !slot.cancelled.load(Ordering::SeqCst))
313                    .count()
314            })
315            .unwrap_or(0);
316        flat + waterfall
317    }
318
319    /// Subscribe to the fire-and-forget emit broadcast bus.
320    /// Snapshot of dispatch counters: (total, per-event sorted ascending).
321    pub fn dispatch_snapshot(&self) -> (u64, Vec<(String, u64)>) {
322        let map = self.dispatch_counts.lock();
323        let total = map.values().sum();
324        (total, map.iter().map(|(k, v)| (k.clone(), *v)).collect())
325    }
326
327    /// Subscribe to the fire-and-forget emit broadcast bus.
328    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<(EventId, serde_json::Value)> {
329        self.bus.subscribe()
330    }
331
332    /// Register a one-shot flat listener.
333    ///
334    /// The returned handle is the same early-cancel subscription [`Self::on`]
335    /// yields; disposing it before the event ever fires unregisters the
336    /// listener. Exactly-once is claimed AT INVOCATION through an atomic
337    /// swap, so concurrent dispatches of the same event run the handler on
338    /// exactly one task and every later dispatch observes an already-spent
339    /// (skipped) slot. Delegates to [`Self::once_with`] with default options;
340    /// a bail-chain-skipped listener stays registered until it actually runs
341    /// (see [`Self::once_with`]).
342    pub fn once<F, Fut>(&self, event: EventId, handler: F) -> Box<dyn Disposable>
343    where
344        F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static,
345        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
346    {
347        self.once_with(event, EventOptions::default(), handler)
348    }
349
350    /// Register a one-shot flat listener with explicit [`EventOptions`].
351    ///
352    /// The claim/dispose flag discipline is identical to [`Self::once`];
353    /// `options.prepend` controls the insertion position in the
354    /// dispatch-order list, `options.global` marks the listener as exempt
355    /// from context filters in [`Self::emit_filtered`]. The historical
356    /// [`Self::once`] delegates here with default options.
357    pub fn once_with<F, Fut>(
358        &self,
359        event: EventId,
360        options: EventOptions,
361        handler: F,
362    ) -> Box<dyn Disposable>
363    where
364        F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static,
365        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
366    {
367        debug_enforce_listener(&event, false);
368        // Registration veto point (C1): a bail/error on `internal/listener`
369        // cancels this registration; the returned handle is inert BY DESIGN
370        // (disposing it flips nothing).
371        if !blocking_listener_veto(self, &event) {
372            return Box::new(|| {});
373        }
374        // One atomic flag does double duty: swapping it `true` AT INVOCATION
375        // claims the single run among concurrent dispatches, and because it
376        // IS the slot's cancellation flag, the spent slot is dropped by the
377        // next dispatch's retain pass. A listener skipped by a bail chain is
378        // never invoked, so its claim stays unspent and the slot stays
379        // registered until a dispatch actually reaches and runs it.
380        let claim = Arc::new(AtomicBool::new(false));
381        let slot_flag = claim.clone();
382        let handle_flag = claim.clone();
383        // The claim wrapper as a `Handler`: each invocation first flips the
384        // atomic; only the caller that observes `false` (the FIRST one)
385        // runs the user handler. Cloning the Arc inside keeps the closure
386        // `Fn` while handing an owned handle to the spawned future.
387        let user = Arc::new(handler);
388        let once_handler: Handler = {
389            let user = user.clone();
390            Arc::new(move |payload: serde_json::Value| {
391                let claimed = claim.swap(true, Ordering::SeqCst);
392                let user = user.clone();
393                Box::pin(async move {
394                    if claimed {
395                        // Already spent: pass the payload through untouched.
396                        return Ok(payload);
397                    }
398                    user(payload).await
399                })
400            })
401        };
402        let slot = HandlerSlot {
403            cancelled: slot_flag,
404            options,
405            handler: once_handler,
406        };
407        self.insert_handler(event, options.prepend, slot);
408        Box::new(move || {
409            handle_flag.store(true, Ordering::SeqCst);
410        })
411    }
412
413    pub fn on<F, Fut>(&self, event: EventId, handler: F) -> Box<dyn Disposable>
414    where
415        F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static,
416        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
417    {
418        self.on_with(event, EventOptions::default(), handler)
419    }
420
421    /// Register a flat listener with explicit [`EventOptions`]: `prepend`
422    /// inserts at the front of the dispatch-order list, `global` marks the
423    /// listener as realm-agnostic for [`Self::emit_filtered`]. The
424    /// historical [`Self::on`] delegates here with default options.
425    pub fn on_with<F, Fut>(
426        &self,
427        event: EventId,
428        options: EventOptions,
429        handler: F,
430    ) -> Box<dyn Disposable>
431    where
432        F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static,
433        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
434    {
435        debug_enforce_listener(&event, false);
436        // Same registration veto point as `once_with`; a cancelled
437        // registration hands back a handle that flips nothing.
438        if !blocking_listener_veto(self, &event) {
439            return Box::new(|| {});
440        }
441        let cancelled = Arc::new(AtomicBool::new(false));
442        let slot = HandlerSlot {
443            cancelled: cancelled.clone(),
444            options,
445            handler: Arc::new(move |v| Box::pin(handler(v))),
446        };
447        self.insert_handler(event, options.prepend, slot);
448        Box::new(move || {
449            cancelled.store(true, Ordering::SeqCst);
450        })
451    }
452
453    /// Shared insertion point so `prepend` ordering is identical for
454    /// `on_with` and `once_with` registrations.
455    fn insert_handler(&self, event: EventId, prepend: bool, slot: HandlerSlot) {
456        let mut handlers = self.handlers.write();
457        let entry = handlers.entry(event).or_default();
458        if prepend {
459            entry.insert(0, slot);
460        } else {
461            entry.push(slot);
462        }
463    }
464
465    /// Register a Cordis `waterfall` around-middleware handler.
466    ///
467    /// `handler` receives the current payload and a `next` continuation.  Calling
468    /// `next(payload)` runs the downstream chain and yields its (possibly
469    /// transformed) result; NOT calling `next` short-circuits the chain so later
470    /// handlers do not run.  Handlers registered here are only invoked by
471    /// [`dispatch`](EventsService::dispatch) with [`Dispatch::Waterfall`]; the plain
472    /// [`on`](EventsService::on) registry is used for emit/parallel/serial/bail.
473    pub fn on_waterfall<F, Fut>(&self, event: EventId, handler: F) -> Box<dyn Disposable>
474    where
475        F: Fn(serde_json::Value, WaterfallNext) -> Fut + Send + Sync + 'static,
476        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
477    {
478        debug_enforce_listener(&event, true);
479        if !blocking_listener_veto(self, &event) {
480            return Box::new(|| {});
481        }
482        let cancelled = Arc::new(AtomicBool::new(false));
483        let slot = WaterfallSlot {
484            cancelled: cancelled.clone(),
485            options: EventOptions::default(),
486            handler: Arc::new(move |v, next| Box::pin(handler(v, next))),
487        };
488        let mut handlers = self.waterfall_handlers.write();
489        let entry = handlers.entry(event).or_default();
490        entry.push(slot);
491        Box::new(move || {
492            cancelled.store(true, Ordering::SeqCst);
493        })
494    }
495
496    /// Snapshot active handlers for `event`, optionally excluding non-global
497    /// listeners whose [`EventOptions`] fails `filter`. Global listeners are
498    /// passed to the filter NEVER — they always participate, mirroring the
499    /// reference-kernel `hook.global || !filter || filter.call(...)` clause.
500    ///
501    /// The retain pass runs under the same write guard as the unfiltered
502    /// variant, so cancelled slots still drop here.
503    fn active_handlers_filtered(
504        &self,
505        event: &EventId,
506        filter: Option<&ListenerFilter>,
507    ) -> Vec<Handler> {
508        let Some(filter) = filter else {
509            return self.active_handlers(event);
510        };
511        let mut handlers = self.handlers.write();
512        let active = {
513            let Some(slots) = handlers.get_mut(event) else {
514                return Vec::new();
515            };
516            slots.retain(|slot| !slot.cancelled.load(Ordering::SeqCst));
517            slots
518                .iter()
519                .filter(|slot| slot.options.global || filter(&slot.options))
520                .map(|slot| slot.handler.clone())
521                .collect::<Vec<_>>()
522        };
523        if active.is_empty() && handlers.get(event).is_some_and(Vec::is_empty) {
524            handlers.remove(event);
525        }
526        active
527    }
528
529    fn active_handlers(&self, event: &EventId) -> Vec<Handler> {
530        let mut handlers = self.handlers.write();
531        let active = {
532            let Some(slots) = handlers.get_mut(event) else {
533                return Vec::new();
534            };
535            slots.retain(|slot| !slot.cancelled.load(Ordering::SeqCst));
536            slots
537                .iter()
538                .map(|slot| slot.handler.clone())
539                .collect::<Vec<_>>()
540        };
541        if active.is_empty() {
542            handlers.remove(event);
543        }
544        active
545    }
546
547    fn active_waterfall(&self, event: &EventId) -> Vec<WaterfallHandler> {
548        self.active_waterfall_filtered(event, None)
549    }
550
551    /// Waterfall counterpart of [`Self::active_handlers_filtered`]: retains
552    /// cancelled slots, keeps global listeners unconditionally, offers every
553    /// other slot's [`EventOptions`] to `filter`.
554    fn active_waterfall_filtered(
555        &self,
556        event: &EventId,
557        filter: Option<&ListenerFilter>,
558    ) -> Vec<WaterfallHandler> {
559        let mut handlers = self.waterfall_handlers.write();
560        let active = {
561            let Some(slots) = handlers.get_mut(event) else {
562                return Vec::new();
563            };
564            slots.retain(|slot| !slot.cancelled.load(Ordering::SeqCst));
565            slots
566                .iter()
567                .filter(|slot| match filter {
568                    None => true,
569                    Some(filter) => slot.options.global || filter(&slot.options),
570                })
571                .map(|slot| slot.handler.clone())
572                .collect::<Vec<_>>()
573        };
574        // Prune ONLY when every slot was cancelled: a filter-empty snapshot
575        // still leaves LIVE (excluded-for-this-dispatch) registrations intact,
576        // mirroring [`Self::active_handlers_filtered`].
577        if active.is_empty() && handlers.get(event).is_some_and(Vec::is_empty) {
578            handlers.remove(event);
579        }
580        active
581    }
582
583    /// Fire-and-forget observation on `internal/dispatch`: carries
584    /// `(mode, name, args)` for every NON-internal dispatch, emitted
585    /// pre-dispatch. Handler results and errors are dropped by design — an
586    /// observability listener must never break or delay the observed
587    /// operation. `internal/*` events are exempt from recursion.
588    fn observe_dispatch(&self, mode: Dispatch, name: &EventId, args: &serde_json::Value) {
589        if is_internal_meta_event(name) || self.listener_count(INTERNAL_DISPATCH_EVENT) == 0 {
590            return;
591        }
592        let Ok(payload) = serde_json::to_value(InternalDispatchPayload {
593            mode: mode.to_string(),
594            name: name.clone(),
595            args: args.clone(),
596        }) else {
597            return;
598        };
599        for handler in self.active_handlers(&INTERNAL_DISPATCH_EVENT.to_string()) {
600            let p = payload.clone();
601            tokio::spawn(async move {
602                let _ = handler(p).await;
603            });
604        }
605    }
606
607    /// Filtered fire-and-forget emit: like [`Dispatch::Emit`] via
608    /// [`Self::dispatch`], but non-global listeners are offered to `filter`
609    /// first — a `false` verdict excludes the listener from this dispatch
610    /// without unregistering it. Global listeners bypass the filter.
611    ///
612    /// The broadcast bus fan-out is NOT filtered (it has no listener
613    /// metadata to filter on); only registered handlers participate in
614    /// filtering. Returns null like every emit path.
615    pub fn emit_filtered(
616        &self,
617        event: EventId,
618        args: serde_json::Value,
619        filter: ListenerFilter,
620    ) -> Result<serde_json::Value, CordisError> {
621        debug_enforce_dispatch(&event, Dispatch::Emit);
622        *self
623            .dispatch_counts
624            .lock()
625            .entry(event.to_string())
626            .or_insert(0) += 1;
627        self.observe_dispatch(Dispatch::Emit, &event, &args);
628        let _ = self.bus.send((event.clone(), args.clone()));
629        for h in self.active_handlers_filtered(&event, Some(&filter)) {
630            let p = args.clone();
631            tokio::spawn(async move {
632                let _ = h(p).await;
633            });
634        }
635        Ok(serde_json::Value::Null)
636    }
637
638    /// Target-carrying Bail dispatch: like [`Dispatch::Bail`] through
639    /// [`Self::dispatch`], but non-global flat listeners whose registration
640    /// options fail `filter` do not participate in THIS dispatch (they stay
641    /// registered). The filter closure captures the operating context at
642    /// the call site, so per-dispatch decisions evaluate against it. Kernel
643    /// meta-events ride here for their veto chains.
644    pub async fn bail_from(
645        &self,
646        event: EventId,
647        payload: serde_json::Value,
648        filter: Option<ListenerFilter>,
649    ) -> Result<serde_json::Value, CordisError> {
650        debug_enforce_dispatch(&event, Dispatch::Bail);
651        *self
652            .dispatch_counts
653            .lock()
654            .entry(event.to_string())
655            .or_insert(0) += 1;
656        let handlers = match filter {
657            Some(filter) => self.active_handlers_filtered(&event, Some(&filter)),
658            None => self.active_handlers(&event),
659        };
660        run_bail_handlers(handlers, payload).await
661    }
662
663    /// Target-carrying Waterfall dispatch with identity terminal: the same
664    /// chain as [`Dispatch::Waterfall`] through [`Self::dispatch`], plus
665    /// per-dispatch filtering of the waterfall registry.
666    pub async fn waterfall_from(
667        &self,
668        event: EventId,
669        payload: serde_json::Value,
670        filter: Option<ListenerFilter>,
671    ) -> Result<serde_json::Value, CordisError> {
672        debug_enforce_dispatch(&event, Dispatch::Waterfall);
673        *self
674            .dispatch_counts
675            .lock()
676            .entry(event.to_string())
677            .or_insert(0) += 1;
678        let handlers = self.active_waterfall_filtered(&event, filter.as_ref());
679        if handlers.is_empty() {
680            return Ok(payload);
681        }
682        run_waterfall_chain(handlers, 0, payload, None).await
683    }
684
685    pub async fn dispatch(
686        &self,
687        event: EventId,
688        payload: serde_json::Value,
689        mode: Dispatch,
690    ) -> Result<serde_json::Value, CordisError> {
691        debug_enforce_dispatch(&event, mode);
692        *self
693            .dispatch_counts
694            .lock()
695            .entry(event.to_string())
696            .or_insert(0) += 1;
697        self.observe_dispatch(mode, &event, &payload);
698        let handlers = self.active_handlers(&event);
699        match mode {
700            // Waterfall uses its own around-middleware registry. With no active
701            // around handlers it is an identity operation.
702            Dispatch::Waterfall => {
703                let wf_handlers = self.active_waterfall(&event);
704                if wf_handlers.is_empty() {
705                    return Ok(payload);
706                }
707                run_waterfall_chain(wf_handlers, 0, payload, None).await
708            }
709            // Emit is fire-and-forget. The broadcast and spawned handlers do not
710            // contribute a result, so callers always observe JSON null.
711            Dispatch::Emit => {
712                let _ = self.bus.send((event, payload.clone()));
713                for h in handlers {
714                    let p = payload.clone();
715                    tokio::spawn(async move {
716                        let _ = h(p).await;
717                    });
718                }
719                Ok(serde_json::Value::Null)
720            }
721            // Parallel fans out the same payload to every handler. All tasks are
722            // joined so errors are observed and no in-flight handler is dropped;
723            // successful dispatch has no meaningful result and returns null.
724            Dispatch::Parallel => {
725                let mut set = tokio::task::JoinSet::new();
726                for (name, h) in handlers.into_iter().enumerate() {
727                    let p = payload.clone();
728                    set.spawn(async move { (name, h(p).await) });
729                }
730                let mut failures: Vec<(String, String)> = Vec::new();
731                while let Some(res) = set.join_next().await {
732                    match res {
733                        Err(join_err) => {
734                            // Consume the JoinError exactly once: unwrap the
735                            // panic payload when the task panicked, otherwise
736                            // render the (returned) cancelled-task error.
737                            let message = match join_err.try_into_panic() {
738                                Ok(payload) => panic_payload_message(&payload),
739                                Err(cancelled) => cancelled.to_string(),
740                            };
741                            failures.push(("listener-task".to_string(), message));
742                        }
743                        Ok((name, Err(err))) => {
744                            failures.push((format!("listener[{name}]"), err.message()))
745                        }
746                        Ok((_, Ok(_))) => {}
747                    }
748                }
749                if failures.is_empty() {
750                    return Ok(serde_json::Value::Null);
751                }
752                tracing::warn!(
753                    event = %event,
754                    failures = %format_listener_errors(&failures),
755                    "parallel dispatch collected listener failures"
756                );
757                Err(CordisError::Internal(format_listener_errors(&failures)))
758            }
759            // Serial invokes handlers in registration order with the original
760            // payload. A non-null result bails out immediately; null means the
761            // handler did not claim the event, so an all-null chain returns the
762            // untouched original payload.
763            Dispatch::Serial | Dispatch::Bail => run_bail_handlers(handlers, payload).await,
764        }
765    }
766
767    /// Around-middleware waterfall whose terminal `next` is `core` rather than identity.
768    ///
769    /// Snapshot active waterfall handlers for `event`. With none registered, `core`
770    /// runs immediately. Otherwise the same chain as [`dispatch`] with
771    /// [`Dispatch::Waterfall`], except `index >= handlers.len()` invokes `core`
772    /// instead of returning the payload unchanged. [`dispatch`] Waterfall stays
773    /// identity-at-end.
774    pub async fn waterfall_around<F, Fut>(
775        &self,
776        event: EventId,
777        payload: serde_json::Value,
778        core: F,
779    ) -> Result<serde_json::Value, CordisError>
780    where
781        F: FnOnce(serde_json::Value) -> Fut + Send + 'static,
782        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
783    {
784        debug_enforce_dispatch(&event, Dispatch::Waterfall);
785        self.observe_dispatch(Dispatch::Waterfall, &event, &payload);
786        let handlers = self.active_waterfall(&event);
787        if handlers.is_empty() {
788            return core(payload).await;
789        }
790        let core: WaterfallCore = Box::new(move |p| {
791            Box::pin(core(p))
792                as Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
793        });
794        run_waterfall_chain(handlers, 0, payload, Some(core)).await
795    }
796
797    /// Target-carrying around-middleware waterfall whose terminal `next` is
798    /// `core`: [`Self::waterfall_around`] plus per-dispatch listener
799    /// filtering. The operating context rides along through whatever the
800    /// caller closes over in `filter`.
801    pub async fn waterfall_async_from<F, Fut>(
802        &self,
803        event: EventId,
804        payload: serde_json::Value,
805        filter: Option<ListenerFilter>,
806        core: F,
807    ) -> Result<serde_json::Value, CordisError>
808    where
809        F: FnOnce(serde_json::Value) -> Fut + Send + 'static,
810        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
811    {
812        debug_enforce_dispatch(&event, Dispatch::Waterfall);
813        *self
814            .dispatch_counts
815            .lock()
816            .entry(event.to_string())
817            .or_insert(0) += 1;
818        let handlers = self.active_waterfall_filtered(&event, filter.as_ref());
819        if handlers.is_empty() {
820            return core(payload).await;
821        }
822        let core: WaterfallCore = Box::new(move |p| {
823            Box::pin(core(p))
824                as Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
825        });
826        run_waterfall_chain(handlers, 0, payload, Some(core)).await
827    }
828
829    /// Strict service read interception at the `internal/get` veto point.
830    ///
831    /// No listeners ⇒ `Ok(None)` at map-lookup cost (zero-cost gate). A Bail
832    /// chain yielding null passes the read through untouched; a non-null
833    /// result REPLACES what the consumer sees; a chain error vetoes the read.
834    pub async fn intercept_get(
835        &self,
836        service: &str,
837        ctx_hint: Option<String>,
838    ) -> Result<Option<serde_json::Value>, CordisError> {
839        if self.listener_count(INTERNAL_GET_EVENT) == 0 {
840            return Ok(None);
841        }
842        let payload = serde_json::json!({ "service": service, "ctx": ctx_hint });
843        let out = self
844            .bail_from(INTERNAL_GET_EVENT.into(), payload, None)
845            .await?;
846        Ok((!out.is_null()).then_some(out))
847    }
848
849    /// Service-write interception at the `internal/set` veto point. A chain
850    /// error vetoes the write (the previous value stays); null / pass-through
851    /// allows the write unchanged.
852    pub async fn intercept_set(
853        &self,
854        service: &str,
855        ctx_hint: Option<String>,
856    ) -> Result<(), CordisError> {
857        if self.listener_count(INTERNAL_SET_EVENT) == 0 {
858            return Ok(());
859        }
860        let payload = serde_json::json!({ "service": service, "ctx": ctx_hint });
861        self.bail_from(INTERNAL_SET_EVENT.into(), payload, None)
862            .await?;
863        Ok(())
864    }
865
866    /// Config-resolution interception at the `internal/config` veto point.
867    /// The chain's non-null terminal IS the effective configuration; null
868    /// passes `raw` through untouched; a chain error fails the activation /
869    /// update that was resolving config.
870    pub async fn intercept_config(
871        &self,
872        raw: serde_json::Value,
873    ) -> Result<serde_json::Value, CordisError> {
874        if self.listener_count(INTERNAL_CONFIG_EVENT) == 0 {
875            return Ok(raw);
876        }
877        self.bail_from(INTERNAL_CONFIG_EVENT.into(), raw, None).await
878    }
879
880    /// Restart-schedule interception at the `internal/update` veto point.
881    /// `Ok(true)` proceeds with the restart; `Ok(false)` (a bail or an
882    /// explicit JSON false) vetoes — the caller stores its pending config
883    /// and skips the restart. A chain error propagates to the caller.
884    pub async fn intercept_update(&self, service: &str) -> Result<bool, CordisError> {
885        if self.listener_count(INTERNAL_UPDATE_EVENT) == 0 {
886            return Ok(true);
887        }
888        let payload = serde_json::json!({ "service": service });
889        let out = self
890            .bail_from(INTERNAL_UPDATE_EVENT.into(), payload, None)
891            .await?;
892        Ok(!(out.is_null() || out.as_bool() == Some(false)))
893    }
894
895    /// Listener-registration interception at the `internal/listener` veto
896    /// point. `Ok(true)` lets the registration proceed; a bail (non-null
897    /// non-true result) or a chain error cancels it — the caller returns an
898    /// inert handle without touching either registry.
899    pub async fn intercept_listener(&self, event: &str) -> Result<bool, CordisError> {
900        if self.listener_count(INTERNAL_LISTENER_EVENT) == 0 {
901            return Ok(true);
902        }
903        let payload = serde_json::json!({ "event": event });
904        let out = self
905            .bail_from(INTERNAL_LISTENER_EVENT.into(), payload, None)
906            .await?;
907        Ok(out.is_null() || out.as_bool() == Some(true))
908    }
909
910    /// Typed dispatch: serialize the payload struct for `E`'s event and
911    /// dispatch with the declared mode. Equivalent to
912    /// [`dispatch`](EventsService::dispatch) with a pre-validated name/mode
913    /// pair; serialization failure is a [`CordisError::Configuration`].
914    pub async fn dispatch_typed<E: crate::events_payload::TypedEvent>(
915        &self,
916        payload: &E::Payload,
917    ) -> Result<serde_json::Value, CordisError> {
918        let value =
919            serde_json::to_value(payload).map_err(|e| CordisError::Configuration(e.to_string()))?;
920        self.dispatch(E::NAME.to_string(), value, E::MODE).await
921    }
922
923    /// Typed flat listener: the handler receives the deserialized payload
924    /// struct instead of raw JSON.
925    ///
926    /// A payload that fails to deserialize is skipped with a warning and the
927    /// incoming value passes through unchanged (for Serial/Bail chains this
928    /// preserves pass-through semantics). Registration still goes through the
929    /// same debug contract enforcement as [`on`](EventsService::on).
930    pub fn on_typed<E, F, Fut>(&self, handler: F) -> Box<dyn Disposable>
931    where
932        E: crate::events_payload::TypedEvent,
933        F: Fn(E::Payload) -> Fut + Send + Sync + 'static,
934        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
935    {
936        debug_enforce_listener(&E::NAME.to_string(), E::AROUND);
937        let wrapped = move |v: serde_json::Value| {
938            let fut = match serde_json::from_value::<E::Payload>(v.clone()) {
939                Ok(payload) => handler(payload),
940                Err(err) => {
941                    tracing::warn!(event = E::NAME, error = %err, "typed listener skipped malformed payload");
942                    return Box::pin(async { Ok(v) })
943                        as Pin<
944                            Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>,
945                        >;
946                }
947            };
948            Box::pin(fut)
949                as Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
950        };
951        self.on(E::NAME.to_string(), wrapped)
952    }
953
954    /// Typed around-middleware waterfall: the handler receives the
955    /// deserialized payload struct plus the raw-JSON [`WaterfallNext`]
956    /// continuation. The rest of the chain keeps working on serialized values;
957    /// delegating handlers re-parse inside `next`, mirroring upstream TS where
958    /// `next` carries serialized args.
959    pub fn on_typed_waterfall<E, F, Fut>(&self, handler: F) -> Box<dyn Disposable>
960    where
961        E: crate::events_payload::TypedEvent,
962        F: Fn(E::Payload, WaterfallNext) -> Fut + Send + Sync + 'static,
963        Fut: Future<Output = Result<serde_json::Value, CordisError>> + Send + 'static,
964    {
965        debug_enforce_listener(&E::NAME.to_string(), E::AROUND);
966        let wrapped = move |v: serde_json::Value, next: WaterfallNext| {
967            let fut = match serde_json::from_value::<E::Payload>(v.clone()) {
968                Ok(payload) => handler(payload, next),
969                Err(err) => {
970                    tracing::warn!(event = E::NAME, error = %err, "typed listener skipped malformed payload");
971                    return Box::pin(async move {
972                        // Preserve chain semantics: hand the untouched value to
973                        // the continuation so downstream handlers still run.
974                        next(v).await
975                    })
976                        as Pin<
977                            Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>,
978                        >;
979                }
980            };
981            Box::pin(fut)
982                as Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
983        };
984        self.on_waterfall(E::NAME.to_string(), wrapped)
985    }
986}
987
988impl Default for EventsService {
989    fn default() -> Self {
990        Self::new()
991    }
992}
993
994/// Re-entrancy fence for the synchronous interception bridges: while a
995/// bridge drives its meta-event chain, nested operations on THIS thread pass
996/// through unintercepted (an `internal/get` listener reading services must
997/// not recurse into its own veto).
998struct InterceptFence;
999
1000impl InterceptFence {
1001    fn enter() -> Option<Self> {
1002        INTERCEPT_FENCE.with(|fence| {
1003            if fence.get() {
1004                None
1005            } else {
1006                fence.set(true);
1007                Some(Self)
1008            }
1009        })
1010    }
1011}
1012
1013impl Drop for InterceptFence {
1014    fn drop(&mut self) {
1015        INTERCEPT_FENCE.with(|fence| fence.set(false));
1016    }
1017}
1018
1019thread_local! {
1020    static INTERCEPT_FENCE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1021}
1022
1023/// Resolve the runtime handle for a synchronous bridge, requiring a
1024/// MULTI-thread runtime (`block_in_place` panics on current-thread flavors).
1025/// `None` means "cannot bridge right now" — callers fall back to allowing
1026/// the operation, matching the historical no-listener behavior.
1027fn bridge_handle() -> Option<tokio::runtime::Handle> {
1028    let handle = tokio::runtime::Handle::try_current().ok()?;
1029    if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
1030        Some(handle)
1031    } else {
1032        None
1033    }
1034}
1035
1036/// Synchronous bridge for the `internal/listener` registration veto.
1037///
1038/// Registrations are sync APIs, so the async veto chain runs to completion
1039/// on the current thread via `block_in_place`. With NO `internal/listener`
1040/// listener registered the check short-circuits BEFORE any blocking — the
1041/// historical zero-cost path every existing caller takes. On runtimes that
1042/// cannot park the worker (single-thread flavors) the registration is
1043/// allowed and a warning records the skipped veto.
1044fn blocking_listener_veto(svc: &EventsService, event: &str) -> bool {
1045    if svc.listener_count(INTERNAL_LISTENER_EVENT) == 0 {
1046        return true;
1047    }
1048    let Some(_fence) = InterceptFence::enter() else {
1049        return true;
1050    };
1051    let Some(handle) = bridge_handle() else {
1052        tracing::warn!(
1053            event = %event,
1054            "internal/listener veto listener present but runtime cannot block in place; allowing registration"
1055        );
1056        return true;
1057    };
1058    // SAFETY: `block_in_place` requires 'static, but the service outlives the
1059    // whole synchronous call and the future completes inside it before the
1060    // borrow ends; the pointer is never null and never aliased mutably.
1061    let svc: &'static EventsService = unsafe { &*(svc as *const EventsService) };
1062    tokio::task::block_in_place(|| {
1063        handle.block_on(async move {
1064            // A failing veto chain cancels the registration (fail-closed).
1065            svc.intercept_listener(event).await.unwrap_or(false)
1066        })
1067    })
1068}
1069
1070/// Verdict of a bridged `internal/get` consultation on a strict service read.
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072pub(crate) enum ReadVerdict {
1073    /// No interception: resolve the read normally.
1074    Pass,
1075    /// The interceptor rewrote the read outcome: skip THIS context frame's
1076    /// bindings (store + intercept) and continue the prototype walk upward,
1077    /// so a parent binding serves the read instead.
1078    RedirectFrame,
1079    /// The interceptor refused the read outright.
1080    Refuse,
1081}
1082
1083/// Synchronous bridge for the `internal/get` strict-read veto. Runs the
1084/// Bail chain to completion on the current thread via `block_in_place`;
1085/// without a runtime (pure-sync caller) the read passes untouched.
1086pub(crate) fn blocking_intercept_get(events: &EventsService, service: &str) -> ReadVerdict {
1087    if events.listener_count(INTERNAL_GET_EVENT) == 0 {
1088        return ReadVerdict::Pass;
1089    }
1090    let Some(_fence) = InterceptFence::enter() else {
1091        // Re-entrant read from inside an interception chain: pass through.
1092        return ReadVerdict::Pass;
1093    };
1094    let Some(handle) = bridge_handle() else {
1095        tracing::warn!(
1096            service,
1097            "internal/get listener present but runtime cannot block in place; passing read through"
1098        );
1099        return ReadVerdict::Pass;
1100    };
1101    // SAFETY: the service outlives the synchronous bridge call; the future
1102    // completes inside `block_in_place`, before the borrow ends.
1103    let events: &'static EventsService = unsafe { &*(events as *const EventsService) };
1104    let service = service.to_string();
1105    tokio::task::block_in_place(|| {
1106        handle.block_on(async move {
1107            match events.intercept_get(&service, None).await {
1108                Err(_) => ReadVerdict::Refuse,
1109                Ok(None) => ReadVerdict::Pass,
1110                Ok(Some(out)) => {
1111                    if out.is_null() {
1112                        ReadVerdict::Pass
1113                    } else if out.get("refuse").and_then(|v| v.as_bool()) == Some(true) {
1114                        ReadVerdict::Refuse
1115                    } else {
1116                        ReadVerdict::RedirectFrame
1117                    }
1118                }
1119            }
1120        })
1121    })
1122}
1123
1124/// Synchronous bridge for the `internal/set` write veto. `Err` vetoes the
1125/// write; without a runtime the write passes untouched.
1126pub(crate) fn blocking_intercept_set(events: &EventsService, service: &str) -> Result<(), CordisError> {
1127    if events.listener_count(INTERNAL_SET_EVENT) == 0 {
1128        return Ok(());
1129    }
1130    let Some(_fence) = InterceptFence::enter() else {
1131        return Ok(());
1132    };
1133    let Some(handle) = bridge_handle() else {
1134        tracing::warn!(
1135            service,
1136            "internal/set listener present but runtime cannot block in place; allowing write"
1137        );
1138        return Ok(());
1139    };
1140    // SAFETY: same lifetime argument as `blocking_intercept_get`.
1141    let events: &'static EventsService = unsafe { &*(events as *const EventsService) };
1142    let service = service.to_string();
1143    tokio::task::block_in_place(|| {
1144        handle.block_on(async move { events.intercept_set(&service, None).await })
1145    })
1146}
1147
1148/// Synchronous bridge for `internal/config` resolution ahead of one apply
1149/// pass. Returns the effective config: `raw` unchanged at zero added cost
1150/// when no listener is registered (or no runtime exists), otherwise the
1151/// chain terminal (null ⇒ raw passes through).
1152pub(crate) fn blocking_intercept_config(
1153    events: &EventsService,
1154    raw: serde_json::Value,
1155) -> Result<serde_json::Value, CordisError> {
1156    if events.listener_count(INTERNAL_CONFIG_EVENT) == 0 {
1157        return Ok(raw);
1158    }
1159    let Some(_fence) = InterceptFence::enter() else {
1160        return Ok(raw);
1161    };
1162    let Some(handle) = bridge_handle() else {
1163        tracing::warn!(
1164            "internal/config listener present but runtime cannot block in place; using raw config"
1165        );
1166        return Ok(raw);
1167    };
1168    // SAFETY: same lifetime argument as `blocking_intercept_get`.
1169    let events: &'static EventsService = unsafe { &*(events as *const EventsService) };
1170    tokio::task::block_in_place(|| handle.block_on(events.intercept_config(raw)))
1171}
1172
1173/// Best-effort message extraction from a panicked listener's panic payload,
1174/// used when a spawned parallel listener task panics before joining.
1175fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
1176    if let Some(s) = payload.downcast_ref::<&str>() {
1177        (*s).to_string()
1178    } else if let Some(s) = payload.downcast_ref::<String>() {
1179        s.clone()
1180    } else {
1181        "listener panicked".to_string()
1182    }
1183}
1184
1185fn json_u64(v: &serde_json::Value, key: &str) -> Option<u64> {
1186    v.get(key).and_then(|x| {
1187        x.as_u64()
1188            .or_else(|| x.as_i64().and_then(|n| u64::try_from(n).ok()))
1189    })
1190}
1191
1192/// Default `"agent.admit"` Bail handler. Deny JSON when payload counts fail
1193/// against payload quota fields. Enterprise (or missing quota fields) continues.
1194fn default_agent_admit(payload: serde_json::Value) -> serde_json::Value {
1195    if payload.get("tier").and_then(|v| v.as_str()) == Some("enterprise") {
1196        return serde_json::Value::Null;
1197    }
1198    let monthly = json_u64(&payload, "monthly").unwrap_or(0);
1199    let daily = json_u64(&payload, "daily").unwrap_or(0);
1200    let Some(rpm) = json_u64(&payload, "requests_per_month") else {
1201        return serde_json::Value::Null;
1202    };
1203    let Some(rpd) = json_u64(&payload, "requests_per_day") else {
1204        return serde_json::Value::Null;
1205    };
1206    if monthly >= rpm {
1207        return serde_json::json!({ "deny": "monthly" });
1208    }
1209    if daily >= rpd {
1210        return serde_json::json!({ "deny": "daily" });
1211    }
1212    serde_json::Value::Null
1213}
1214
1215impl Service for EventsService {}
1216
1217async fn run_bail_handlers(
1218    handlers: Vec<Handler>,
1219    payload: serde_json::Value,
1220) -> Result<serde_json::Value, CordisError> {
1221    for handler in handlers {
1222        let result = handler(payload.clone()).await?;
1223        if !result.is_null() {
1224            return Ok(result);
1225        }
1226    }
1227    Ok(payload)
1228}
1229
1230/// Optional terminal `next` for [`EventsService::waterfall_around`]. `None` is
1231/// identity (used by [`Dispatch::Waterfall`]).
1232type WaterfallCore = Box<
1233    dyn FnOnce(
1234            serde_json::Value,
1235        )
1236            -> Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
1237        + Send,
1238>;
1239
1240/// Run a Cordis `waterfall` around-middleware chain starting at `index`.
1241///
1242/// Each handler receives the current payload and a `next` continuation.  The `next`
1243/// closure, when invoked, advances to `index + 1` (running the rest of the chain).
1244/// When the chain is exhausted, `core` runs if `Some`, otherwise the payload is
1245/// returned unchanged.  A handler that does not call `next` short-circuits: its
1246/// own return value is the final result, later handlers never run, and `core` is
1247/// dropped uncalled.  Errors propagate.
1248fn run_waterfall_chain(
1249    handlers: Vec<WaterfallHandler>,
1250    index: usize,
1251    payload: serde_json::Value,
1252    core: Option<WaterfallCore>,
1253) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>> {
1254    Box::pin(async move {
1255        if index >= handlers.len() {
1256            return match core {
1257                Some(core) => core(payload).await,
1258                None => Ok(payload),
1259            };
1260        }
1261        let handler = handlers[index].clone();
1262        // Build the continuation.  Because `next` is `FnOnce` and captures `index`,
1263        // each handler sees exactly one downstream step. `core` moves into `next`
1264        // so a short-circuit (unused `next`) skips the terminal function.
1265        let next = move |p: serde_json::Value| {
1266            let remaining = handlers.clone();
1267            Box::pin(async move { run_waterfall_chain(remaining, index + 1, p, core).await })
1268                as Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>>
1269        };
1270        handler(payload, Box::new(next)).await
1271    })
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276    use super::*;
1277    use std::sync::atomic::AtomicUsize;
1278
1279    #[tokio::test]
1280    async fn on_dispose_unregisters_handler() {
1281        let svc = EventsService::new();
1282        let flag = Arc::new(AtomicBool::new(false));
1283        let f = flag.clone();
1284        let d = svc.on("gone".into(), move |_v| {
1285            let f = f.clone();
1286            async move {
1287                f.store(true, Ordering::SeqCst);
1288                Ok(serde_json::Value::Null)
1289            }
1290        });
1291        d.dispose();
1292        svc.dispatch("gone".into(), serde_json::json!({}), Dispatch::Emit)
1293            .await
1294            .unwrap();
1295        svc.dispatch("gone".into(), serde_json::json!({}), Dispatch::Serial)
1296            .await
1297            .unwrap();
1298        assert!(svc.handlers.read().get("gone").is_none());
1299        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1300        assert!(
1301            !flag.load(Ordering::SeqCst),
1302            "disposed on() handler must not run for Emit or Serial"
1303        );
1304    }
1305
1306    #[tokio::test]
1307    async fn on_waterfall_dispose_unregisters_handler() {
1308        let svc = EventsService::new();
1309        let flag = Arc::new(AtomicBool::new(false));
1310        let f = flag.clone();
1311        let d = svc.on_waterfall("gone.wf".into(), move |payload, _next| {
1312            let f = f.clone();
1313            async move {
1314                f.store(true, Ordering::SeqCst);
1315                Ok(payload)
1316            }
1317        });
1318        d.dispose();
1319        svc.dispatch(
1320            "gone.wf".into(),
1321            serde_json::json!({ "n": 1 }),
1322            Dispatch::Waterfall,
1323        )
1324        .await
1325        .unwrap();
1326        assert!(
1327            !flag.load(Ordering::SeqCst),
1328            "disposed on_waterfall handler must not run"
1329        );
1330        assert!(svc.waterfall_handlers.read().get("gone.wf").is_none());
1331    }
1332
1333    /// Concurrent dispatches of the same event race the once-slot: exactly
1334    /// ONE invocation runs the handler, every other dispatch observes an
1335    /// already-claimed slot and passes through.
1336    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1337    async fn once_fires_exactly_once_concurrently() {
1338        let svc = std::sync::Arc::new(EventsService::new());
1339        let runs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1340        let r = runs.clone();
1341        svc.once("once.test".into(), move |payload| {
1342            let r = r.clone();
1343            async move {
1344                r.fetch_add(1, Ordering::SeqCst);
1345                Ok(payload)
1346            }
1347        });
1348
1349        // Fire N overlapping Parallel dispatches; each fans out to the same
1350        // slot concurrently.
1351        let mut tasks = tokio::task::JoinSet::new();
1352        for _ in 0..16 {
1353            let svc = std::sync::Arc::clone(&svc);
1354            tasks.spawn(async move {
1355                svc.dispatch(
1356                    "once.test".into(),
1357                    serde_json::json!({}),
1358                    Dispatch::Parallel,
1359                )
1360                .await
1361            });
1362        }
1363        while let Some(res) = tasks.join_next().await {
1364            res.expect("dispatch task").expect("parallel dispatch ok");
1365        }
1366        assert_eq!(
1367            runs.load(Ordering::SeqCst),
1368            1,
1369            "exactly one concurrent dispatch may run the once handler"
1370        );
1371    }
1372
1373    /// A bail-chain skip is NOT a run: a once listener that never got to run
1374    /// because an earlier handler bailed stays registered until it actually
1375    /// executes on a later dispatch.
1376    #[tokio::test]
1377    async fn once_stays_registered_when_skipped_by_bail() {
1378        let svc = EventsService::new();
1379        let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1380        let first = ran.clone();
1381        // Earlier handler bails with a non-null result: the chain stops here.
1382        let bailer = svc.on("once.bail".into(), move |_payload| {
1383            let first = first.clone();
1384            async move {
1385                first.fetch_add(1, Ordering::SeqCst);
1386                Ok(serde_json::json!({ "handled": true }))
1387            }
1388        });
1389        let second = ran.clone();
1390        svc.once("once.bail".into(), move |payload| {
1391            let second = second.clone();
1392            async move {
1393                second.fetch_add(1, Ordering::SeqCst);
1394                Ok(payload)
1395            }
1396        });
1397
1398        // Dispatch 1: bail handler claims; the once listener is skipped
1399        // WITHOUT running (its claim must stay unspent).
1400        let out = svc
1401            .dispatch("once.bail".into(), serde_json::json!({}), Dispatch::Bail)
1402            .await
1403            .unwrap();
1404        assert_eq!(out, serde_json::json!({ "handled": true }));
1405        assert_eq!(ran.load(Ordering::SeqCst), 1, "only the bail handler ran");
1406        // Slot is still registered (never fired).
1407        assert!(svc.handlers.read().get("once.bail").is_some());
1408
1409        // Dispatch 2: the bail handler terminates the chain again — the once
1410        // listener is skipped a second time and REMAINS registered.
1411        let _ = svc
1412            .dispatch("once.bail".into(), serde_json::json!({}), Dispatch::Bail)
1413            .await;
1414        assert_eq!(
1415            ran.load(Ordering::SeqCst),
1416            2,
1417            "the bail handler claims every chain"
1418        );
1419        assert!(
1420            svc.handlers.read().get("once.bail").is_some(),
1421            "skipped-by-bail once slot stays registered"
1422        );
1423
1424        // Dispose ONLY the bailer's own subscription so the next chain
1425        // actually REACHES the pending once slot (same service instance,
1426        // same slot — nothing was re-registered).
1427        bailer.dispose();
1428        let out = svc
1429            .dispatch(
1430                "once.bail".into(),
1431                serde_json::json!({"n": 1}),
1432                Dispatch::Bail,
1433            )
1434            .await
1435            .unwrap();
1436        // No bailer left: the once listener runs (chain ends null → payload).
1437        assert_eq!(out, serde_json::json!({"n": 1}));
1438        assert_eq!(
1439            ran.load(Ordering::SeqCst),
1440            3,
1441            "the surviving once slot fires exactly once"
1442        );
1443        // One more dispatch prunes the spent slot from the registry AND
1444        // proves it cannot run again.
1445        let _ = svc
1446            .dispatch(
1447                "once.bail".into(),
1448                serde_json::json!({"n": 2}),
1449                Dispatch::Bail,
1450            )
1451            .await;
1452        assert_eq!(
1453            ran.load(Ordering::SeqCst),
1454            3,
1455            "spent once slot must not run again"
1456        );
1457        assert!(
1458            svc.handlers.read().get("once.bail").is_none(),
1459            "pruned from the registry after spending"
1460        );
1461    }
1462
1463    #[tokio::test]
1464    async fn parallel_returns_null_after_all_handlers_complete() {
1465        let svc = EventsService::new();
1466        let completed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1467        for value in [1, 2] {
1468            let completed = completed.clone();
1469            svc.on("parallel.result".into(), move |_payload| {
1470                let completed = completed.clone();
1471                async move {
1472                    completed.fetch_add(value, Ordering::SeqCst);
1473                    Ok(serde_json::json!({ "value": value }))
1474                }
1475            });
1476        }
1477
1478        let out = svc
1479            .dispatch(
1480                "parallel.result".into(),
1481                serde_json::json!({ "input": true }),
1482                Dispatch::Parallel,
1483            )
1484            .await
1485            .unwrap();
1486
1487        assert_eq!(out, serde_json::Value::Null);
1488        assert_eq!(completed.load(Ordering::SeqCst), 3);
1489    }
1490
1491    #[tokio::test]
1492    async fn serial_stops_at_first_non_null_result() {
1493        let svc = EventsService::new();
1494        let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1495        let first = ran.clone();
1496        svc.on("serial.bail".into(), move |payload| {
1497            let first = first.clone();
1498            async move {
1499                first.fetch_add(1, Ordering::SeqCst);
1500                assert_eq!(payload["input"], true);
1501                Ok(serde_json::Value::Null)
1502            }
1503        });
1504        let second = ran.clone();
1505        svc.on("serial.bail".into(), move |payload| {
1506            let second = second.clone();
1507            async move {
1508                second.fetch_add(1, Ordering::SeqCst);
1509                assert_eq!(payload["input"], true);
1510                Ok(serde_json::json!({ "handled": true }))
1511            }
1512        });
1513        let third = ran.clone();
1514        svc.on("serial.bail".into(), move |_payload| {
1515            let third = third.clone();
1516            async move {
1517                third.fetch_add(1, Ordering::SeqCst);
1518                Ok(serde_json::json!({ "late": true }))
1519            }
1520        });
1521
1522        let out = svc
1523            .dispatch(
1524                "serial.bail".into(),
1525                serde_json::json!({ "input": true }),
1526                Dispatch::Serial,
1527            )
1528            .await
1529            .unwrap();
1530
1531        assert_eq!(out, serde_json::json!({ "handled": true }));
1532        assert_eq!(ran.load(Ordering::SeqCst), 2);
1533    }
1534
1535    #[tokio::test]
1536    async fn serial_preserves_original_payload_when_no_handler_bails() {
1537        let svc = EventsService::new();
1538        svc.on("serial.identity".into(), |_payload| async move {
1539            Ok(serde_json::Value::Null)
1540        });
1541        svc.on("serial.identity".into(), |_payload| async move {
1542            Ok(serde_json::Value::Null)
1543        });
1544
1545        let payload = serde_json::json!({ "input": [1, 2], "nested": { "ok": true } });
1546        let out = svc
1547            .dispatch("serial.identity".into(), payload.clone(), Dispatch::Serial)
1548            .await
1549            .unwrap();
1550
1551        assert_eq!(out, payload);
1552    }
1553
1554    #[tokio::test]
1555    async fn default_agent_admit_handler_denies_monthly() {
1556        let svc = EventsService::new();
1557        let out = svc
1558            .dispatch(
1559                "agent.admit".into(),
1560                serde_json::json!({
1561                    "monthly": 10,
1562                    "daily": 0,
1563                    "requests_per_month": 10,
1564                    "requests_per_day": 50,
1565                    "tier": "free"
1566                }),
1567                Dispatch::Bail,
1568            )
1569            .await
1570            .unwrap();
1571        assert_eq!(out["deny"], "monthly");
1572    }
1573
1574    #[tokio::test]
1575    async fn default_agent_admit_handler_allows_under_quota() {
1576        let svc = EventsService::new();
1577        let out = svc
1578            .dispatch(
1579                "agent.admit".into(),
1580                serde_json::json!({
1581                    "monthly": 0,
1582                    "daily": 0,
1583                    "requests_per_month": 10,
1584                    "requests_per_day": 50,
1585                    "tier": "free"
1586                }),
1587                Dispatch::Bail,
1588            )
1589            .await
1590            .unwrap();
1591        assert!(
1592            out.get("deny").is_none(),
1593            "under-quota must not deny, got {out}"
1594        );
1595    }
1596
1597    #[tokio::test]
1598    async fn waterfall_around_no_handlers_runs_core() {
1599        let svc = EventsService::new();
1600        let out = svc
1601            .waterfall_around(
1602                "around.empty".into(),
1603                serde_json::json!({}),
1604                |mut payload| async move {
1605                    if let Some(obj) = payload.as_object_mut() {
1606                        obj.insert("core".into(), serde_json::json!(true));
1607                    }
1608                    Ok(payload)
1609                },
1610            )
1611            .await
1612            .unwrap();
1613        assert_eq!(out["core"], true);
1614    }
1615
1616    #[tokio::test]
1617    async fn waterfall_around_handler_calls_next_then_core() {
1618        let svc = EventsService::new();
1619        svc.on_waterfall("around.wrap".into(), |mut payload, next| async move {
1620            if let Some(obj) = payload.as_object_mut() {
1621                obj.insert("wrap".into(), serde_json::json!(true));
1622            }
1623            next(payload).await
1624        });
1625        let out = svc
1626            .waterfall_around(
1627                "around.wrap".into(),
1628                serde_json::json!({}),
1629                |mut payload| async move {
1630                    if let Some(obj) = payload.as_object_mut() {
1631                        obj.insert("core".into(), serde_json::json!(true));
1632                    }
1633                    Ok(payload)
1634                },
1635            )
1636            .await
1637            .unwrap();
1638        assert_eq!(out["wrap"], true);
1639        assert_eq!(out["core"], true);
1640    }
1641
1642    #[tokio::test]
1643    async fn waterfall_around_short_circuit_skips_core() {
1644        let svc = EventsService::new();
1645        let flag = Arc::new(AtomicBool::new(false));
1646        svc.on_waterfall("around.short".into(), |payload, _next| async move {
1647            Ok(payload)
1648        });
1649        let f = flag.clone();
1650        let out = svc
1651            .waterfall_around(
1652                "around.short".into(),
1653                serde_json::json!({ "ok": true }),
1654                move |payload| {
1655                    let f = f.clone();
1656                    async move {
1657                        f.store(true, Ordering::SeqCst);
1658                        Ok(payload)
1659                    }
1660                },
1661            )
1662            .await
1663            .unwrap();
1664        assert_eq!(out["ok"], true);
1665        assert!(
1666            !flag.load(Ordering::SeqCst),
1667            "core must not run when handler short-circuits"
1668        );
1669    }
1670
1671    // -- typed wrappers (events_payload) -----------------------------------
1672
1673    /// dispatch_typed -> on_typed round trip on an Emit event: the handler
1674    /// observes the deserialized struct, not raw JSON.
1675    #[tokio::test]
1676    async fn typed_dispatch_and_listener_round_trip() {
1677        let svc = EventsService::new();
1678        let seen = Arc::new(parking_lot::Mutex::new(
1679            None::<crate::events_payload::AgentUsagePayload>,
1680        ));
1681        let slot = seen.clone();
1682        let _d = svc.on_typed::<crate::events_payload::AgentUsageEvent, _, _>(move |p| {
1683            let slot = slot.clone();
1684            async move {
1685                *slot.lock() = Some(p);
1686                Ok(serde_json::Value::Null)
1687            }
1688        });
1689        svc.dispatch_typed::<crate::events_payload::AgentUsageEvent>(
1690            &crate::events_payload::AgentUsagePayload {
1691                tenant: Some("acme".into()),
1692                prompt: 3,
1693                completion: 4,
1694                total: 7,
1695            },
1696        )
1697        .await
1698        .unwrap();
1699        // Emit spawns handlers; poll briefly for the handler to land.
1700        for _ in 0..100 {
1701            if seen.lock().is_some() {
1702                break;
1703            }
1704            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1705        }
1706        let got = seen.lock().clone().expect("handler must observe payload");
1707        assert_eq!(got.tenant.as_deref(), Some("acme"));
1708        assert_eq!(got.total, 7);
1709    }
1710
1711    /// A malformed payload skips the typed handler and passes the value
1712    /// through unchanged (Bail passthrough semantics preserved).
1713    #[tokio::test]
1714    async fn typed_listener_skips_malformed_payload_passthrough() {
1715        let svc = EventsService::new();
1716        let ran = Arc::new(AtomicBool::new(false));
1717        let flag = ran.clone();
1718        let _d = svc.on_typed::<crate::events_payload::AgentAdmitEvent, _, _>(move |_p| {
1719            let flag = flag.clone();
1720            async move {
1721                flag.store(true, Ordering::SeqCst);
1722                Ok(serde_json::json!({ "deny": "daily" }))
1723            }
1724        });
1725        let out = svc
1726            .dispatch(
1727                crate::events_catalog::ev::AGENT_ADMIT.into(),
1728                serde_json::json!({ "tenant_id": 42 }), // wrong type: not a string
1729                Dispatch::Bail,
1730            )
1731            .await
1732            .unwrap();
1733        assert!(
1734            !ran.load(Ordering::SeqCst),
1735            "malformed payload must skip the typed handler"
1736        );
1737        assert_eq!(out["tenant_id"], 42, "value must pass through unchanged");
1738    }
1739
1740    /// Typed waterfall listener can short-circuit by not calling next; the
1741    /// returned value is the chain result.
1742    #[tokio::test]
1743    async fn typed_waterfall_short_circuit() {
1744        use crate::events::WaterfallNext;
1745        let svc = EventsService::new();
1746        let _d = svc.on_typed_waterfall::<crate::events_payload::LlmGetClientEvent, _, _>(
1747            |p, next: WaterfallNext| async move {
1748                if p.capability == "blocked" {
1749                    return Ok(serde_json::json!({ "deny": true }));
1750                }
1751                next(serde_json::json!({ "capability": p.capability })).await
1752            },
1753        );
1754        let denied = svc
1755            .dispatch(
1756                crate::events_catalog::ev::LLM_GET_CLIENT.into(),
1757                serde_json::json!({ "capability": "blocked" }),
1758                Dispatch::Waterfall,
1759            )
1760            .await
1761            .unwrap();
1762        assert_eq!(denied["deny"], true);
1763        let passed = svc
1764            .dispatch(
1765                crate::events_catalog::ev::LLM_GET_CLIENT.into(),
1766                serde_json::json!({ "capability": "chat" }),
1767                Dispatch::Waterfall,
1768            )
1769            .await
1770            .unwrap();
1771        assert_eq!(passed["capability"], "chat");
1772    }
1773
1774    #[test]
1775    fn summarize_listener_errors_formats_multiple_failures() {
1776        let summary = crate::events::summarize_listener_errors(vec![
1777            ("quota".to_string(), "monthly cap reached".to_string()),
1778            ("audit".to_string(), "db write failed".to_string()),
1779        ]);
1780        assert_eq!(
1781            summary,
1782            "2 listener failures: quota: monthly cap reached; audit: db write failed"
1783        );
1784    }
1785
1786    #[test]
1787    fn summarize_listener_errors_formats_single_failure() {
1788        let summary = crate::events::summarize_listener_errors(vec![(
1789            "solo".to_string(),
1790            "boom".to_string(),
1791        )]);
1792        assert_eq!(summary, "1 listener failure: solo: boom");
1793        assert_eq!(
1794            crate::events::summarize_listener_errors(Vec::new()),
1795            "0 listener failures"
1796        );
1797    }
1798
1799    #[test]
1800    fn aggregate_error_display_and_error_impl() {
1801        let agg = AggregateError {
1802            errors: vec![
1803                ("a".to_string(), "x".to_string()),
1804                ("b".to_string(), "y".to_string()),
1805            ],
1806        };
1807        assert_eq!(agg.to_string(), "2 listener failures: a: x; b: y");
1808        // std::error::Error is object-safe usable via dyn.
1809        let dyn_err: &dyn std::error::Error = &agg;
1810        assert!(dyn_err.to_string().contains("b: y"));
1811    }
1812
1813    /// Parallel dispatch aggregates EVERY failed listener (not just the first)
1814    /// into one Internal error whose message is the shared summary format;
1815    /// successes still run to completion and the failure names each listener
1816    /// position with its message.
1817    #[tokio::test]
1818    async fn parallel_dispatch_aggregates_all_listener_failures() {
1819        use crate::CordisError as Err;
1820        let svc = EventsService::new();
1821        let completed = Arc::new(AtomicBool::new(false));
1822        let c = completed.clone();
1823        svc.on("par.agg".into(), move |_p| {
1824            let c = c.clone();
1825            async move {
1826                c.store(true, Ordering::SeqCst);
1827                Ok(serde_json::json!({ "ok": true }))
1828            }
1829        });
1830        svc.on("par.agg".into(), |_p| async move {
1831            Err::<serde_json::Value, _>(Err::Configuration("first failure".into()))
1832        });
1833        svc.on("par.agg".into(), |_p| async move {
1834            Err::<serde_json::Value, _>(Err::Fiber("second failure".into()))
1835        });
1836
1837        let err = svc
1838            .dispatch("par.agg".into(), serde_json::json!({}), Dispatch::Parallel)
1839            .await
1840            .unwrap_err();
1841        let text = err.message();
1842        assert!(
1843            text.starts_with("internal kernel error: 2 listener failures:")
1844                && text.contains("listener[1]: configuration error: first failure")
1845                && text.contains("listener[2]: fiber error: second failure"),
1846            "aggregate must list every failing listener, got: {text}"
1847        );
1848        assert!(
1849            completed.load(Ordering::SeqCst),
1850            "healthy listener still ran"
1851        );
1852    }
1853
1854    /// A single failing listener yields the singular summary wording through
1855    /// the same dispatch path.
1856    #[tokio::test]
1857    async fn parallel_dispatch_single_failure_uses_singular_summary() {
1858        let svc = EventsService::new();
1859        svc.on("par.solo".into(), |_p| async move {
1860            Err::<serde_json::Value, _>(crate::CordisError::Internal("only one".into()))
1861        });
1862        let err = svc
1863            .dispatch("par.solo".into(), serde_json::json!({}), Dispatch::Parallel)
1864            .await
1865            .unwrap_err();
1866        assert_eq!(
1867            err.message(),
1868            "internal kernel error: 1 listener failure: listener[0]: internal kernel error: only one"
1869        );
1870    }
1871
1872    // ------------------------------------------------------------------
1873    // EventOptions: prepend ordering + global filter bypass (C1)
1874    // ------------------------------------------------------------------
1875
1876    /// `on_with(prepend)` runs the prepended listener BEFORE previously
1877    /// registered listeners of the same event; default registration keeps
1878    /// appending. Proved with a Serial dispatch whose run order is recorded.
1879    #[tokio::test]
1880    async fn prepend_ordering_observed() {
1881        let svc = EventsService::new();
1882        let order = Arc::new(parking_lot::Mutex::<Vec<String>>::new(Vec::new()));
1883
1884        for name in ["first", "second"] {
1885            let slot = order.clone();
1886            svc.on("prepend.test".into(), move |_p| {
1887                let slot = slot.clone();
1888                async move {
1889                    slot.lock().push(name.to_string());
1890                    Ok(serde_json::Value::Null)
1891                }
1892            });
1893        }
1894
1895        // Prepended persistent listener: must land in FRONT of both defaults.
1896        let prepended_slot = order.clone();
1897        svc.on_with(
1898            "prepend.test".into(),
1899            EventOptions {
1900                prepend: true,
1901                global: false,
1902            },
1903            move |_p| {
1904                let prepended_slot = prepended_slot.clone();
1905                async move {
1906                    prepended_slot.lock().push("prepended".to_string());
1907                    Ok(serde_json::Value::Null)
1908                }
1909            },
1910        );
1911
1912        // Also prove the once_with path honors prepend: it must land in front.
1913        let once_slot = order.clone();
1914        svc.once_with(
1915            "prepend.test".into(),
1916            EventOptions {
1917                prepend: true,
1918                global: false,
1919            },
1920            move |_p| {
1921                let once_slot = once_slot.clone();
1922                async move {
1923                    once_slot.lock().push("once-prepended".to_string());
1924                    Ok(serde_json::Value::Null)
1925                }
1926            },
1927        );
1928
1929        svc.dispatch(
1930            "prepend.test".into(),
1931            serde_json::json!({}),
1932            Dispatch::Serial,
1933        )
1934        .await
1935        .unwrap();
1936        assert_eq!(
1937            *order.lock(),
1938            ["once-prepended", "prepended", "first", "second"],
1939            "prepend inserts at the dispatch-order front; defaults append"
1940        );
1941
1942        // A second Serial pass re-runs only the persistent listeners, in the
1943        // same relative order (the once slot is spent).
1944        svc.dispatch(
1945            "prepend.test".into(),
1946            serde_json::json!({}),
1947            Dispatch::Serial,
1948        )
1949        .await
1950        .unwrap();
1951        assert_eq!(
1952            order.lock()[4..],
1953            ["prepended", "first", "second"],
1954            "spent once slot drops out; relative order is stable"
1955        );
1956    }
1957
1958    /// `emit_filtered` excludes non-global listeners whose options fail the
1959    /// filter and runs the rest — without unregistering anyone: an
1960    /// unfiltered dispatch afterwards runs every listener again.
1961    #[tokio::test]
1962    async fn filter_excludes_nonmatching_contexts() {
1963        let svc = EventsService::new();
1964        // One counter per listener, incremented on EVERY run so each
1965        // dispatch's participation is directly observable.
1966        let ran_a = Arc::new(AtomicUsize::new(0));
1967        let ran_b = Arc::new(AtomicUsize::new(0));
1968
1969        let a = ran_a.clone();
1970        svc.on_with("filtered.test".into(), EventOptions::default(), move |p| {
1971            let a = a.clone();
1972            async move {
1973                a.fetch_add(1, Ordering::SeqCst);
1974                Ok(p)
1975            }
1976        });
1977        let b = ran_b.clone();
1978        svc.on_with("filtered.test".into(), EventOptions::default(), move |p| {
1979            let b = b.clone();
1980            async move {
1981                b.fetch_add(1, Ordering::SeqCst);
1982                Ok(p)
1983            }
1984        });
1985
1986        // Filter admits NOTHING: neither listener runs.
1987        svc.emit_filtered(
1988            "filtered.test".into(),
1989            serde_json::json!({ "tenant": "a" }),
1990            Box::new(|_opts| false),
1991        )
1992        .unwrap();
1993        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1994        assert_eq!(ran_a.load(Ordering::SeqCst), 0, "rejecting filter excludes a");
1995        assert_eq!(ran_b.load(Ordering::SeqCst), 0, "rejecting filter excludes b");
1996
1997        // Exclusion was per-dispatch: an UNFILTERED emit runs both listeners.
1998        svc.dispatch("filtered.test".into(), serde_json::json!({}), Dispatch::Emit)
1999            .await
2000            .unwrap();
2001        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
2002        assert_eq!(
2003            ran_a.load(Ordering::SeqCst),
2004            1,
2005            "listener a must be back for unfiltered dispatches"
2006        );
2007        assert_eq!(
2008            ran_b.load(Ordering::SeqCst),
2009            1,
2010            "listener b must be back for unfiltered dispatches"
2011        );
2012
2013        // And both registration slots survived the filtered pass untouched.
2014        let handlers = svc.handlers.read();
2015        let slots = handlers.get("filtered.test").expect("entry kept");
2016        assert_eq!(
2017            slots.len(),
2018            2,
2019            "filter exclusion must not unregister anyone"
2020        );
2021    }
2022
2023    /// Global listeners bypass context filters entirely: the same
2024    /// `emit_filtered` that excludes a non-global listener leaves a global
2025    /// one untouched by the filter verdict.
2026    #[tokio::test]
2027    async fn global_bypasses_filter() {
2028        let svc = EventsService::new();
2029        let ran = Arc::new(AtomicUsize::new(0));
2030
2031        // Non-global listener registered for tenant "b".
2032        let b = ran.clone();
2033        svc.on_with(
2034            "global.test".into(),
2035            EventOptions::default(),
2036            move |payload| {
2037                let b = b.clone();
2038                async move {
2039                    if payload["tenant"] == "b" {
2040                        b.fetch_add(1, Ordering::SeqCst);
2041                    }
2042                    Ok(payload)
2043                }
2044            },
2045        );
2046
2047        // Global listener for tenant "b": exempt from every filter.
2048        let g = ran.clone();
2049        svc.on_with(
2050            "global.test".into(),
2051            EventOptions {
2052                prepend: false,
2053                global: true,
2054            },
2055            move |payload| {
2056                let g = g.clone();
2057                async move {
2058                    if payload["tenant"] == "b" {
2059                        g.fetch_add(10, Ordering::SeqCst);
2060                    }
2061                    Ok(payload)
2062                }
2063            },
2064        );
2065
2066        // Dispatch under a filter that admits NOTHING ("tenant z"): the
2067        // non-global listener is excluded, the global one still runs.
2068        svc.emit_filtered(
2069            "global.test".into(),
2070            serde_json::json!({ "tenant": "b" }),
2071            Box::new(|_opts| false),
2072        )
2073        .unwrap();
2074        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
2075        assert_eq!(
2076            ran.load(Ordering::SeqCst),
2077            10,
2078            "global listener runs despite a rejecting filter; non-global does not"
2079        );
2080    }
2081
2082    // ------------------------------------------------------------------
2083    // C1 kernel intercept meta-events
2084    // ------------------------------------------------------------------
2085
2086    /// `internal/get` rewrites a strict read: the interceptor's non-null
2087    /// terminal replaces the resolved value; with the listener disposed the
2088    /// same consultation passes through (`None` = no interception).
2089    #[tokio::test]
2090    async fn get_interceptor_rewrites_read() {
2091        let svc = EventsService::new();
2092        // No listeners: zero-cost pass-through.
2093        assert_eq!(svc.intercept_get("Svc", None).await.unwrap(), None);
2094
2095        let d = svc.on(INTERNAL_GET_EVENT.into(), |_payload| async move {
2096            Ok(serde_json::json!({ "service": "Svc", "rewritten": true }))
2097        });
2098        let out = svc.intercept_get("Svc", Some("tenant-a".into())).await;
2099        match out {
2100            Ok(Some(value)) => {
2101                assert_eq!(value["rewritten"], serde_json::json!(true));
2102                assert_eq!(value["service"], "Svc");
2103            }
2104            other => panic!("expected rewritten read, got {other:?}"),
2105        }
2106        // Disposing the interceptor restores the pass-through.
2107        d.dispose();
2108        assert_eq!(svc.intercept_get("Svc", None).await.unwrap(), None);
2109    }
2110
2111    /// `internal/set` vetoes a write when its chain errors; without the
2112    /// veto (or after disposal) the write proceeds.
2113    #[tokio::test]
2114    async fn set_interceptor_vetoes_write_leaves_old_value() {
2115        let svc = EventsService::new();
2116        assert!(svc.intercept_set("Svc", None).await.is_ok());
2117
2118        let d = svc.on(INTERNAL_SET_EVENT.into(), |_payload| async move {
2119            Err::<serde_json::Value, CordisError>(CordisError::Configuration(
2120                "writes are frozen".into(),
2121            ))
2122        });
2123        let err = svc.intercept_set("Svc", None).await.unwrap_err();
2124        assert!(
2125            err.to_string().contains("frozen"),
2126            "veto error must surface, got {err}"
2127        );
2128        d.dispose();
2129        assert!(svc.intercept_set("Svc", None).await.is_ok());
2130    }
2131
2132    /// `internal/config`'s non-null terminal IS the effective config.
2133    #[tokio::test]
2134    async fn config_interceptor_rewrites_effective_config() {
2135        let svc = EventsService::new();
2136        let raw = serde_json::json!({ "model": "base" });
2137        // Pass-through with no listener.
2138        assert_eq!(svc.intercept_config(raw.clone()).await.unwrap(), raw);
2139
2140        let d = svc.on(INTERNAL_CONFIG_EVENT.into(), |raw| async move {
2141            let mut effective = raw;
2142            if let Some(obj) = effective.as_object_mut() {
2143                obj.insert("model".into(), serde_json::json!("rewritten"));
2144                obj.insert("seen_by_interceptor".into(), serde_json::json!(true));
2145            }
2146            Ok(effective)
2147        });
2148        let effective = svc.intercept_config(raw).await.unwrap();
2149        assert_eq!(effective["model"], "rewritten");
2150        assert_eq!(effective["seen_by_interceptor"], true);
2151        d.dispose();
2152
2153        // A null terminal passes the raw config through unchanged.
2154        svc.on(INTERNAL_CONFIG_EVENT.into(), |_raw| async move {
2155            Ok(serde_json::Value::Null)
2156        });
2157        let raw2 = serde_json::json!({ "keep": 1 });
2158        assert_eq!(svc.intercept_config(raw2.clone()).await.unwrap(), raw2);
2159    }
2160
2161    /// `internal/update` bail vetoes the restart: the fiber keeps serving,
2162    /// the proposed change lands in `vetoed_config`, and no runner runs.
2163    #[tokio::test]
2164    async fn update_interceptor_veto_skips_restart_keeps_config() {
2165        use crate::{Context, Fiber};
2166        let ctx = Context::new_root();
2167        let events = Arc::new(EventsService::new());
2168        ctx.provide_arc(events.clone());
2169        let fiber = Arc::new(Fiber::new());
2170        fiber.set_reload_context(&ctx);
2171        fiber.set_id(70_100);
2172
2173        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2174        let c = calls.clone();
2175        fiber.set_reload_runner(Box::new(move |_| {
2176            c.fetch_add(1, Ordering::SeqCst);
2177            Ok(true)
2178        }));
2179        fiber.declare_inject::<crate::ReflectService>();
2180        let _prov = ctx.provide(crate::ReflectService::new());
2181        fiber.refresh(&ctx).await;
2182        assert!(matches!(fiber.state(), crate::FiberState::Active { .. }));
2183        assert_eq!(calls.load(Ordering::SeqCst), 1, "initial apply ran");
2184
2185        // Vetoing interceptor: any update is refused from now on.
2186        let d = events.on(INTERNAL_UPDATE_EVENT.into(), |_payload| async move {
2187            Ok(serde_json::json!({ "veto": "maintenance window" }))
2188        });
2189        fiber.update(&ctx).await.unwrap();
2190        assert!(
2191            matches!(fiber.state(), crate::FiberState::Active { .. }),
2192            "vetoed update must keep the fiber Active, got {:?}",
2193            fiber.state()
2194        );
2195        assert_eq!(
2196            calls.load(Ordering::SeqCst),
2197            1,
2198            "runner must not run again under veto"
2199        );
2200        d.dispose();
2201
2202        // After disposal updates flow again: refresh re-applies (epoch
2203        // unchanged + satisfied ⇒ early return, but no veto either way).
2204        fiber.update(&ctx).await.unwrap();
2205        assert!(matches!(fiber.state(), crate::FiberState::Active { .. }));
2206    }
2207
2208    /// `internal/listener` bail cancels a registration: the returned handle
2209    /// is inert and NEITHER registry ever sees the listener. An ERRORING
2210    /// veto chain cancels too (fail-closed).
2211    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2212    async fn listener_interceptor_bail_cancels_registration_inert_handle() {
2213        let svc = EventsService::new();
2214        // NOTE: this service is reused later in the test; every gate installed
2215        // here is disposed before the sections that must register normally.
2216        let allow_gate = svc.on(INTERNAL_LISTENER_EVENT.into(), |payload| async move {
2217            if payload["event"] == "blocked.event" {
2218                Ok(serde_json::json!("denied"))
2219            } else {
2220                Ok(serde_json::Value::Null)
2221            }
2222        });
2223
2224        // Flat registration on the blocked event: inert handle.
2225        let ran = Arc::new(AtomicBool::new(false));
2226        let flag = ran.clone();
2227        let handle = svc.on("blocked.event".into(), move |_p| {
2228            let flag = flag.clone();
2229            async move {
2230                flag.store(true, Ordering::SeqCst);
2231                Ok(serde_json::Value::Null)
2232            }
2233        });
2234        handle.dispose(); // must flip nothing
2235        svc.dispatch("blocked.event".into(), serde_json::json!({}), Dispatch::Bail)
2236            .await
2237            .unwrap();
2238        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
2239        assert!(
2240            !ran.load(Ordering::SeqCst),
2241            "cancelled registration must never run"
2242        );
2243
2244        // Waterfall registration on the blocked event: also cancelled.
2245        let wf_handle = svc.on_waterfall(
2246            "blocked.event".into(),
2247            |_p: serde_json::Value, next| async move { next(_p).await },
2248        );
2249        wf_handle.dispose();
2250        assert_eq!(
2251            svc.listener_count("blocked.event"),
2252            0,
2253            "neither registry may hold a cancelled registration"
2254        );
2255
2256        // Unrelated event names still register normally through the same
2257        // veto chain (null verdict = allow).
2258        let allowed = svc.on("allowed.event".into(), |_p| async move {
2259            Ok(serde_json::Value::Null)
2260        });
2261        allowed.dispose();
2262        assert_eq!(svc.listener_count("allowed.event"), 0, "dispose works");
2263
2264        // Fail-closed: an erroring veto chain cancels too.
2265        let fail_gate = svc.on(INTERNAL_LISTENER_EVENT.into(), |_payload| async move {
2266            Err::<serde_json::Value, CordisError>(CordisError::Configuration("gate down".into()))
2267        });
2268        let second = svc.on("another.event".into(), |_p| async move {
2269            Ok(serde_json::Value::Null)
2270        });
2271        second.dispose();
2272        assert_eq!(
2273            svc.listener_count("another.event"),
2274            0,
2275            "erroring veto chain must fail closed"
2276        );
2277        // Drop the erroring gate and the allowing gate so this service's own
2278        // later sections register normally again.
2279        fail_gate.dispose();
2280        allow_gate.dispose();
2281    }
2282
2283    fn p_owned(p: serde_json::Value) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, CordisError>> + Send>> {
2284        Box::pin(async move { Ok(p) })
2285    }
2286
2287    /// `internal/dispatch` observes every NON-internal dispatch with
2288    /// (mode, name, args); meta-event dispatches themselves are exempt
2289    /// from recursion.
2290    #[tokio::test]
2291    async fn internal_dispatch_observes_non_internal_only() {
2292        let svc = EventsService::new();
2293        let seen = Arc::new(Mutex::new(Vec::<InternalDispatchPayload>::new()));
2294        let s = seen.clone();
2295        svc.on(INTERNAL_DISPATCH_EVENT.into(), move |payload| {
2296            let s = s.clone();
2297            async move {
2298                if let Ok(parsed) =
2299                    serde_json::from_value::<InternalDispatchPayload>(payload)
2300                {
2301                    s.lock().push(parsed);
2302                }
2303                Ok(serde_json::Value::Null)
2304            }
2305        });
2306
2307        // Three product-mode dispatches + one meta dispatch.
2308        svc.dispatch("observed.a".into(), serde_json::json!({ "n": 1 }), Dispatch::Emit)
2309            .await
2310            .unwrap();
2311        svc.dispatch(
2312            "observed.b".into(),
2313            serde_json::json!({ "n": 2 }),
2314            Dispatch::Waterfall,
2315        )
2316        .await
2317        .unwrap();
2318        svc.dispatch("observed.c".into(), serde_json::json!({}), Dispatch::Bail)
2319            .await
2320            .unwrap();
2321        // Meta-events are exempt: intercept_get consults internal/get only.
2322        svc.on(INTERNAL_GET_EVENT.into(), |_p| async move {
2323            Ok(serde_json::json!({ "x": true }))
2324        });
2325        svc.intercept_get("SomeSvc", None).await.unwrap();
2326        // The observer itself fires via spawn; poll briefly for delivery.
2327        for _ in 0..100 {
2328            if seen.lock().len() >= 3 {
2329                break;
2330            }
2331            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2332        }
2333        let observed = seen.lock().clone();
2334        assert_eq!(
2335            observed.len(),
2336            3,
2337            "meta dispatch must NOT be observed; got {observed:?}"
2338        );
2339        assert_eq!(observed[0].mode, "emit");
2340        assert_eq!(observed[0].name, "observed.a");
2341        assert_eq!(observed[1].mode, "waterfall");
2342        assert_eq!(observed[1].name, "observed.b");
2343        assert_eq!(observed[2].mode, "bail");
2344        assert_eq!(observed[0].args["n"], 1);
2345    }
2346
2347    /// A failing `internal/config` chain fails the activation: the fiber
2348    /// rests inspectable `Failed` carrying the interception error instead of
2349    /// activating with an unvalidated config.
2350    #[tokio::test]
2351    async fn interceptor_error_fails_fiber_activation() {
2352        use crate::{Context, Fiber};
2353        let ctx = Context::new_root();
2354        let events = Arc::new(EventsService::new());
2355        ctx.provide_arc(events.clone());
2356        events.on(INTERNAL_CONFIG_EVENT.into(), |_raw| async move {
2357            Err::<serde_json::Value, CordisError>(CordisError::Configuration(
2358                "config rejected by policy".into(),
2359            ))
2360        });
2361
2362        let fiber = Arc::new(Fiber::new());
2363        fiber.set_reload_context(&ctx);
2364        fiber.set_id(70_101);
2365        fiber.set_raw_config(serde_json::json!({ "model": "base" }));
2366        fiber.set_reload_runner(Box::new(|_| {
2367            panic!("runner must never run when config interception refuses");
2368        }));
2369        fiber.declare_inject::<crate::ReflectService>();
2370        let _prov = ctx.provide(crate::ReflectService::new());
2371
2372        fiber.refresh(&ctx).await;
2373        match fiber.state() {
2374            crate::FiberState::Failed { error } => {
2375                let msg = error.unwrap_or_default();
2376                assert!(
2377                    msg.contains("config rejected by policy"),
2378                    "failure must carry the interception error, got: {msg}"
2379                );
2380            }
2381            other => panic!("expected Failed activation, got {other:?}"),
2382        }
2383    }
2384
2385    /// Target-carrying dispatch helpers honor per-dispatch filters while
2386    /// leaving registrations intact: `bail_from` filters the flat registry,
2387    /// `waterfall_from` / `waterfall_async_from` filter the waterfall one.
2388    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2389    async fn target_carrying_dispatches_filter_per_dispatch() {
2390        let svc = EventsService::new();
2391        let flat_ran = Arc::new(AtomicUsize::new(0));
2392        let f = flat_ran.clone();
2393        svc.on_with(
2394            "wf.filtered".into(),
2395            EventOptions::default(),
2396            move |_p| {
2397                let f = f.clone();
2398                async move {
2399                    f.fetch_add(1, Ordering::SeqCst);
2400                    Ok(serde_json::Value::Null)
2401                }
2402            },
2403        );
2404        let wf_ran = Arc::new(AtomicUsize::new(0));
2405        let w = wf_ran.clone();
2406        svc.on_waterfall("wf.filtered".into(), move |_p, next| {
2407            let w = w.clone();
2408            async move {
2409                w.fetch_add(1, Ordering::SeqCst);
2410                next(_p).await
2411            }
2412        });
2413
2414        // bail_from with a rejecting filter: the flat listener never runs.
2415        let out = svc
2416            .bail_from(
2417                "wf.filtered".into(),
2418                serde_json::json!({"n": 1}),
2419                Some(Box::new(|_opts| false)),
2420            )
2421            .await
2422            .unwrap();
2423        // An all-excluded chain is empty: the payload passes through.
2424        assert_eq!(out, serde_json::json!({"n": 1}));
2425        assert_eq!(
2426            flat_ran.load(Ordering::SeqCst),
2427            0,
2428            "rejecting filter must exclude the flat listener"
2429        );
2430
2431        // waterfall_from with a rejecting filter: identity result.
2432        let out = svc
2433            .waterfall_from(
2434                "wf.filtered".into(),
2435                serde_json::json!({"n": 1}),
2436                Some(Box::new(|_opts| false)),
2437            )
2438            .await
2439            .unwrap();
2440        assert_eq!(out, serde_json::json!({"n": 1}));
2441        assert_eq!(
2442            wf_ran.load(Ordering::SeqCst),
2443            0,
2444            "rejecting filter must exclude the waterfall listener"
2445        );
2446
2447        // Admitting filters: each registry runs exactly once per dispatch.
2448        svc.bail_from(
2449            "wf.filtered".into(),
2450            serde_json::json!({"n": 2}),
2451            Some(Box::new(|_opts| true)),
2452        )
2453        .await
2454        .unwrap();
2455        assert_eq!(flat_ran.load(Ordering::SeqCst), 1);
2456        svc.waterfall_async_from(
2457            "wf.filtered".into(),
2458            serde_json::json!({"n": 2}),
2459            Some(Box::new(|_opts| true)),
2460            |mut p| async move {
2461                if let Some(obj) = p.as_object_mut() {
2462                    obj.insert("core".into(), serde_json::json!(true));
2463                }
2464                Ok(p)
2465            },
2466        )
2467        .await
2468        .unwrap();
2469        assert_eq!(wf_ran.load(Ordering::SeqCst), 1);
2470
2471        // Registrations survived every filtered dispatch.
2472        assert_eq!(svc.listener_count("wf.filtered"), 2);
2473    }
2474}