Skip to content

Commit 17254d7

Browse files
authored
feat(middleware): add dynamic conditional middleware guardrails (#831)
#### Overview Add dynamically registered conditional middleware guardrails that can enable or disable global runtime registrations by registration kind and effective name. The surface is available to Rust, primary language bindings, native plugins, and gRPC worker plugins without introducing a new native ABI version beyond V4. - [x] I confirm this contribution is my own work, or I have the right to submit it under this project's license. - [x] I searched existing issues and open pull requests, and this does not duplicate existing work. #### Details - Add stable runtime-registration kinds, structured registration identity/discovery, and a process-global conditional guardrail registry. - Centralize the shared runtime-registration discovery DTOs in `nemo-relay-types` while preserving the existing core, native plugin SDK, and worker SDK import paths through re-exports. - Apply matching gates at runtime snapshot boundaries so registering or deregistering a gate changes behavior between turns without rebuilding the underlying registration registry. - Preserve the agreed invariants: global registrations only, scope-local registrations remain ungated, every matching gate must allow a target, and gate failures fail open. - Expose registration discovery and dynamic gate lifecycle APIs through Rust, Python, Node.js, experimental Go/C FFI, native plugin ABI V4, and the gRPC worker protocol/Python worker SDK. - Document native and worker timer-driven control patterns and middleware semantics. Validation: - `cargo test -p nemo-relay-types` — passed, including stable kind serialization, DTO round trips, and trait coverage. - `just test-rust` — passed. - `just test-python` — passed (689 package tests and 19 Python plugin-example tests). - `just test-node` — passed (396 package tests and 21 Node plugin-example tests). - The new Go conditional-guardrail test passed. `just test-go` otherwise reaches the existing `TestObservabilityPluginActivatesDerivedLogsAndExplicitMetrics` timeout waiting for `/v1/logs`; the same failure reproduced twice from untouched `origin/release/0.8`, so it is not introduced by this branch. - `cargo clippy --workspace --all-targets -- -D warnings` — passed. - `uv run pre-commit run --all-files` — passed. Real-provider end-to-end validation has not been run. Current executable coverage uses in-process/runtime fixtures and local test collectors. Breaking changes: none expected. This extends the V4 ABI being introduced for the 0.8 release rather than adding V5. The shared DTO move preserves existing Rust import paths and JSON/protobuf/C ABI wire shapes. #### Where should the reviewer start? Start with `crates/types/src/api/registry.rs` for the canonical discovery model and `crates/core/src/api/registry.rs` for the gate registry and compatibility re-exports, then `crates/core/src/api/runtime/state.rs` for runtime filtering semantics. Cross-plane APIs are centered in `crates/plugin/src/lib.rs`, `crates/core/src/plugin/dynamic/worker.rs`, and `crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto`. End-to-end core behavior is covered in `crates/core/tests/integration/middleware_tests.rs`. #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) - Relates to: N/A ## Summary by CodeRabbit - **New Features** - Added conditional middleware guardrails that can temporarily disable matching subscribers, sanitizers, guardrails, metadata injectors, or intercepts. - Added runtime registration discovery with filtering and ownership metadata. - Added management APIs across Python, Node.js, Go, C, native plugins, and worker plugins. - Added lifecycle cleanup, callback safety, and fail-open behavior. - **Bug Fixes** - Improved runtime consistency by evaluating middleware from stable snapshots. - **Documentation** - Documented guardrail behavior, ordering, ownership, discovery, and usage. Authors: - Bryan Bednarski (https://github.com/bbednarski9) - Will Killian (https://github.com/willkill07) Approvers: - Will Killian (https://github.com/willkill07) URL: #831
1 parent b4ef220 commit 17254d7

53 files changed

Lines changed: 5118 additions & 370 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/core/src/api/llm.rs

Lines changed: 162 additions & 92 deletions
Large diffs are not rendered by default.

crates/core/src/api/registry.rs

Lines changed: 262 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,273 @@
55
//! intercepts, and subscribers.
66
77
use crate::api::runtime::{
8-
EventMetadataInjectorFn, EventSanitizeFn, LlmConditionalFn, LlmExecutionFn,
9-
LlmRequestInterceptFn, LlmSanitizeRequestFn, LlmSanitizeResponseFn, LlmStreamExecutionFn,
10-
ToolConditionalFn, ToolExecutionFn, ToolInterceptFn, ToolSanitizeFn,
8+
ConditionalMiddlewareGuardrailFn, EventMetadataInjectorFn, EventSanitizeFn, LlmConditionalFn,
9+
LlmExecutionFn, LlmRequestInterceptFn, LlmSanitizeRequestFn, LlmSanitizeResponseFn,
10+
LlmStreamExecutionFn, ToolConditionalFn, ToolExecutionFn, ToolInterceptFn, ToolSanitizeFn,
1111
};
1212
use crate::api::runtime::{current_scope_stack, global_context};
1313
use crate::api::shared::ensure_runtime_owner;
1414
use crate::error::{FlowError, Result};
1515
use crate::registry::RegistryEntry;
16+
pub use nemo_relay_types::api::registry::{
17+
RuntimeRegistrationIdentity, RuntimeRegistrationKind, RuntimeRegistrationOwner,
18+
RuntimeRegistrationOwnerKind,
19+
};
20+
use std::collections::{BTreeMap, BTreeSet};
21+
use std::panic::{AssertUnwindSafe, catch_unwind};
22+
use std::sync::{OnceLock, RwLock};
23+
24+
#[derive(Clone)]
25+
struct ConditionalMiddlewareGuardrail {
26+
kinds: BTreeSet<RuntimeRegistrationKind>,
27+
registration_name: String,
28+
callback: ConditionalMiddlewareGuardrailFn,
29+
}
30+
31+
static CONDITIONAL_MIDDLEWARE_GUARDRAILS: OnceLock<
32+
RwLock<BTreeMap<String, ConditionalMiddlewareGuardrail>>,
33+
> = OnceLock::new();
34+
35+
fn conditional_middleware_guardrails()
36+
-> &'static RwLock<BTreeMap<String, ConditionalMiddlewareGuardrail>> {
37+
CONDITIONAL_MIDDLEWARE_GUARDRAILS.get_or_init(|| RwLock::new(BTreeMap::new()))
38+
}
39+
40+
/// Register a global conditional middleware guardrail.
41+
pub fn register_conditional_middleware_guardrail(
42+
name: &str,
43+
kinds: BTreeSet<RuntimeRegistrationKind>,
44+
registration_name: &str,
45+
guardrail: ConditionalMiddlewareGuardrailFn,
46+
) -> Result<()> {
47+
ensure_runtime_owner()?;
48+
if kinds.is_empty() {
49+
return Err(FlowError::InvalidArgument(
50+
"conditional middleware guardrail kinds must not be empty".to_string(),
51+
));
52+
}
53+
let mut gates = conditional_middleware_guardrails()
54+
.write()
55+
.map_err(|error| FlowError::Internal(error.to_string()))?;
56+
if gates.contains_key(name) {
57+
return Err(FlowError::AlreadyExists(format!(
58+
"{name} conditional middleware guardrail already exists"
59+
)));
60+
}
61+
gates.insert(
62+
name.to_string(),
63+
ConditionalMiddlewareGuardrail {
64+
kinds,
65+
registration_name: registration_name.to_string(),
66+
callback: guardrail,
67+
},
68+
);
69+
Ok(())
70+
}
71+
72+
/// Deregister a global conditional middleware guardrail.
73+
pub fn deregister_conditional_middleware_guardrail(name: &str) -> Result<bool> {
74+
ensure_runtime_owner()?;
75+
let mut gates = conditional_middleware_guardrails()
76+
.write()
77+
.map_err(|error| FlowError::Internal(error.to_string()))?;
78+
Ok(gates.remove(name).is_some())
79+
}
80+
81+
/// Return whether one global runtime registration is enabled by every
82+
/// matching conditional middleware guardrail.
83+
pub(crate) fn runtime_registration_is_enabled(
84+
kind: RuntimeRegistrationKind,
85+
effective_name: &str,
86+
) -> bool {
87+
let Some(registry) = CONDITIONAL_MIDDLEWARE_GUARDRAILS.get() else {
88+
return true;
89+
};
90+
let matching = match registry.read() {
91+
Ok(gates) => gates
92+
.iter()
93+
.filter(|(_, gate)| {
94+
gate.kinds.contains(&kind) && gate.registration_name == effective_name
95+
})
96+
.map(|(name, gate)| (name.clone(), gate.kinds.clone(), gate.callback.clone()))
97+
.collect::<Vec<_>>(),
98+
Err(error) => {
99+
log::error!(
100+
target: "nemo_relay.runtime",
101+
event = "conditional_middleware_guardrail_registry_failed",
102+
registration_kind = kind.as_str(),
103+
registration_name = effective_name;
104+
"Conditional middleware guardrail registry read failed; enabling target: {error}"
105+
);
106+
return true;
107+
}
108+
};
109+
110+
for (gate_name, kinds, callback) in matching {
111+
match catch_unwind(AssertUnwindSafe(|| callback(&kinds, effective_name))) {
112+
Ok(None) => {}
113+
Ok(Some(reason)) => {
114+
log::debug!(
115+
target: "nemo_relay.runtime",
116+
event = "runtime_registration_disabled",
117+
gate = gate_name.as_str(),
118+
registration_kind = kind.as_str(),
119+
registration_name = effective_name,
120+
reason = reason.as_str();
121+
"Conditional middleware guardrail disabled runtime registration"
122+
);
123+
return false;
124+
}
125+
Err(_) => log::error!(
126+
target: "nemo_relay.runtime",
127+
event = "conditional_middleware_guardrail_panicked",
128+
gate = gate_name.as_str(),
129+
registration_kind = kind.as_str(),
130+
registration_name = effective_name;
131+
"Conditional middleware guardrail panicked; enabling target"
132+
),
133+
}
134+
}
135+
true
136+
}
137+
138+
fn registration_identity(
139+
kind: RuntimeRegistrationKind,
140+
effective_name: &str,
141+
) -> RuntimeRegistrationIdentity {
142+
const PREFIX: &str = "__nemo_relay_plugin__";
143+
let Some(rest) = effective_name.strip_prefix(PREFIX) else {
144+
return RuntimeRegistrationIdentity {
145+
kind,
146+
local_name: effective_name.to_string(),
147+
effective_name: effective_name.to_string(),
148+
owner: RuntimeRegistrationOwner {
149+
kind: RuntimeRegistrationOwnerKind::GlobalApi,
150+
plugin_kind: None,
151+
component_ordinal: None,
152+
},
153+
};
154+
};
155+
let mut pieces = rest.split("__");
156+
let plugin_kind = pieces.next().unwrap_or_default().to_string();
157+
let second = pieces.next().unwrap_or_default();
158+
let (component_ordinal, local_name) = match second.parse::<u32>() {
159+
Ok(ordinal) => (Some(ordinal), pieces.collect::<Vec<_>>().join("__")),
160+
Err(_) => {
161+
let mut local = vec![second];
162+
local.extend(pieces);
163+
(None, local.join("__"))
164+
}
165+
};
166+
RuntimeRegistrationIdentity {
167+
kind,
168+
local_name,
169+
effective_name: effective_name.to_string(),
170+
owner: RuntimeRegistrationOwner {
171+
kind: RuntimeRegistrationOwnerKind::Plugin,
172+
plugin_kind: Some(plugin_kind),
173+
component_ordinal,
174+
},
175+
}
176+
}
177+
178+
/// List a deterministic snapshot of global gateable runtime registrations.
179+
pub fn list_runtime_registrations(
180+
kinds: Option<&BTreeSet<RuntimeRegistrationKind>>,
181+
) -> Result<Vec<RuntimeRegistrationIdentity>> {
182+
ensure_runtime_owner()?;
183+
let context = global_context();
184+
let state = context
185+
.read()
186+
.map_err(|error| FlowError::Internal(error.to_string()))?;
187+
let mut registrations = Vec::new();
188+
macro_rules! collect_registry {
189+
($kind:expr, $field:ident) => {
190+
if kinds.is_none_or(|selected| selected.contains(&$kind)) {
191+
registrations.extend(
192+
state
193+
.$field
194+
.values()
195+
.map(|entry| registration_identity($kind, &entry.name)),
196+
);
197+
}
198+
};
199+
}
200+
if kinds.is_none_or(|selected| selected.contains(&RuntimeRegistrationKind::Subscriber)) {
201+
registrations.extend(
202+
state
203+
.event_subscribers
204+
.keys()
205+
.map(|name| registration_identity(RuntimeRegistrationKind::Subscriber, name)),
206+
);
207+
}
208+
collect_registry!(
209+
RuntimeRegistrationKind::EventMetadataInjector,
210+
event_metadata_injectors
211+
);
212+
collect_registry!(
213+
RuntimeRegistrationKind::MarkSanitizeGuardrail,
214+
mark_sanitize_guardrails
215+
);
216+
collect_registry!(
217+
RuntimeRegistrationKind::ScopeSanitizeStartGuardrail,
218+
scope_sanitize_start_guardrails
219+
);
220+
collect_registry!(
221+
RuntimeRegistrationKind::ScopeSanitizeEndGuardrail,
222+
scope_sanitize_end_guardrails
223+
);
224+
collect_registry!(
225+
RuntimeRegistrationKind::ToolSanitizeRequestGuardrail,
226+
tool_sanitize_request_guardrails
227+
);
228+
collect_registry!(
229+
RuntimeRegistrationKind::ToolSanitizeResponseGuardrail,
230+
tool_sanitize_response_guardrails
231+
);
232+
collect_registry!(
233+
RuntimeRegistrationKind::ToolConditionalExecutionGuardrail,
234+
tool_conditional_execution_guardrails
235+
);
236+
collect_registry!(
237+
RuntimeRegistrationKind::ToolRequestIntercept,
238+
tool_request_intercepts
239+
);
240+
collect_registry!(
241+
RuntimeRegistrationKind::ToolExecutionIntercept,
242+
tool_execution_intercepts
243+
);
244+
collect_registry!(
245+
RuntimeRegistrationKind::LlmSanitizeRequestGuardrail,
246+
llm_sanitize_request_guardrails
247+
);
248+
collect_registry!(
249+
RuntimeRegistrationKind::LlmSanitizeResponseGuardrail,
250+
llm_sanitize_response_guardrails
251+
);
252+
collect_registry!(
253+
RuntimeRegistrationKind::LlmConditionalExecutionGuardrail,
254+
llm_conditional_execution_guardrails
255+
);
256+
collect_registry!(
257+
RuntimeRegistrationKind::LlmRequestIntercept,
258+
llm_request_intercepts
259+
);
260+
collect_registry!(
261+
RuntimeRegistrationKind::LlmExecutionIntercept,
262+
llm_execution_intercepts
263+
);
264+
collect_registry!(
265+
RuntimeRegistrationKind::LlmStreamExecutionIntercept,
266+
llm_stream_execution_intercepts
267+
);
268+
registrations.sort_by(|left, right| {
269+
left.kind
270+
.cmp(&right.kind)
271+
.then_with(|| left.effective_name.cmp(&right.effective_name))
272+
});
273+
Ok(registrations)
274+
}
16275

