1use 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#[derive(Debug, Clone)]
37pub struct ProbeSnapshot {
38 snapshot: Snapshot,
39 id: SnapshotId,
40 provenance: ProbeProvenance,
41}
42
43impl ProbeSnapshot {
44 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 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
88pub struct ProbeInputs<'a> {
90 pub acquisition_pages: &'a [rustev_contract::acquisition::AcquisitionPage],
91 pub runtime: &'a Runtime,
92 pub config: &'a LiveConfig,
94 pub candidates: &'a BTreeMap<String, Compiled>,
97 pub snapshot: &'a ProbeSnapshot,
98 pub evaluation_time: Timestamp,
100 pub principal_handle: Vec<u8>,
101 pub subject: Handle,
102 pub scope: Scope,
103 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 SpendCapped,
114 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#[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#[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 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 pub served_identity: Option<IdentityVerdict>,
156 pub failure: Option<ProbeFailure>,
157}
158
159#[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
170const PURPOSE: &str = ".probe";
173const MISMATCH: &str = "served-identity-mismatch";
174
175fn 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
203pub 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
234pub 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 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 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
451fn 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
523fn 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
536fn 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
580pub(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}