Interception

The kernel routes its own sensitive operations through the event bus. Five meta-events are veto points. A sixth observes dispatches. The constants live in cordis::events:

Meta-eventGuards
internal/getStrict service reads (Context::get)
internal/setService writes (provide paths)
internal/configConfig resolution before a plugin apply
internal/updateRestart scheduling on config change
internal/listenerListener registration
internal/dispatchObservation only; no veto power

These meta-events do not join the product event catalog, so catalog validation skips them.

Decision guide: which meta-event for which need

Pick by the operation you want to guard, not by the mechanism.

You need to...Subscribe toVeto power
Hide or rewrite one service from consumersinternal/getRefuse the read, redirect to the parent frame, or substitute the value
Freeze writes or audit every provideinternal/setBlock the write; old value stays intact
Transform or validate plugin configurationinternal/configReplace the effective config, or fail activation
Defer a restart during a change windowinternal/updatePark the proposed config in vetoed_config, keep serving
Gate listener registration per realminternal/listenerCancel registration with an inert handle
Trace every product dispatchinternal/dispatchNone — observation only

Rules of thumb:

  • One need, one meta-event. Do not emulate internal/update vetoes with config rewriting; update preserves the running application untouched, while config changes force a new application.
  • For multi-tenant scoping, register listeners inside the isolate realm. Meta-event listeners are ordinary listeners; they obey isolate boundaries like everything else on that context.
  • Prefer internal/dispatch over wrapping product code in logging. The observer sees every dispatch without touching call sites.

Zero-cost gates

Every interception point calls EventsService::listener_count first. The count is one pair of map lookups over the flat and waterfall registries. With zero listeners, the operation proceeds on its historical path with no chain work. This gate makes the whole feature free until someone subscribes.

Veto semantics

All veto chains run as bail chains: handlers run in registration order, and the first non-null result terminates the chain.

Ordering guarantees within one chain

Order inside a chain is deterministic:

  • Default registrations append to the back of that event's list. First registered runs first.
  • A registration with EventOptions { prepend: true } inserts at the front. It runs before every default registration, including ones made earlier.
  • Handlers are async but the chain awaits them sequentially. Handler N+1 never starts before handler N returns. There is no concurrency inside a bail chain.
  • The first non-null result wins. Later handlers do not run at all — not even for observation. Put audit listeners on internal/dispatch instead if they must always run.
  • Disposing a listener handle removes exactly that slot. Remaining handlers keep their relative order.

Test anchors: prepend_ordering_observed in events.rs.

internal/get

Payload: { "service", "ctx" }. Rules for intercept_get:

  • No listeners: Ok(None) at map-lookup cost.
  • A chain terminal of null passes the read through untouched.
  • A non-null result replaces what the consumer sees.
  • A chain error vetoes the read.

The synchronous bridge in Context::get maps verdicts further:

  • Non-null JSON with "refuse": true refuses the read outright (ReadVerdict::Refuse, lookup returns None).
  • Any other non-null value redirects the read to the parent frame (ReadVerdict::RedirectFrame). This frame's store and intercept bindings are skipped, so a parent binding serves the read.
  • Null passes normally.

Test anchors: get_interceptor_rewrites_read, accessor_bypasses_intercept_waterfalls.

internal/set

Payload: { "service", "ctx" }. A chain error vetoes the write. The previous value stays fully intact; no store, owner, or version mutation happens. Null and pass-through allow the write unchanged.

Test anchor: set_interceptor_vetoes_write_leaves_old_value.

internal/config

Input: the raw config value. The chain's non-null terminal is the effective configuration. A null terminal passes the raw config through. A chain error fails the activation or update that was resolving config; the fiber rests Failed.

The registry captures raw config at registration time. Each refresh pass re-resolves the effective config from that single source through Fiber::resolve_effective_config, stages it once, and the runner consumes it via effective_config_override. With no interception, the path is byte-identical to the legacy runner.

Test anchors: config_interceptor_rewrites_effective_config, config_waterfall_covers_activation_path, interceptor_error_fails_fiber_activation.

internal/update

Payload: { "service" }. Three outcomes inside Fiber::update:

  • Proceed: the chain passes (null counts as proceed here), so the restart runs.
  • Veto: a bail with explicit JSON false parks the proposed config in vetoed_config and returns Ok(()). No restart runs; the fiber keeps serving its current application. Operators can inspect what was deferred through Fiber::vetoed_config.
  • Error: a chain error propagates out of Fiber::update as an Err. The fiber stays Active on its old configuration. Nothing was applied and nothing was deferred.

Test anchors: update_interceptor_veto_skips_restart_keeps_config, update_veto_defers_config_and_returns_ok, update_error_stays_active_old_config.

internal/listener

Payload: { "event" }. Registrations are synchronous APIs, so the veto chain runs to completion on the current thread through block_in_place. Rules:

  • Ok(true) or a null terminal lets the registration proceed.
  • A bail (non-null, non-true) or a chain error cancels it. Fail-closed.
  • A cancelled registration returns an inert handle. Disposing it flips nothing.
  • On runtimes that cannot park a worker (single-thread flavors), the registration falls open and a warning records the skipped veto.

Test anchor: listener_interceptor_bail_cancels_registration_inert_handle.

internal/dispatch

Observer, not veto. Before every non-internal dispatch, listeners receive { mode, name, args } fire-and-forget. Handler results and errors drop by design: observability must never break or delay the observed operation. Internal meta-events are exempt, so observation cannot recurse into itself.

Test anchor: internal_dispatch_observes_non_internal_only.

Worked mini-scenarios

