Skip to main content

rustev_live/
probe.rs

1//! The probe of every binding before any dataset case (spec 043, 3.7).
2//!
3//! Static checks first, with no dispatch: each reached binding's first
4//! candidate plan prepares against the runtime, the registered descriptor
5//! names exactly the binding's backend and artifact, and no binding's cost
6//! model is `unknown`. Then each binding receives `probe.calls` sequential
7//! live decisions of that plan on the probe snapshot, through the host's
8//! authorizer and the journal gate, like any live attempt.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::io::Read;
12use std::sync::Arc;
13
14use rustev_contract::ids::{ArtifactId, ContentDigest, SnapshotId};
15use rustev_contract::live::AuthorizationOutcome;
16use rustev_contract::plan::{PlanExecution, PlanStepDetail};
17use rustev_contract::remote::Disclosure;
18use rustev_contract::run::{AttemptEnd, CostModel, RunRecord};
19use rustev_contract::scope::{Handle, Scope};
20use rustev_contract::snapshot::Snapshot;
21use rustev_contract::time::Timestamp;
22use rustev_contract::{Document, Identified};
23use rustev_core::Compiled;
24use rustev_core::seams::CancelSignal;
25use rustev_runtime::{
26    Clock, LiveAuthorizer, LiveRefusal, LiveRequest, LiveResult, LiveSources, LiveSpend,
27    PreparedPlan, Runtime, SourceOutcome, SpendBinding, SpendUnit,
28};
29use serde::{Deserialize, Serialize};
30
31use crate::JournalGate;
32use crate::documents::{BindingIdentity, CostUnit, LiveConfig, LiveError, Probe, ProbeProvenance};
33
34/// The probe snapshot, read and identified anew (spec 043, 3.7.2). Loading
35/// it opens no journal, so every refusal here precedes any spend.
36#[derive(Debug, Clone)]
37pub struct ProbeSnapshot {
38    snapshot: Snapshot,
39    id: SnapshotId,
40    provenance: ProbeProvenance,
41}
42
43impl ProbeSnapshot {
44    /// Read `probe.snapshot` within the snapshot parse bound.
45    pub fn load(probe: &Probe, case_snapshots: &[SnapshotId]) -> Result<Self, LiveError> {
46        let mut bytes = Vec::new();
47        std::fs::File::open(&probe.snapshot)
48            .and_then(|f| {
49                f.take(Snapshot::LIMITS.max_bytes as u64 + 1)
50                    .read_to_end(&mut bytes)
51            })
52            .map_err(|e| LiveError {
53                status: "io_error",
54                subject: "probe-snapshot",
55                detail: e.to_string(),
56            })?;
57        Self::from_bytes(probe, &bytes, case_snapshots)
58    }
59
60    /// Refused `probe-snapshot` when the recomputed digest is not the
61    /// declared one or equals any case snapshot's.
62    pub fn from_bytes(
63        probe: &Probe,
64        bytes: &[u8],
65        case_snapshots: &[SnapshotId],
66    ) -> Result<Self, LiveError> {
67        let refused = |detail: &str| LiveError::invalid("probe-snapshot", detail);
68        let snapshot = Snapshot::parse(bytes).map_err(|e| refused(&e.to_string()))?;
69        let id = snapshot.id().map_err(|e| refused(&e.to_string()))?;
70        if id != probe.digest {
71            return Err(refused("the recomputed digest differs from probe.digest"));
72        }
73        if case_snapshots.contains(&id) {
74            return Err(refused("the probe snapshot is a case snapshot"));
75        }
76        Ok(Self {
77            snapshot,
78            id,
79            provenance: probe.provenance,
80        })
81    }
82
83    pub fn id(&self) -> &SnapshotId {
84        &self.id
85    }
86}
87
88/// Everything one probe needs besides the authorizer, gate and signal.
89pub struct ProbeInputs<'a> {
90    pub acquisition_pages: &'a [rustev_contract::acquisition::AcquisitionPage],
91    pub runtime: &'a Runtime,
92    /// The checked configuration (`LiveConfig::check`).
93    pub config: &'a LiveConfig,
94    /// Candidate plans by name; a binding is probed with the first plan, in
95    /// name order, that reaches it as a primary or fallback target.
96    pub candidates: &'a BTreeMap<String, Compiled>,
97    pub snapshot: &'a ProbeSnapshot,
98    /// The run's fixed evaluation time, or the host's current time.
99    pub evaluation_time: Timestamp,
100    pub principal_handle: Vec<u8>,
101    pub subject: Handle,
102    pub scope: Scope,
103    /// The runtime's clock; calls to one binding are spaced on it.
104    pub clock: &'a dyn Clock,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case", deny_unknown_fields)]
109pub enum ProbeStatus {
110    Probed,
111    ProbeFailed,
112    /// A reservation was refused; no later attempt of the run was sent.
113    SpendCapped,
114    /// The caller's signal was raised; no later call was made.
115    Interrupted,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case", deny_unknown_fields)]
120pub enum IdentityVerdict {
121    Matched,
122    Unverified,
123    Mismatched,
124}
125
126/// The first failing call (1-based), or `None` for a static check.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct ProbeFailure {
130    pub call: Option<u64>,
131    pub code: String,
132}
133
134/// One probe decision, whose purpose is `probe` (3.9).
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ProbeCall {
138    pub case_digest: Option<ContentDigest>,
139    pub decision_id: String,
140    /// Attempts to the probed binding and their served identities, as the
141    /// backend reported them (`None` is `unknown`).
142    pub attempts: u64,
143    pub served: Vec<Option<String>>,
144    pub code: Option<String>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct BindingProbe {
150    pub identity: BindingIdentity,
151    pub candidate: String,
152    pub calls: Vec<ProbeCall>,
153    pub reachable: bool,
154    /// `None` until a call returned output to this binding.
155    pub served_identity: Option<IdentityVerdict>,
156    pub failure: Option<ProbeFailure>,
157}
158
159/// The probe section of `rustev.live-run/1` (3.11.4).
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct ProbeReport {
163    pub snapshot_digest: SnapshotId,
164    pub provenance: ProbeProvenance,
165    pub status: ProbeStatus,
166    pub bindings: Vec<BindingProbe>,
167    pub spend_refusal: Option<String>,
168}
169
170/// Probe decisions retain `.probe` before any resume sequence: run spend
171/// (3.5.3) counts them, and no candidate name starts with `.` (3.3.4).
172const PURPOSE: &str = ".probe";
173const MISMATCH: &str = "served-identity-mismatch";
174
175/// Choose the next resume sequence from every reservation, including released
176/// and crash-liability attempts. All calls in this probe share the sequence.
177fn decision_prefix(gate: &JournalGate, resume: bool) -> Result<String, LiveError> {
178    let prefix = format!("live/{}/{PURPOSE}/", gate.run_id());
179    if !resume {
180        return Ok(prefix);
181    }
182    let largest = gate
183        .journal()
184        .attempts_with_prefix(&prefix)
185        .iter()
186        .filter_map(|attempt| {
187            attempt
188                .strip_prefix(&prefix)?
189                .strip_prefix("resume-")?
190                .split_once('/')?
191                .0
192                .parse::<u64>()
193                .ok()
194        })
195        .max()
196        .unwrap_or(0);
197    let next = largest
198        .checked_add(1)
199        .ok_or_else(|| LiveError::invalid("probe-sequence", "probe resume sequence exhausted"))?;
200    Ok(format!("{prefix}resume-{next}/"))
201}
202
203/// The bindings a plan reaches: every primary and fallback target.
204pub fn reached(compiled: &Compiled) -> BTreeSet<BindingIdentity> {
205    let mut out: BTreeSet<_> = compiled
206        .plan
207        .steps
208        .iter()
209        .filter_map(|s| match &s.detail {
210            PlanStepDetail::Semantic(b) => Some((&b.backend_id, &b.artifact)),
211            PlanStepDetail::Generate(b) => Some((&b.backend_id, &b.artifact)),
212            PlanStepDetail::Extract(b) => Some((&b.backend_id, &b.artifact)),
213            _ => None,
214        })
215        .map(|(b, a)| identity(b, a))
216        .collect();
217    if let PlanExecution::Declared(d) = &compiled.plan.execution {
218        out.extend(
219            d.fallbacks
220                .iter()
221                .map(|f| identity(&f.backend_id, &f.artifact)),
222        );
223    }
224    out
225}
226
227fn identity(backend_id: &str, artifact: &ArtifactId) -> BindingIdentity {
228    BindingIdentity {
229        backend_id: backend_id.to_string(),
230        artifact: artifact.clone(),
231    }
232}
233
234/// Probe every configured binding (spec 043, 3.7). `Err` is a refusal
235/// before any dispatch: a reached binding with no entry (`live-config`) or
236/// a binding whose cost model is `unknown` (`spend-unit`).
237pub async fn probe(
238    inputs: ProbeInputs<'_>,
239    authorizer: &dyn LiveAuthorizer,
240    gate: &JournalGate,
241    cancel: &CancelSignal,
242) -> Result<ProbeReport, LiveError> {
243    probe_recorded(inputs, authorizer, gate, cancel, false, |_, _, _, _, _| {
244        Ok(None)
245    })
246    .await
247}
248
249pub(crate) async fn probe_recorded(
250    inputs: ProbeInputs<'_>,
251    authorizer: &dyn LiveAuthorizer,
252    gate: &JournalGate,
253    cancel: &CancelSignal,
254    resume: bool,
255    mut record: impl FnMut(
256        usize,
257        u64,
258        &str,
259        &LiveResult,
260        &SourceOutcome,
261    ) -> Result<Option<ContentDigest>, LiveError>,
262) -> Result<ProbeReport, LiveError> {
263    let ProbeInputs {
264        runtime: rt,
265        config,
266        candidates,
267        snapshot,
268        ..
269    } = inputs;
270    let reach: BTreeMap<&String, BTreeSet<BindingIdentity>> =
271        candidates.iter().map(|(n, c)| (n, reached(c))).collect();
272    let configured: BTreeSet<_> = config.bindings.iter().map(|b| &b.identity).collect();
273    if reach.values().flatten().collect::<BTreeSet<_>>() != configured {
274        return Err(LiveError::invalid(
275            "live-config",
276            "configured bindings differ from reachable bindings",
277        ));
278    }
279    for b in &config.bindings {
280        if rt
281            .backend_terms(&b.identity.backend_id)
282            .is_some_and(|(_, model)| model == CostModel::Unknown)
283        {
284            return Err(LiveError::invalid(
285                "spend-unit",
286                format!("{} discloses an unknown cost model", b.identity.backend_id),
287            ));
288        }
289    }
290
291    // Static checks: no dispatch, and any failure ends the probe before a call.
292    let mut prepared: BTreeMap<&String, Result<Arc<PreparedPlan>, ()>> = BTreeMap::new();
293    let mut probes = Vec::new();
294    for b in &config.bindings {
295        let name = reach
296            .iter()
297            .find(|(_, set)| set.contains(&b.identity))
298            .map(|(n, _)| *n)
299            .expect("every configured binding is reached");
300        let plan = prepared
301            .entry(name)
302            .or_insert_with(|| {
303                rt.prepare(candidates[name].clone())
304                    .map(Arc::new)
305                    .map_err(drop)
306            })
307            .clone();
308        let terms = rt.backend_terms(&b.identity.backend_id);
309        let failure = if terms.is_some_and(|(d, _)| {
310            d.backend_id != b.identity.backend_id || d.artifact != b.identity.artifact
311        }) {
312            Some("descriptor-identity")
313        } else if plan.is_err() {
314            Some("check-plan")
315        } else {
316            None
317        };
318        probes.push(BindingProbe {
319            identity: b.identity.clone(),
320            candidate: name.clone(),
321            calls: vec![],
322            reachable: false,
323            served_identity: None,
324            failure: failure.map(|code| ProbeFailure {
325                call: None,
326                code: code.into(),
327            }),
328        });
329    }
330    let mut report = ProbeReport {
331        snapshot_digest: snapshot.id.clone(),
332        provenance: snapshot.provenance,
333        status: ProbeStatus::ProbeFailed,
334        bindings: vec![],
335        spend_refusal: None,
336    };
337    if probes.iter().any(|p| p.failure.is_some()) {
338        report.bindings = probes;
339        return Ok(report);
340    }
341
342    check_acquisitions(config, candidates)?;
343    let acquisitions = acquisition_postures(config)?;
344    let spend_bindings = spend_bindings(config);
345    let decision_prefix = decision_prefix(gate, resume)?;
346    let mut stopped = None;
347    'bindings: for (index, (p, b)) in probes.iter_mut().zip(&config.bindings).enumerate() {
348        let Ok(plan) = prepared[&p.candidate].clone() else {
349            unreachable!("static checks passed")
350        };
351        let mut last_end = None;
352        for call in 1..=config.probe.calls {
353            if let Some(end) = last_end {
354                let mut sleep = inputs
355                    .clock
356                    .sleep_until(u64::saturating_add(end, config.probe.interval_ms));
357                std::future::poll_fn(|cx| {
358                    if cancel.poll_raised(cx).is_ready() {
359                        return std::task::Poll::Ready(());
360                    }
361                    sleep.as_mut().poll(cx)
362                })
363                .await;
364            }
365            if cancel.is_raised() {
366                stopped = Some(ProbeStatus::Interrupted);
367                break 'bindings;
368            }
369            let decision_id = format!("{decision_prefix}{}/{call}", index + 1);
370            let result = rt
371                .reexecute_live_with_sources(
372                    &plan,
373                    LiveRequest {
374                        decision_id: decision_id.clone(),
375                        snapshot: snapshot.snapshot.clone(),
376                        expected_snapshot_id: Some(snapshot.id.clone()),
377                        evaluation_time: inputs.evaluation_time,
378                        deadline_ms: Some(b.case_deadline_ms),
379                        principal_handle: inputs.principal_handle.clone(),
380                        subject: inputs.subject.clone(),
381                        scope: inputs.scope.clone(),
382                        capture: None,
383                        historical: None,
384                    },
385                    authorizer,
386                    cancel,
387                    LiveSpend {
388                        gate,
389                        bindings: &spend_bindings,
390                    },
391                    LiveSources {
392                        acquisitions: &acquisitions,
393                        pages: inputs.acquisition_pages,
394                    },
395                )
396                .await;
397            last_end = Some(inputs.clock.now());
398            let case_digest = result
399                .as_ref()
400                .ok()
401                .map(|(r, sources)| record(index + 1, call, &p.candidate, r, sources))
402                .transpose()?
403                .flatten();
404            let mut observed = observe(&p.identity, &decision_id, result);
405            observed.call.case_digest = case_digest;
406            if let Some(code) = &observed.spend_refusal {
407                report.spend_refusal = Some(code.clone());
408                stopped = Some(ProbeStatus::SpendCapped);
409            }
410            let failed = observed.call.code.clone();
411            p.calls.push(observed.call);
412            if let Some(code) = failed {
413                p.failure = Some(ProbeFailure {
414                    call: Some(call),
415                    code,
416                });
417            }
418            if stopped.is_none() && cancel.is_raised() {
419                stopped = Some(ProbeStatus::Interrupted);
420            }
421            if stopped.is_some() {
422                break 'bindings;
423            }
424            if p.failure.is_some() {
425                break;
426            }
427        }
428    }
429    for p in &mut probes {
430        // A mismatched identity is still a mapped output: reachable, failed.
431        let mapped = |c: &ProbeCall| c.code.as_deref().is_none_or(|c| c == MISMATCH);
432        p.reachable = p.calls.iter().all(mapped)
433            && (p.calls.len() as u64 == config.probe.calls
434                || p.failure.as_ref().is_some_and(|f| f.code == MISMATCH));
435        p.served_identity = verdict(p);
436    }
437    report.status = match stopped {
438        Some(s) => s,
439        None if probes.iter().all(|p| p.failure.is_none()) => ProbeStatus::Probed,
440        None => ProbeStatus::ProbeFailed,
441    };
442    report.bindings = probes;
443    Ok(report)
444}
445
446struct Observed {
447    call: ProbeCall,
448    spend_refusal: Option<String>,
449}
450
451/// One call's attempts to `binding`, with the first failing condition.
452fn observe(
453    binding: &BindingIdentity,
454    decision_id: &str,
455    result: Result<(LiveResult, SourceOutcome), LiveRefusal>,
456) -> Observed {
457    let mut call = ProbeCall {
458        case_digest: None,
459        decision_id: decision_id.to_string(),
460        attempts: 0,
461        served: vec![],
462        code: None,
463    };
464    let (r, sources) = match result {
465        Ok(r) => r,
466        Err(e) => {
467            call.code = Some(refusal_code(&e).into());
468            return Observed {
469                call,
470                spend_refusal: None,
471            };
472        }
473    };
474    let ours = |backend_id: &str, artifact: &ArtifactId| {
475        backend_id == binding.backend_id && *artifact == binding.artifact
476    };
477    let refused_authorization = r.document.effects.iter().any(|e| {
478        ours(&e.backend_id, &e.artifact)
479            && matches!(e.authorization, AuthorizationOutcome::Refused { .. })
480    });
481    let mut ends = vec![];
482    for a in r.record.requests.iter().flat_map(|q| &q.attempts) {
483        if ours(&a.backend_id, &a.artifact) {
484            ends.push(a.end.clone());
485            call.served.push(served(&r.record, &a.attempt_id));
486        }
487    }
488    call.attempts = ends.len() as u64;
489    let spend_refusal = r.spend_refusal.map(|s| s.code);
490    call.code = if let Some(code) = &spend_refusal {
491        Some(code.clone())
492    } else if let Some(reason) = sources.incomparable {
493        Some(reason)
494    } else if !sources.refusals.is_empty() {
495        Some(sources.refusals[0].1.clone())
496    } else if refused_authorization {
497        Some("authorization_refused".into())
498    } else if let Some(end) = ends.iter().find(|e| **e != AttemptEnd::Output) {
499        Some(match end {
500            AttemptEnd::Failed { class, .. } => class.name().into(),
501            AttemptEnd::Deadline => "deadline".into(),
502            AttemptEnd::Cancelled => "cancelled".into(),
503            AttemptEnd::Output => unreachable!(),
504        })
505    } else if ends.is_empty() {
506        Some("probe-unreached".into())
507    } else if call
508        .served
509        .iter()
510        .flatten()
511        .any(|s| s != binding.artifact.as_str())
512    {
513        Some(MISMATCH.into())
514    } else {
515        None
516    };
517    Observed {
518        call,
519        spend_refusal,
520    }
521}
522
523/// The served identity in the record's remote evidence; absent is `unknown`.
524fn served(record: &RunRecord, attempt_id: &str) -> Option<String> {
525    let e = record
526        .remote_evidence
527        .iter()
528        .flatten()
529        .find(|e| e.attempt_id == attempt_id)?;
530    match e.served.disclosure {
531        Disclosure::Unknown => None,
532        Disclosure::Pinned | Disclosure::Reported => e.served.identity.clone(),
533    }
534}
535
536/// 3.7.3, in order: `mismatched`, then `unverified`, then `matched`.
537fn verdict(p: &BindingProbe) -> Option<IdentityVerdict> {
538    let served: Vec<_> = p.calls.iter().flat_map(|c| &c.served).collect();
539    if served.is_empty() {
540        None
541    } else if served.iter().any(|s| {
542        s.as_deref()
543            .is_some_and(|s| s != p.identity.artifact.as_str())
544    }) {
545        Some(IdentityVerdict::Mismatched)
546    } else if served.iter().any(|s| s.is_none()) {
547        Some(IdentityVerdict::Unverified)
548    } else {
549        Some(IdentityVerdict::Matched)
550    }
551}
552
553pub(crate) fn refusal_code(e: &LiveRefusal) -> &'static str {
554    match e {
555        LiveRefusal::SpendAcquisitionUnsupported => "acquisition-undeclared",
556        LiveRefusal::Rejected(_) => "rejected",
557        LiveRefusal::GenerationFailed(_) => "generation-failed",
558        LiveRefusal::SnapshotIdentity { .. } | LiveRefusal::InvalidSnapshot(_) => {
559            "identity-mismatch"
560        }
561        _ => "live-refused",
562    }
563}
564
565pub(crate) fn spend_bindings(config: &LiveConfig) -> Vec<SpendBinding> {
566    config
567        .bindings
568        .iter()
569        .map(|b| SpendBinding {
570            backend_id: b.identity.backend_id.clone(),
571            artifact: b.identity.artifact.clone(),
572            cost_unit: match b.cost_unit {
573                CostUnit::NanoUsd => SpendUnit::NanoUsd,
574                CostUnit::None => SpendUnit::None,
575            },
576        })
577        .collect()
578}
579
580/// Configuration keys name the exact SourceId, not a mutable source alias.
581pub(crate) fn check_acquisitions(
582    config: &LiveConfig,
583    candidates: &BTreeMap<String, Compiled>,
584) -> Result<(), LiveError> {
585    let declared: BTreeSet<_> = candidates
586        .values()
587        .flat_map(|c| c.plan.acquisitions.iter().flatten())
588        .flat_map(|a| &a.sources)
589        .map(|s| s.source.id.to_string())
590        .collect();
591    if declared != config.acquisitions.keys().cloned().collect() {
592        return Err(LiveError::invalid(
593            "acquisition-undeclared",
594            "configured sources differ from candidate sources",
595        ));
596    }
597    Ok(())
598}
599
600pub(crate) fn acquisition_postures(
601    config: &LiveConfig,
602) -> Result<Vec<rustev_runtime::LiveAcquisition>, LiveError> {
603    config
604        .acquisitions
605        .iter()
606        .map(|(source, posture)| {
607            Ok(rustev_runtime::LiveAcquisition {
608                source: rustev_contract::ids::SourceId::parse(source)
609                    .map_err(|e| LiveError::invalid("acquisition-undeclared", e))?,
610                posture: match posture {
611                    crate::documents::AcquisitionMode::Live => {
612                        rustev_runtime::AcquisitionPosture::Live
613                    }
614                    crate::documents::AcquisitionMode::Captured => {
615                        rustev_runtime::AcquisitionPosture::Captured
616                    }
617                },
618            })
619        })
620        .collect()
621}