diff --git a/crates/switchyard-nemo-relay-plugin/README.md b/crates/switchyard-nemo-relay-plugin/README.md index 208de4386..f90b3ca5a 100644 --- a/crates/switchyard-nemo-relay-plugin/README.md +++ b/crates/switchyard-nemo-relay-plugin/README.md @@ -236,7 +236,7 @@ retains ownership of the outer LLM lifecycle. When Relay is configured with OTLP logs and metrics exporters, the plugin emits typed telemetry through Relay's native plugin runtime: -- Routing request, decision, and overhead marks are Info logs. +- Routing request, privacy decision, routing decision, and overhead marks are Info logs. - Per-model call marks are Debug logs with `call_role`, outcome, and latency, but no token usage. Streaming marks cover stream creation; later failures are reported separately. @@ -267,6 +267,7 @@ meaning requires a new schema version. | Mark | Data fields | | --- | --- | | `switchyard.routing.requested` | `algorithm` | +| `switchyard.privacy.decision` | `lane`, `source`, `reason_code`, optional `clear_score`, and optional `clear_threshold` | | `switchyard.routing.llm_call` | `call_index`, `selected_model`, `call_role`, `outcome`, `latency_ms` | | `switchyard.routing.overhead` | `latency_ms` | | `switchyard.routing.decision` | `algorithm`, optional `outcome_id`, `selected_model`, nullable `served_model`, nullable `fallback_used`, and optional `evidence` | @@ -277,6 +278,10 @@ meaning requires a new schema version. `evidence` is an object containing the supported string fields `source`, `verdict`, `trigger`, and `reason_code`, and numeric fields `score`, `confidence`, and `threshold`. String values longer than 64 bytes are omitted and should be stable, non-sensitive labels. +After successful lane selection, privacy-enabled routes record one privacy decision mark. The +mark is retained if the selected lane later fails. `clear_score` is the classifier probability +assigned to `no_sensitive_content`; `clear_threshold` is the minimum score required for the +standard lane. Routes without privacy configuration do not emit this mark. ## Failure policy diff --git a/crates/switchyard-nemo-relay-plugin/src/runtime.rs b/crates/switchyard-nemo-relay-plugin/src/runtime.rs index 4c1b7ab05..6f23f7086 100644 --- a/crates/switchyard-nemo-relay-plugin/src/runtime.rs +++ b/crates/switchyard-nemo-relay-plugin/src/runtime.rs @@ -17,7 +17,7 @@ use switchyard_protocol::{ Request, Response, Usage, WireFormat, }; use switchyard_runner::{ - ProviderKeyRedactor, Route, RouteErrorSummary, Runner, stream_error_summary, + PrivacyDecision, ProviderKeyRedactor, Route, RouteErrorSummary, Runner, stream_error_summary, }; use switchyard_translation::{TranslationEngine, encode_stream_with_extensions}; @@ -254,7 +254,12 @@ impl SwitchyardRuntime { .unwrap_or_else(|poisoned| poisoned.into_inner()) .push(observation); }); - match route.execute(request, Some(observer)).await { + let result = route + .execute_with_privacy_observer(request, Some(observer), |decision| { + events.push(privacy_decision_mark(decision, metadata.clone())); + }) + .await; + match result { Ok(output) => { let outcome_fields = self.emit_observations( &mut events, @@ -666,6 +671,29 @@ fn route_execution_error_mark( } } +fn privacy_decision_mark(decision: &PrivacyDecision, metadata: Json) -> RoutingEvent { + let mut data = Map::from_iter([ + ("lane".into(), Json::String(decision.lane().into())), + ("source".into(), Json::String(decision.source().into())), + ( + "reason_code".into(), + Json::String(decision.reason_code().into()), + ), + ]); + if let Some(score) = decision.clear_score() { + data.insert("clear_score".into(), json!(score)); + } + if let Some(threshold) = decision.clear_threshold() { + data.insert("clear_threshold".into(), json!(threshold)); + } + RoutingEvent::Mark(RoutingMark { + name: "switchyard.privacy.decision".into(), + data: Json::Object(data), + metadata, + severity: Some(LogSeverity::Info), + }) +} + fn route_execution_error_events( summary: &RouteErrorSummary, metadata: Json, @@ -958,6 +986,47 @@ mod tests { .expect("cross-format runtime should load") } + fn privacy_runtime(base_url: &str) -> SwitchyardRuntime { + let deployment = json!({ + "schema_version": 1, + "llm_clients": { + "target": { + "format": "openai_chat", + "base_url": base_url, + "max_retries": 0, + } + }, + "targets": { + "standard": {"id": "standard/model", "llm_client": "target"}, + "restricted": {"id": "restricted/model", "llm_client": "target", "private": true}, + "backup": {"id": "backup/model", "llm_client": "target", "private": true}, + }, + "routes": { + "privacy": { + "id": "switchyard/privacy", + "type": "random", + "targets": ["standard", "restricted", "backup"], + "weights": [100.0, 10.0, 1.0], + "seed": 42, + "privacy": { + "accept_external_signal": true, + }, + } + } + }); + SwitchyardRuntime::new(crate::config::SwitchyardConfig { + priority: 0, + switchyard_config_path: None, + switchyard_config: Some( + deployment + .as_object() + .expect("deployment should be an object") + .clone(), + ), + }) + .expect("privacy runtime should load") + } + fn namespaced_responses_request() -> RelayRequest { RelayRequest { headers: Map::new(), @@ -988,6 +1057,197 @@ mod tests { assert!(!runtime.manages_model("other")); } + #[tokio::test] + async fn privacy_marks_survive_lane_failure() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_partial_json(json!({"model": "standard/model"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl_privacy", + "object": "chat.completion", + "model": "standard/model", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }))) + .expect(1) + .mount(&server) + .await; + for model in ["restricted/model", "backup/model"] { + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_partial_json(json!({"model": model}))) + .respond_with(ResponseTemplate::new(503).set_body_string("unavailable")) + .expect(1) + .mount(&server) + .await; + } + let runtime = privacy_runtime(&format!("{}/v1", server.uri())); + + for (restricted, expected, succeeds) in [ + ( + false, + json!({"lane": "standard", "source": "policy", "reason_code": "all_clear"}), + true, + ), + ( + true, + json!({"lane": "restricted", "source": "external_signal", "reason_code": "restricted"}), + false, + ), + ] { + let mut request = runtime + .decode_request( + WireFormat::OpenAiChat, + RelayRequest { + headers: Map::new(), + content: json!({ + "model": "switchyard/privacy", + "messages": [{"role": "user", "content": "hello"}], + }), + }, + false, + ) + .expect("privacy request should decode"); + if restricted { + switchyard_runner::mark_privacy_restricted(&mut request); + } + let execution = runtime + .execute_buffered(WireFormat::OpenAiChat, request) + .await; + assert_eq!(execution.result.is_ok(), succeeds); + let marks = execution + .events + .iter() + .filter_map(|event| match event { + RoutingEvent::Mark(mark) => Some(mark), + RoutingEvent::Metric(_) => None, + }) + .collect::>(); + let privacy = marks + .iter() + .position(|mark| mark.name == "switchyard.privacy.decision") + .expect("privacy mark should be emitted"); + assert_eq!(marks[privacy].data, expected); + if !succeeds { + let error = marks + .iter() + .position(|mark| mark.name == "switchyard.routing.error") + .expect("failure should emit an error mark"); + assert!(privacy < error); + } + } + } + + // Answer failures must not restore a public candidate, including on the streaming path. + #[tokio::test] + async fn private_target_flags_restrict_buffered_and_streaming_fallbacks() { + for streaming in [false, true] { + let server = MockServer::start().await; + Mock::given(body_partial_json(json!({"model": "restricted/model"}))) + .respond_with(ResponseTemplate::new(503).set_body_string("unavailable")) + .expect(1) + .mount(&server) + .await; + let response = json!({ + "id": "chatcmpl_private", + "object": if streaming { "chat.completion.chunk" } else { "chat.completion" }, + "model": "backup/model", + "choices": [{ + "index": 0, + (if streaming { "delta" } else { "message" }): {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }); + let response = if streaming { + ResponseTemplate::new(200).set_body_raw( + format!("data: {response}\n\ndata: [DONE]\n\n"), + "text/event-stream", + ) + } else { + ResponseTemplate::new(200).set_body_json(response) + }; + Mock::given(body_partial_json(json!({"model": "backup/model"}))) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + let runtime = privacy_runtime(&format!("{}/v1", server.uri())); + let mut request = runtime + .decode_request( + WireFormat::OpenAiChat, + RelayRequest { + headers: Map::new(), + content: json!({ + "model": "switchyard/privacy", + "messages": [{"role": "user", "content": "hello"}], + "stream": streaming, + }), + }, + streaming, + ) + .expect("privacy request should decode"); + switchyard_runner::mark_privacy_restricted(&mut request); + let events = if streaming { + let execution = runtime + .execute_stream(WireFormat::OpenAiChat, request, Arc::new(|_| {})) + .await; + let chunks = execution + .result + .expect("private fallback should stream") + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("stream should encode"); + assert!( + chunks + .iter() + .any(|chunk| chunk["choices"][0]["delta"]["content"] == "ok") + ); + execution.events + } else { + let execution = runtime + .execute_buffered(WireFormat::OpenAiChat, request) + .await; + assert_eq!( + execution.result.expect("private fallback should answer")["model"], + "backup/model" + ); + execution.events + }; + let decision = events + .iter() + .find_map(|event| match event { + RoutingEvent::Mark(mark) if mark.name == "switchyard.routing.decision" => { + Some(&mark.data) + } + _ => None, + }) + .expect("routing decision should be emitted"); + assert_eq!(decision["selected_model"], "restricted/model"); + assert_eq!(decision["served_model"], "backup/model"); + assert_eq!(decision["fallback_used"], true); + let requests = server + .received_requests() + .await + .expect("requests should be recorded"); + let models = requests + .iter() + .map(|request| { + serde_json::from_slice::(&request.body).expect("request JSON")["model"] + .clone() + }) + .collect::>(); + assert_eq!(models, ["restricted/model", "backup/model"]); + } + } + #[tokio::test] async fn responses_requests_use_the_configured_target_format() { for (format, endpoint, upstream_response) in [ diff --git a/crates/switchyard-runner/src/lib.rs b/crates/switchyard-runner/src/lib.rs index d8ad1602b..e63671c32 100644 --- a/crates/switchyard-runner/src/lib.rs +++ b/crates/switchyard-runner/src/lib.rs @@ -19,7 +19,7 @@ pub use algorithm::{ pub use failure::{RouteErrorKind, RouteErrorPhase, RouteErrorSummary, stream_error_summary}; // Re-exported because `Route::new` takes it, so a host wiring routes does not need a libsy dep. pub use libsy::RuntimeModels; -pub use privacy::mark_privacy_restricted; +pub use privacy::{PrivacyDecision, mark_privacy_restricted}; pub use provider_key_redactor::ProviderKeyRedactor; pub use route::{ AuxiliaryTarget, CallerAuthKind, ModelCapabilities, Route, RunOutput, RunnerError, diff --git a/crates/switchyard-runner/src/privacy.rs b/crates/switchyard-runner/src/privacy.rs index 6ac8a2dde..ac0bcd229 100644 --- a/crates/switchyard-runner/src/privacy.rs +++ b/crates/switchyard-runner/src/privacy.rs @@ -67,7 +67,7 @@ pub(crate) fn selected_lane(request: &Request) -> Option { } /// Bounded explanation of the privacy lane selected for one request. -pub(crate) struct PrivacyDecision { +pub struct PrivacyDecision { lane: PrivacyLane, source: PrivacySource, reason_code: &'static str, @@ -93,27 +93,27 @@ impl PrivacySource { impl PrivacyDecision { /// Returns the selected execution lane. - pub(crate) fn lane(&self) -> &'static str { + pub fn lane(&self) -> &'static str { self.lane.as_str() } /// Returns the policy component that selected the lane. - pub(crate) fn source(&self) -> &'static str { + pub fn source(&self) -> &'static str { self.source.as_str() } /// Returns a bounded reason for the decision. - pub(crate) const fn reason_code(&self) -> &'static str { + pub const fn reason_code(&self) -> &'static str { self.reason_code } /// Returns the classifier probability assigned to clear content, when available. - pub(crate) const fn clear_score(&self) -> Option { + pub const fn clear_score(&self) -> Option { self.clear_score } /// Returns the minimum clear score required for the standard lane, when applicable. - pub(crate) const fn clear_threshold(&self) -> Option { + pub const fn clear_threshold(&self) -> Option { self.clear_threshold } diff --git a/crates/switchyard-runner/src/route.rs b/crates/switchyard-runner/src/route.rs index bca8cbebb..36fce3eaa 100644 --- a/crates/switchyard-runner/src/route.rs +++ b/crates/switchyard-runner/src/route.rs @@ -290,7 +290,21 @@ impl Route { request: Request, observer: Option, ) -> Result { - let (lane, _) = self.select_lane(&request).await?; + self.execute_with_privacy_observer(request, observer, |_| {}) + .await + } + + /// Executes the route and reports a configured privacy decision before its lane runs. + pub async fn execute_with_privacy_observer( + &self, + request: Request, + observer: Option, + on_privacy_decision: impl FnOnce(&PrivacyDecision) + Send, + ) -> Result { + let (lane, privacy_decision) = self.select_lane(&request).await?; + if let Some(decision) = &privacy_decision { + on_privacy_decision(decision); + } let (selected_model, response) = switchyard_llm_client::run( Arc::clone(&lane.algorithm), lane.clients.clone(), @@ -362,7 +376,12 @@ mod tests { use super::*; use crate::privacy::{SemanticPrivacyClassifier, mark_privacy_restricted}; - struct CallThenRoute; + enum RoutingCall { + Llm, + Decision, + } + + struct CallThenRoute(RoutingCall); #[async_trait::async_trait] impl Algorithm for CallThenRoute { @@ -375,12 +394,29 @@ mod tests { driver: Driver, request: Request, ) -> libsy::Result { - driver - .call_model( - request.clone(), - driver.models_for(&Category::Judge).to_vec(), - ) - .await?; + match &self.0 { + RoutingCall::Llm => { + driver + .call_model( + request.clone(), + driver.models_for(&Category::Judge).to_vec(), + ) + .await?; + } + RoutingCall::Decision => { + let judge = driver.first_model_for(&Category::Judge)?.clone(); + driver + .call_decision( + DecisionRequest { + model: None, + context: Value::Null, + questions: BTreeMap::new(), + }, + judge, + ) + .await?; + } + } let (selected, fallbacks) = driver .models_for(&Category::Any) .split_first() @@ -496,6 +532,16 @@ mod tests { request } + fn decision_target(target: &str, model: &ModelId) -> DecisionTarget { + DecisionTarget { + target: target.to_string(), + model: model.clone(), + format: WireFormat::OpenAiChat, + base_url: format!("https://{target}.example/v1"), + extra_body: BTreeMap::new(), + } + } + #[tokio::test] async fn privacy_restricted_lane_covers_judge_fallbacks_and_auxiliary_calls() { let calls = Arc::new(Mutex::new(Vec::new())); @@ -512,7 +558,7 @@ mod tests { "standard-auxiliary", ); let restricted = lane( - Arc::new(CallThenRoute), + Arc::new(CallThenRoute(RoutingCall::Llm)), client, RuntimeModels::new(HashMap::from([ (Category::Judge, vec!["restricted-judge".into()]), @@ -526,10 +572,17 @@ mod tests { let route = Route::from_lane(standard, None, ModelCapabilities::default()) .with_privacy(PrivacyPolicy::new(true, None), restricted); + let mut observed = None; let output = route - .execute(restricted_request(), None) + .execute_with_privacy_observer(restricted_request(), None, |decision| { + observed = Some((decision.lane(), decision.source(), decision.reason_code())); + }) .await .expect("restricted fallback should answer"); + assert_eq!( + observed, + Some(("restricted", "external_signal", "restricted")) + ); assert_eq!(output.selected_model, "restricted-primary"); assert_eq!( output.response.served_model().map(ModelId::as_str), @@ -556,6 +609,64 @@ mod tests { "{error}" ); } + + #[tokio::test] + async fn restricted_typed_decision_resolves_restricted_target_metadata() { + let answer: ModelId = "shared-model".into(); + let llm: Arc = Arc::new(RecordingClient { + calls: Arc::new(Mutex::new(Vec::new())), + }); + let mut standard = lane( + Arc::new(Passthrough), + Arc::clone(&llm), + RuntimeModels::new(HashMap::from([(Category::Any, vec![answer.clone()])])), + "standard-auxiliary", + ); + standard.decision_targets = vec![decision_target("standard", &answer)]; + + let judge: ModelId = "restricted-decision".into(); + let decision_calls = Arc::new(AtomicUsize::new(0)); + let restricted = ExecutionLane::new( + Arc::new(CallThenRoute(RoutingCall::Decision)), + ClientRouter::single_with_decision_clients( + llm, + HashMap::from([( + judge.clone(), + Arc::new(VerdictClient { + calls: Arc::clone(&decision_calls), + }) as Arc, + )]), + ), + None, + None, + vec![decision_target("restricted", &answer)], + RuntimeModels::new(HashMap::from([ + (Category::Judge, vec![judge]), + (Category::Any, vec![answer]), + ])), + ); + let route = Route::from_lane(standard, None, ModelCapabilities::default()) + .with_privacy(PrivacyPolicy::new(true, None), restricted); + let route_id: ModelId = "switchyard/private".into(); + let runner = crate::Runner::new(vec![(route_id.clone(), route)]); + + let outcome = runner + .route(route_id.as_str()) + .expect("route") + .decide(restricted_request()) + .await + .expect("restricted decision"); + assert_eq!(decision_calls.load(Ordering::Relaxed), 1); + let description = runner + .describe_decision(&route_id, &outcome) + .expect("restricted target metadata"); + assert_eq!(description.selected.target, "restricted"); + assert_eq!( + description.selected.base_url, + "https://restricted.example/v1" + ); + } + #[tokio::test] async fn external_and_input_restrictions_skip_semantic_assessment() { let calls = Arc::new(Mutex::new(Vec::new())); diff --git a/docs/internal/privacy-routing-spike.md b/docs/internal/privacy-routing-spike.md new file mode 100644 index 000000000..8d515c5e0 --- /dev/null +++ b/docs/internal/privacy-routing-spike.md @@ -0,0 +1,199 @@ +# Privacy routing spike + +This spike lets an application restrict which configured targets Switchyard may call. It does +not add a routing algorithm. The target flag and configuration below are proposed, not released. + +`private = true` means the deployment owner approves that target for restricted requests. +It does not detect sensitive content, verify the endpoint, or prevent a provider from retaining +data. Targets default to `private = false`. + +## Target approval and an external signal + +Declare the targets normally and flag the approved ones. The route lists its usual candidates; +there is no replacement-target map. Client definitions are omitted here. + +```toml +[targets.fast] +id = "public-model" +llm_client = "public_endpoint" + +[targets.internal] +id = "internal-model" +llm_client = "internal_endpoint" +private = true + +[routes.coding] +id = "switchyard/coding" +type = "random" +targets = ["fast", "internal"] +weights = [3, 1] + +[routes.coding.privacy] +accept_external_signal = true +``` + +The embedded application decides whether this request needs private targets: + +```rust +use switchyard_runner::mark_privacy_restricted; + +if requires_restricted_targets { + mark_privacy_restricted(&mut request); +} +let output = route.execute(request, None).await?; +``` + +Unmarked requests use both configured candidates. Marked requests can use only `internal`, +including after a failed attempt. Random keeps the weights paired with their targets when +filtering. No detector or classifier runs in this example. + +The signal is trusted, in-process Rust state, not an HTTP header or a Python binding. A marked +request fails if the route has not enabled external signals. An unmarked request can still be +restricted by an optional semantic check. + +```mermaid +flowchart TD + R[Request] --> T{Task already restricted?} + T -->|Yes| P[Configured targets flagged private] + T -->|No| C[External signal and optional semantic assessment] + C -->|Clear| S[All configured targets] + C -->|Sensitive or indeterminate| P + S --> A[Configured routing algorithm] + P --> B[Separate instance of the same algorithm] + A --> SM[Standard judges, answers, fallbacks and auxiliaries] + B --> PM[Private judges, answers, fallbacks and auxiliaries] +``` + +Filtering never imports another deployment's targets or silently changes a target's role. +Configurations fail to load if the remaining targets cannot support the chosen router. + +Stage, Escalation and other fixed-role routers still need their required efficient, capable +and judge targets. A Stage route with a public efficient target and a private capable target +therefore cannot enable this policy: filtering would remove a required role. This spike does +not silently turn Stage into passthrough. Custom classifier pools can contain public and private +targets; their required groups must remain usable after filtering. + +## Always-private workload + +Use an ordinary route containing only approved targets. It does not need classification or a +second lane. Flag both targets `private = true`; Stage still makes its normal tier choice. + +```toml +[targets.internal_fast] +id = "internal-small-model" +llm_client = "internal_endpoint" +private = true + +[targets.internal_capable] +id = "internal-large-model" +llm_client = "internal_endpoint" +private = true + +[routes.private_coding] +id = "switchyard/private-coding" +type = "stage_router" +efficient_target = "internal_fast" +capable_target = "internal_capable" +picker = "efficient_first" +confidence_threshold = 0.60 +``` + +## Optional semantic assessment + +Add one classifier to the mixed route. Generic LLM and typed decision models are alternatives, +not consecutive judges. Both require a target flagged `private = true`: the classifier receives +the content before deciding which targets may answer. + +```toml +[targets.privacy_classifier] +id = "internal-classifier" +llm_client = "internal_endpoint" +private = true + +[routes.coding.privacy.classifier] +type = "llm" +target = "privacy_classifier" +clear_threshold = 0.90 +response_format_type = "json_schema" +# prompt = "Your policy instructions." +``` + +```toml +[decision_targets.privacy_decision] +id = "internal-decision-model" +decision_client = "internal_decisions" +private = true + +[routes.coding.privacy.classifier] +type = "decision" +target = "privacy_decision" +clear_threshold = 0.90 +# instructions = { policy = "Your policy instructions." } +``` + +The generic path supports `json_schema` and `json_object`, with typed local verdict validation. +Both include the schema in the prompt. Structural validation restricts opaque or oversized +content before classification; it does not detect PII. The input borrows content-bearing request +fields, including instructions, tools, extensions and preserved request bodies; it excludes transport +metadata and cached responses. Remote MCP tool definitions are not assessed, so their transport +credentials are not forwarded to the classifier. The serialized input is bounded to 64 KiB. Invalid output, +oversized input, uncertainty, low clear scores and failed calls restrict the current request. +`clear_score` is a model score, not a calibrated guarantee of safety. + +The semantic adapter reuses libsy's private judge contract, request builder, decoder and +`Driver`. Built-in pattern detectors are not included. Ownership of privacy categories and +classifier prompts remains a team decision. + +## Task retention + +Set `restriction_scope = "task"` in the existing privacy table and supply +`request.metadata.task_id`. Only explicit external restriction or a valid sensitive semantic +verdict becomes sticky. A failure, invalid verdict, uncertainty or low clear score is reassessed +next turn. + +Retention is process-local and limited to 4,096 restricted task IDs. There is no expiry, reset +API or replica coordination. Missing/invalid task IDs and exhausted capacity restrict requests. + +## Responsibilities and API impact + +| Component | Responsibility | +|---|---| +| Embedded application | Supply an external restriction and trusted task identity, if needed. | +| Libsy | Check allowed model IDs; build and validate optional semantic judge calls. | +| Runner | Filter configured targets, choose clients and auxiliaries, retain task restrictions. | +| LLM client | Execute provider requests, retries and candidate fallbacks. | + +Direct Rust hosts can use `RuntimeModels::with_target_restriction()` and a matching +`ClientRouter`. The guard covers `Driver`, the default `run_stream`, and `drive`, including +rewritten steps from a custom stream. A custom stream consumer must enforce its own boundary. +Model IDs do not distinguish deployments exposing the same model: the host must select the +correct clients. This does not sandbox arbitrary Rust code or network calls made by the host. + +The additive prototype APIs are `PrivacyPreflight`, `PrivacyAssessment`, and the restriction +builder. Existing public signatures and the native plugin ABI are unchanged. Runner adds direct +dependencies on existing workspace packages; no new package versions enter the lockfile. +Generic judge helpers remain private. + +Target flags are internal configuration fields, not additions to public Rust types. There is +no `restricted_targets` or `restricted_decision_target` table. A typed routing judge and its +named candidates must be approved just like other callable targets. Aliases for the same +model on the same client cannot disagree on the flag. + +Mixed routes currently reject forwarded caller auth, prefill routing and OpenAI Responses +continuation state. Direct embedded hosts must not reuse a broad client router's stored +continuation across privacy lanes. A private target's provider policy remains the deployment +owner's responsibility. + +## Validation + +Focused coverage checks target filtering, positional weights, required roles, classifier +approval, fallback enforcement and privacy marks on failed requests. Existing tests cover +bounded context, malformed verdicts, thresholds, task retention and cancellation. Synthetic +verdicts validate wiring, not classifier accuracy; no live provider benchmark was rerun for +this configuration change. + +Classifier calls now use the standard libsy call spans and metrics. The INFO context span keeps +route attribution; Relay privacy decision marks are unchanged. Buffered response IDs/models +remain available. Abandoned calls use the existing `error` outcome rather than a separate +`cancelled` label. Custom prompts follow the shared contract, which rejects +`{{RESPONSE_SCHEMA}}` placeholders because Switchyard supplies the schema.