17276
/// A priority-ordered registration record.
18277
///

crates/core/src/api/runtime.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ pub mod state;
1111
pub mod subscriber_dispatcher;
1212

1313
pub use callbacks::{
14-
BuiltinLlmCodec, EventMetadataInjectorFn, EventSanitizeFn, EventSubscriberFn, LlmCodecIdentity,
15-
LlmCollectorFn, LlmConditionalFn, LlmExecutionFn, LlmExecutionNextFn, LlmFinalizerFn,
16-
LlmJsonStream, LlmRequestInterceptFn, LlmSanitizeRequestContext, LlmSanitizeRequestFn,
17-
LlmSanitizeResponseContext, LlmSanitizeResponseFn, LlmStreamExecutionFn,
18-
LlmStreamExecutionNextFn, LlmStreamInner, ToolConditionalFn, ToolExecutionFn,
19-
ToolExecutionNextFn, ToolInterceptFn, ToolSanitizeFn,
14+
BuiltinLlmCodec, ConditionalMiddlewareGuardrailFn, EventMetadataInjectorFn, EventSanitizeFn,
15+
EventSubscriberFn, LlmCodecIdentity, LlmCollectorFn, LlmConditionalFn, LlmExecutionFn,
16+
LlmExecutionNextFn, LlmFinalizerFn, LlmJsonStream, LlmRequestInterceptFn,
17+
LlmSanitizeRequestContext, LlmSanitizeRequestFn, LlmSanitizeResponseContext,
18+
LlmSanitizeResponseFn, LlmStreamExecutionFn, LlmStreamExecutionNextFn, LlmStreamInner,
19+
ToolConditionalFn, ToolExecutionFn, ToolExecutionNextFn, ToolInterceptFn, ToolSanitizeFn,
2020
};
2121
#[doc(hidden)]
2222
pub use continuation_context::MiddlewareContinuationContext;

