qualia_client_core/job_router.rs
1//! Job routing / placement policy — decide **where** a curated job is processed.
2//!
3//! Given a curated job, this module answers one narrow question: should the work run on
4//! the person's **local, in-process inference engine**, or be sent to an **external
5//! provider reached over MCP**? It is *transport / placement policy only*. It performs no
6//! I/O, no async work, and no cryptography — it is a pure decision function over a small
7//! set of inputs so it is trivially testable and auditable.
8//!
9//! # Privacy-first, fail-closed ordering
10//!
11//! The directive being implemented (Timothy): *local inference is PREFERRED when the person
12//! can run it; costly external MCP services are used only when wanted/needed and consented;
13//! sanctuary/private data must never leave the device.*
14//!
15//! The rules are therefore evaluated in a deliberate order so that the most protective
16//! outcome always wins:
17//!
18//! 1. **Classified / sanctuary data → local only.** Such data must never leave the device,
19//! regardless of consent or policy. If no local engine is available the job is
20//! [`RoutingDecision::Blocked`] rather than sent out.
21//! 2. **Policy forbids external → stay local.** If the person's [`RoutingPolicy`] disables
22//! external providers, the job runs locally (or is blocked if no local engine exists).
23//! 3. **Capability gap → external needed.** If the job needs a capability the local engine
24//! lacks, an external provider is required — but only *with explicit consent*. Otherwise
25//! the caller is told consent is needed ([`RoutingDecision::NeedsConsent`]).
26//! 4. **No local engine → external fallback.** For non-classified data, if no local engine
27//! is available the job goes external *with explicit consent*, else consent is requested.
28//! 5. **Cost ceiling.** A remote path that would exceed the policy's cost ceiling requires
29//! consent for the spend. Cost is only meaningful on a remote path — a [`RoutingDecision::Local`]
30//! decision has no metered cost, so the ceiling is never applied to it.
31//! 6. **Default → local.** With a local engine available and nothing forcing a remote hop,
32//! the job runs locally.
33//!
34//! # Relationship to the authority check
35//!
36//! This is **placement policy, not an authority check.** Whether the requesting agent is
37//! *permitted* to run the job at all is decided separately and complementarily by
38//! [`qualia_cooperative_core::agency_delegation::delegation_permits`] (a fail-closed ABAC
39//! evaluator). The caller runs both: `delegation_permits` answers *"is this allowed?"*, and
40//! [`route_job`] answers *"where should it run?"*. This module deliberately does **not**
41//! reimplement or second-guess that authority decision.
42
43use serde::{Deserialize, Serialize};
44use wellfare_core::record::SensitivityClass;
45
46/// A modest default per-job cost ceiling: **10 US cents** expressed in microcents
47/// (1 cent = 1_000_000 microcents, so 10 cents = `10_000_000`).
48///
49/// Above this, a remote job asks the person to confirm the spend rather than silently
50/// incurring the cost. It is only ever compared against a *remote* path — local inference
51/// has no metered cost.
52pub const DEFAULT_COST_CEILING_MICROCENTS: u64 = 10_000_000;
53
54/// The inputs to a routing decision for a single curated job.
55///
56/// All fields are supplied by the caller from the job's curation metadata and the current
57/// device/consent state; this module treats them as ground truth and does not fetch them.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct RoutingInputs {
60 /// Sensitivity of the data the job will touch. [`SensitivityClass::Classified`] is
61 /// sanctuary-grade and is *never* routed off-device.
62 pub sensitivity: SensitivityClass,
63 /// Whether a local, in-process inference engine is available to run the job.
64 pub local_available: bool,
65 /// Whether the person has explicitly consented to using an external MCP provider for
66 /// this job. Consent is a precondition for every remote path (never assumed).
67 pub external_consented: bool,
68 /// A capability the job requires (e.g. a specific model or tool), if any. When present
69 /// and not satisfied locally, the job needs an external provider.
70 pub requires_capability: Option<String>,
71 /// Whether the local engine can satisfy [`RoutingInputs::requires_capability`]. Ignored
72 /// when no capability is required.
73 pub local_has_capability: bool,
74 /// The estimated cost of the *remote* execution, in microcents (1 cent = 1_000_000
75 /// microcents). Only compared against the ceiling when a remote path is chosen.
76 pub estimated_cost_microcents: u64,
77}
78
79/// The person's placement policy — the guard rails [`route_job`] evaluates against.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct RoutingPolicy {
82 /// Whether external MCP providers may be used at all. When `false`, every job stays
83 /// local (or is blocked if no local engine exists), regardless of consent.
84 pub allow_external: bool,
85 /// The maximum estimated remote cost (in microcents) that may be incurred without an
86 /// extra consent step. A remote path above this becomes [`RoutingDecision::NeedsConsent`].
87 pub cost_ceiling_microcents: u64,
88}
89
90impl Default for RoutingPolicy {
91 /// A sensible default: external providers are permitted, with a modest
92 /// [`DEFAULT_COST_CEILING_MICROCENTS`] (10 cents) per-job ceiling above which the person
93 /// is asked to confirm the spend.
94 fn default() -> Self {
95 Self {
96 allow_external: true,
97 cost_ceiling_microcents: DEFAULT_COST_CEILING_MICROCENTS,
98 }
99 }
100}
101
102/// The placement decision for a curated job.
103///
104/// Every non-`Local` variant carries a clear, human-readable `reason` so the caller can
105/// surface *why* a job was blocked, deferred for consent, or sent out.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub enum RoutingDecision {
108 /// Run on the person's local, in-process inference engine. No outbound traffic, no cost.
109 Local,
110 /// Route to an external provider over MCP. Only reached when external use is permitted
111 /// by policy, consented by the person, and within the cost ceiling.
112 RemoteMcp { reason: String },
113 /// A remote path is needed or wanted, but the person's explicit consent (to use an
114 /// external provider, or to authorise a spend above the ceiling) has not yet been given.
115 NeedsConsent { reason: String },
116 /// The job cannot be routed anywhere: classified data with no local engine, or external
117 /// providers disabled by policy with no local engine.
118 Blocked { reason: String },
119}
120
121/// Decide where a curated job should run.
122///
123/// A pure function of ([`RoutingInputs`], [`RoutingPolicy`]) implementing the privacy-first,
124/// fail-closed ordering documented at the module level. See the module docs for the full
125/// rule list and its rationale.
126pub fn route_job(inputs: &RoutingInputs, policy: &RoutingPolicy) -> RoutingDecision {
127 // Rule 1 — Classified / sanctuary data: LOCAL ONLY. Never routed off-device, regardless
128 // of consent or policy. This is the strongest, first-evaluated protection.
129 if inputs.sensitivity == SensitivityClass::Classified {
130 return if inputs.local_available {
131 RoutingDecision::Local
132 } else {
133 RoutingDecision::Blocked {
134 reason: "classified/sanctuary data cannot leave the device and no local engine is available"
135 .to_string(),
136 }
137 };
138 }
139
140 // Rule 2 — Policy forbids external providers: the job must stay local (or block). This
141 // outranks capability and availability: an explicit "no external" is honoured absolutely.
142 if !policy.allow_external {
143 return if inputs.local_available {
144 RoutingDecision::Local
145 } else {
146 RoutingDecision::Blocked {
147 reason:
148 "external providers are disabled by policy and no local engine is available"
149 .to_string(),
150 }
151 };
152 }
153
154 // From here on: sensitivity is Public or Restricted, and policy allows external use.
155 // Restricted data still reaches an external provider ONLY via an explicit-consent branch
156 // below (rules 3 and 4); it can never go out unconsented.
157
158 // Rule 3 — Capability gap: the job needs a capability the local engine cannot provide,
159 // so an external provider is required. Consent gates the hop.
160 if let Some(cap) = inputs.requires_capability.as_deref() {
161 if !inputs.local_has_capability {
162 if inputs.external_consented {
163 return remote_unless_over_ceiling(
164 inputs,
165 policy,
166 format!(
167 "local engine lacks the required capability '{cap}'; routing to an external MCP provider"
168 ),
169 );
170 }
171 return RoutingDecision::NeedsConsent {
172 reason: format!(
173 "external capability '{cap}' required — needs consent to use an external provider"
174 ),
175 };
176 }
177 }
178
179 // Rule 4 — No local engine available (Public/Restricted): fall back to an external
180 // provider, again gated by explicit consent.
181 if !inputs.local_available {
182 if inputs.external_consented {
183 return remote_unless_over_ceiling(
184 inputs,
185 policy,
186 "no local inference engine is available; routing to an external MCP provider"
187 .to_string(),
188 );
189 }
190 return RoutingDecision::NeedsConsent {
191 reason: "no local engine available — needs consent to use external provider"
192 .to_string(),
193 };
194 }
195
196 // Rule 6 — Default: a local engine is available and nothing forces a remote hop, so run
197 // locally. (Rule 5, the cost ceiling, is applied inside `remote_unless_over_ceiling` on
198 // the remote paths above; a local decision has no metered cost.)
199 RoutingDecision::Local
200}
201
202/// Rule 5 helper: on an otherwise-chosen remote path, downgrade to [`RoutingDecision::NeedsConsent`]
203/// when the estimated cost exceeds the policy ceiling; otherwise commit to [`RoutingDecision::RemoteMcp`].
204///
205/// Kept private: the cost ceiling is only ever meaningful once a remote path has been
206/// selected, so it is never applied to a local decision.
207fn remote_unless_over_ceiling(
208 inputs: &RoutingInputs,
209 policy: &RoutingPolicy,
210 remote_reason: String,
211) -> RoutingDecision {
212 if inputs.estimated_cost_microcents > policy.cost_ceiling_microcents {
213 RoutingDecision::NeedsConsent {
214 reason: format!(
215 "estimated cost {} microcents exceeds the policy ceiling of {} microcents — needs consent to authorise the spend",
216 inputs.estimated_cost_microcents, policy.cost_ceiling_microcents
217 ),
218 }
219 } else {
220 RoutingDecision::RemoteMcp {
221 reason: remote_reason,
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 /// Coarse discriminant of a decision, used so the case table can assert the outcome
231 /// *shape* without pinning every exact reason string.
232 #[derive(Debug, PartialEq, Eq)]
233 enum Kind {
234 Local,
235 Remote,
236 NeedsConsent,
237 Blocked,
238 }
239
240 fn kind_of(d: &RoutingDecision) -> Kind {
241 match d {
242 RoutingDecision::Local => Kind::Local,
243 RoutingDecision::RemoteMcp { .. } => Kind::Remote,
244 RoutingDecision::NeedsConsent { .. } => Kind::NeedsConsent,
245 RoutingDecision::Blocked { .. } => Kind::Blocked,
246 }
247 }
248
249 fn reason_of(d: &RoutingDecision) -> Option<&str> {
250 match d {
251 RoutingDecision::Local => None,
252 RoutingDecision::RemoteMcp { reason }
253 | RoutingDecision::NeedsConsent { reason }
254 | RoutingDecision::Blocked { reason } => Some(reason.as_str()),
255 }
256 }
257
258 /// A permissive baseline job: Public data, local engine present, nothing else set.
259 /// Cases override only the fields they care about via `..base()`.
260 fn base() -> RoutingInputs {
261 RoutingInputs {
262 sensitivity: SensitivityClass::Public,
263 local_available: true,
264 external_consented: false,
265 requires_capability: None,
266 local_has_capability: false,
267 estimated_cost_microcents: 0,
268 }
269 }
270
271 fn no_external_policy() -> RoutingPolicy {
272 RoutingPolicy {
273 allow_external: false,
274 ..RoutingPolicy::default()
275 }
276 }
277
278 struct Case {
279 name: &'static str,
280 inputs: RoutingInputs,
281 policy: RoutingPolicy,
282 expect: Kind,
283 /// If set, the decision's reason must contain this substring.
284 reason_contains: Option<&'static str>,
285 }
286
287 #[test]
288 fn route_job_table() {
289 let over_ceiling = DEFAULT_COST_CEILING_MICROCENTS + 1;
290
291 let cases = vec![
292 // --- Rule 1: Classified is local-only, and never external. ---
293 Case {
294 name: "classified_with_local_runs_local",
295 inputs: RoutingInputs {
296 sensitivity: SensitivityClass::Classified,
297 ..base()
298 },
299 policy: RoutingPolicy::default(),
300 expect: Kind::Local,
301 reason_contains: None,
302 },
303 Case {
304 name: "classified_without_local_is_blocked",
305 inputs: RoutingInputs {
306 sensitivity: SensitivityClass::Classified,
307 local_available: false,
308 ..base()
309 },
310 policy: RoutingPolicy::default(),
311 expect: Kind::Blocked,
312 reason_contains: Some("classified"),
313 },
314 Case {
315 // Consent must NOT let classified data leave the device.
316 name: "classified_never_external_even_with_consent",
317 inputs: RoutingInputs {
318 sensitivity: SensitivityClass::Classified,
319 local_available: false,
320 external_consented: true,
321 requires_capability: Some("vision".into()),
322 estimated_cost_microcents: 5,
323 ..base()
324 },
325 policy: RoutingPolicy::default(),
326 expect: Kind::Blocked,
327 reason_contains: Some("cannot leave the device"),
328 },
329 Case {
330 // Classified + local available: stays local; cost is irrelevant on local.
331 name: "classified_local_ignores_cost",
332 inputs: RoutingInputs {
333 sensitivity: SensitivityClass::Classified,
334 estimated_cost_microcents: over_ceiling,
335 external_consented: true,
336 ..base()
337 },
338 policy: RoutingPolicy::default(),
339 expect: Kind::Local,
340 reason_contains: None,
341 },
342 // --- Rule 2: policy disables external. ---
343 Case {
344 name: "no_external_policy_runs_local",
345 inputs: base(),
346 policy: no_external_policy(),
347 expect: Kind::Local,
348 reason_contains: None,
349 },
350 Case {
351 // No external allowed AND no local engine → blocked, even with consent.
352 name: "no_external_policy_without_local_is_blocked",
353 inputs: RoutingInputs {
354 local_available: false,
355 external_consented: true,
356 ..base()
357 },
358 policy: no_external_policy(),
359 expect: Kind::Blocked,
360 reason_contains: Some("policy"),
361 },
362 Case {
363 // Policy "no external" outranks a capability gap (rule 2 before rule 3).
364 name: "no_external_policy_overrides_capability_gap",
365 inputs: RoutingInputs {
366 requires_capability: Some("ocr".into()),
367 local_has_capability: false,
368 external_consented: true,
369 ..base()
370 },
371 policy: no_external_policy(),
372 expect: Kind::Local,
373 reason_contains: None,
374 },
375 // --- Rule 3: capability gap. ---
376 Case {
377 name: "capability_gap_with_consent_goes_remote",
378 inputs: RoutingInputs {
379 requires_capability: Some("vision".into()),
380 local_has_capability: false,
381 external_consented: true,
382 ..base()
383 },
384 policy: RoutingPolicy::default(),
385 expect: Kind::Remote,
386 reason_contains: Some("capability"),
387 },
388 Case {
389 name: "capability_gap_without_consent_needs_consent",
390 inputs: RoutingInputs {
391 requires_capability: Some("vision".into()),
392 local_has_capability: false,
393 external_consented: false,
394 ..base()
395 },
396 policy: RoutingPolicy::default(),
397 expect: Kind::NeedsConsent,
398 reason_contains: Some("capability"),
399 },
400 Case {
401 // Restricted data + capability gap: only reaches external WITH consent.
402 name: "restricted_capability_gap_goes_external_only_with_consent",
403 inputs: RoutingInputs {
404 sensitivity: SensitivityClass::Restricted,
405 requires_capability: Some("vision".into()),
406 local_has_capability: false,
407 external_consented: true,
408 ..base()
409 },
410 policy: RoutingPolicy::default(),
411 expect: Kind::Remote,
412 reason_contains: Some("capability"),
413 },
414 Case {
415 name: "restricted_capability_gap_without_consent_needs_consent",
416 inputs: RoutingInputs {
417 sensitivity: SensitivityClass::Restricted,
418 requires_capability: Some("vision".into()),
419 local_has_capability: false,
420 external_consented: false,
421 ..base()
422 },
423 policy: RoutingPolicy::default(),
424 expect: Kind::NeedsConsent,
425 reason_contains: Some("consent"),
426 },
427 Case {
428 // Capability required but locally satisfied → no gap → local.
429 name: "capability_satisfied_locally_runs_local",
430 inputs: RoutingInputs {
431 requires_capability: Some("summarise".into()),
432 local_has_capability: true,
433 ..base()
434 },
435 policy: RoutingPolicy::default(),
436 expect: Kind::Local,
437 reason_contains: None,
438 },
439 // --- Rule 4: no local engine (non-classified). ---
440 Case {
441 name: "no_local_with_consent_goes_remote",
442 inputs: RoutingInputs {
443 local_available: false,
444 external_consented: true,
445 ..base()
446 },
447 policy: RoutingPolicy::default(),
448 expect: Kind::Remote,
449 reason_contains: Some("MCP"),
450 },
451 Case {
452 name: "no_local_without_consent_needs_consent",
453 inputs: RoutingInputs {
454 local_available: false,
455 external_consented: false,
456 ..base()
457 },
458 policy: RoutingPolicy::default(),
459 expect: Kind::NeedsConsent,
460 reason_contains: Some("no local engine available"),
461 },
462 // --- Rule 5: cost ceiling on remote paths. ---
463 Case {
464 name: "no_local_over_cost_needs_consent",
465 inputs: RoutingInputs {
466 local_available: false,
467 external_consented: true,
468 estimated_cost_microcents: over_ceiling,
469 ..base()
470 },
471 policy: RoutingPolicy::default(),
472 expect: Kind::NeedsConsent,
473 reason_contains: Some("ceiling"),
474 },
475 Case {
476 name: "capability_gap_over_cost_needs_consent",
477 inputs: RoutingInputs {
478 requires_capability: Some("vision".into()),
479 local_has_capability: false,
480 external_consented: true,
481 estimated_cost_microcents: over_ceiling,
482 ..base()
483 },
484 policy: RoutingPolicy::default(),
485 expect: Kind::NeedsConsent,
486 reason_contains: Some("cost"),
487 },
488 Case {
489 // Cost equal to the ceiling is allowed (strictly-greater triggers consent).
490 name: "cost_equal_to_ceiling_is_allowed_remote",
491 inputs: RoutingInputs {
492 local_available: false,
493 external_consented: true,
494 estimated_cost_microcents: DEFAULT_COST_CEILING_MICROCENTS,
495 ..base()
496 },
497 policy: RoutingPolicy::default(),
498 expect: Kind::Remote,
499 reason_contains: None,
500 },
501 Case {
502 // Cost never blocks a LOCAL decision: huge cost + local available → local.
503 name: "local_path_ignores_cost_ceiling",
504 inputs: RoutingInputs {
505 sensitivity: SensitivityClass::Restricted,
506 estimated_cost_microcents: over_ceiling,
507 ..base()
508 },
509 policy: RoutingPolicy::default(),
510 expect: Kind::Local,
511 reason_contains: None,
512 },
513 // --- Rule 6: default. ---
514 Case {
515 name: "default_public_local_runs_local",
516 inputs: base(),
517 policy: RoutingPolicy::default(),
518 expect: Kind::Local,
519 reason_contains: None,
520 },
521 Case {
522 // Restricted data prefers local and does not go external unbidden.
523 name: "restricted_prefers_local",
524 inputs: RoutingInputs {
525 sensitivity: SensitivityClass::Restricted,
526 ..base()
527 },
528 policy: RoutingPolicy::default(),
529 expect: Kind::Local,
530 reason_contains: None,
531 },
532 ];
533
534 for c in &cases {
535 let decision = route_job(&c.inputs, &c.policy);
536 assert_eq!(
537 kind_of(&decision),
538 c.expect,
539 "case '{}' expected {:?} but got {:?}",
540 c.name,
541 c.expect,
542 decision
543 );
544 if let Some(needle) = c.reason_contains {
545 let reason = reason_of(&decision).unwrap_or("");
546 assert!(
547 reason.contains(needle),
548 "case '{}': reason {:?} did not contain {:?}",
549 c.name,
550 reason,
551 needle
552 );
553 }
554 }
555 }
556
557 #[test]
558 fn default_policy_is_sensible() {
559 let p = RoutingPolicy::default();
560 assert!(
561 p.allow_external,
562 "default policy should permit external use"
563 );
564 assert_eq!(p.cost_ceiling_microcents, DEFAULT_COST_CEILING_MICROCENTS);
565 }
566
567 #[test]
568 fn public_types_round_trip_through_serde() {
569 let inputs = RoutingInputs {
570 sensitivity: SensitivityClass::Restricted,
571 local_available: false,
572 external_consented: true,
573 requires_capability: Some("vision".into()),
574 local_has_capability: false,
575 estimated_cost_microcents: 42,
576 };
577 let policy = RoutingPolicy::default();
578
579 let inputs_json = serde_json::to_string(&inputs).expect("serialize inputs");
580 let inputs_back: RoutingInputs =
581 serde_json::from_str(&inputs_json).expect("deserialize inputs");
582 assert_eq!(inputs, inputs_back);
583
584 let policy_json = serde_json::to_string(&policy).expect("serialize policy");
585 let policy_back: RoutingPolicy =
586 serde_json::from_str(&policy_json).expect("deserialize policy");
587 assert_eq!(policy, policy_back);
588
589 let decision = route_job(&inputs, &policy);
590 let decision_json = serde_json::to_string(&decision).expect("serialize decision");
591 let decision_back: RoutingDecision =
592 serde_json::from_str(&decision_json).expect("deserialize decision");
593 assert_eq!(decision, decision_back);
594 }
595}