Skip to main content

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}