crates/core/src/api/runtime/callbacks.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! so the runtime can compose tool and LLM middleware consistently across
99
//! bindings.
1010
11-
use std::collections::BTreeMap;
11+
use std::collections::{BTreeMap, BTreeSet};
1212
use std::future::Future;
1313
use std::pin::Pin;
1414
use std::sync::Arc;
@@ -18,13 +18,22 @@ use tokio_stream::Stream;
1818

1919
use crate::api::event::{Event, EventSanitizeFields};
2020
use crate::api::llm::{LlmRequest, LlmRequestInterceptOutcome};
21+
use crate::api::registry::RuntimeRegistrationKind;
2122
use crate::api::tool::{ToolExecutionInterceptOutcome, ToolExecutionResult};
2223
use crate::codec::request::AnnotatedLlmRequest;
2324
use crate::codec::traits::{LlmCodec, LlmResponseCodec};
2425
use crate::error::Result;
2526
use crate::json::Json;
2627
pub use nemo_relay_types::codec::identity::{BuiltinLlmCodec, LlmCodecIdentity};
2728

29+
/// Decide whether matching global runtime registrations remain eligible.
30+
///
31+
/// Relay invokes this callback with the gate's configured registration kinds
32+
/// and the target registration's effective name. Returning `None` enables the
33+
/// target. Returning a reason string disables it for the current snapshot.
34+
pub type ConditionalMiddlewareGuardrailFn =
35+
Arc<dyn Fn(&BTreeSet<RuntimeRegistrationKind>, &str) -> Option<String> + Send + Sync>;
36+
2837
/// Sanitize mutable observability fields on a fully constructed event.
2938
///
3039
/// The callback receives the current event as immutable context and the fields

crates/core/src/api/runtime/scope_stack.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,21 @@ impl ScopeStack {
369369
.collect()
370370
}
371371

372+
/// Clone one registry field from every active scope that owns it.
373+
///
374+
/// Eligibility callbacks must not run while the scope-stack lock is held.
375+
/// Resolution paths use these owned snapshots before consulting global
376+
/// conditional middleware guardrails.
377+
pub(crate) fn snapshot_scope_local_registries<T: RegistryEntry + Clone>(
378+
&self,
379+
field: impl Fn(&ScopeLocalRegistries) -> &SortedRegistry<T>,
380+
) -> Vec<SortedRegistry<T>> {
381+
self.collect_scope_local_registries(field)
382+
.into_iter()
383+
.cloned()
384+
.collect()
385+
}
386+
372387
/// Collect all scope-local subscribers visible from the active stack.
373388
///
374389
/// # Returns

0 commit comments

Comments
 (0)