Each scenario shows the payload shape and one verdict path. Payloads are JSON objects; shapes come from the bridge implementations in events.rs and its tests.

Scenario: tenant A may not read the billing service. A listener on internal/get receives:

{ "service": "BillingStore", "ctx": "<frame id>" }

The handler returns { "refuse": true } when "service" names a billing type and the dispatch belongs to tenant A. The synchronous bridge maps that to ReadVerdict::Refuse, so Context::get returns None. Tenant B's handler returns null for the same event; null passes the read through untouched.

Scenario: freeze all writes during a migration. One listener on internal/set receives { "service": "SessionCache", "ctx": "<frame id>" } and returns an Err(CordisError). The chain aborts with that error and vetoes the write. The previous value stays fully intact: no store slot, owner, or version changes. Callers see the error; nothing else moves.

Scenario: inject feature flags into plugin config. The registry captured raw config {"pool": 4} at registration. A listener on internal/config receives that raw value and returns

{ "pool": 4, "feature_x": true }

That non-null terminal IS the effective configuration for every apply and refresh pass of this fiber. Removing the listener reverts the next refresh to the raw value.

Scenario: hold a restart during peak hours. An operator gate listens on internal/update. It receives { "service": "SearchIndex" } and returns JSON false outside the maintenance window. The proposed config parks in Fiber::vetoed_config and update returns Ok(()); the old application keeps serving. Returning any other non-null value — including an object — counts as proceed. Only explicit false is a veto.

Scenario: block a debug-only listener in production. A listener on internal/listener receives { "event": "tools/execute" }. For events matching a deny list it returns Ok(json!(false)). The registration cancels fail-closed and the caller gets an inert handle whose dispose flips nothing.

Scenario: trace latency per dispatch mode. A listener on internal/dispatch receives

{ "mode": "emit", "name": "agent/step", "args": { "n": 1 } }

before every non-internal emit. The handler records the timestamp and returns anything; results drop by design. Internal meta-events never trigger it, so tracing cannot recurse into itself.

Layered intercept chains

Intercept overrides stack per TypeId. Every set appends a layer. Layers order outermost to innermost across ancestor frames; the innermost layer is the effective value every getter returns. Context::intercept_chain returns the full chain. An isolate label is a realm boundary: layers beyond it do not leak in.

Structural comparison uses shared-instance identity (Arc::ptr_eq per layer) via Context::chains_structurally_equal. Freshly built values compare unequal by design.

Test anchors: chained_layers_append_innermost_effective, intercept_chain_returns_all_layers_in_order, inject_appends_layer.

Accessors bypass the waterfalls

Name-keyed accessors live beside the TypeId store. Register them with Context::register_accessor; alias names share one slot and dispose together. Reads use read_property (typed variant: read_property_typed); writes use write_property. Accessor reads and writes never consult or re-enter the internal/get or internal/set waterfalls. An interceptor that refuses all strict reads cannot block accessor traffic.

Test anchor: accessor_bypasses_intercept_waterfalls.

Target-carrying filtered dispatches

The *_from variants carry a target filter alongside the payload:

  • bail_from(event, payload, Option<ListenerFilter>)
  • waterfall_from(event, payload, Option<ListenerFilter>)
  • waterfall_async_from(event, payload, filter, core)

Non-global listeners whose registration options fail the filter skip this dispatch but stay registered. Global listeners bypass the filter and always participate. Kernel meta-events ride bail_from for their veto chains. A filter-empty snapshot still leaves live registrations intact; only cancelled slots prune.

Test anchors: target_carrying_dispatches_filter_per_dispatch, filter_excludes_nonmatching_contexts, global_bypasses_filter.

Re-entrancy protection

A thread-local fence (InterceptFence) marks the thread while a synchronous bridge drives its chain. Nested operations on the same thread pass through unintercepted, so an internal/get listener that reads services does not recurse into its own veto. Bridges also require a multi-thread runtime; without one they fall open and log a warning.

Why the fence exists

Consider an internal/get listener that inspects other services to make its verdict. The inspection calls ctx.get. Without protection that read re-enters the same veto chain, which reads services again. The stack grows until something breaks.

The fence is one boolean in thread-local storage:

  1. InterceptFence::enter sets it and returns a guard.
  2. The bridge drives its chain while the guard lives.
  3. Any nested operation on this thread sees the flag set. Its bridge short-circuits to "allow" before consulting listeners.
  4. Dropping the guard clears the flag.

The scope is deliberately per-thread, not global. Two worker threads can each drive their own chain at the same time. Only true nesting — one bridge inside another on the same call stack — passes through unintercepted.

The same reasoning protects internal/set, internal/config, internal/update, and internal/listener: every synchronous bridge enters the fence first. A fenced nested write proceeds unintercepted, so an interceptor can record state without tripping its own gate.

Test anchor: accessor_bypasses_intercept_waterfalls shows the related design bypass for accessors; the fence covers same-thread recursion for every bridge.

Fall-open on single-thread runtimes

Synchronous bridges park the current worker with block_in_place. Parking requires spare workers, so it needs a multi-thread tokio runtime. On a current-thread flavor there are no spare workers, and block_in_place panics.

Each bridge checks the flavor before parking:

listener_count == 0        -> historical path (zero cost)
fence already held         -> allow, no recursion
single-thread runtime      -> allow + warning: skipped veto
otherwise                  -> park and run the bail chain

Fall-open means fail-open by design choice. Registration and reads are core APIs; refusing them because of a runtime flavor would break every test harness and embedded use. The warning names the skipped veto so deployments notice when they expected enforcement. Product deployments run multi-thread flavors, where the chain always runs.