Skip to main content

rustev_runtime/
dynamic.rs

1//! `ask_dynamic`, the only execution entry point for dynamic questions
2//! (spec 019, 3.1 and 3.5).
3//!
4//! One validated request, one host-selected [`DynamicBackend`], fresh
5//! bounds, one attempt: no retry, fallback, batching, duplicate
6//! suppression or cache reuse. The hard cost cap is reserved before
7//! dispatch, and a backend without an enforceable per-call bound at or
8//! below it is refused before dispatch. The deadline covers admission,
9//! capability validation, cost disclosure, dispatch and response
10//! validation. The result is a [`DynamicOutcome`], never a judgment, and
11//! nothing here touches a plan, a pending plan request or staged
12//! evaluation.
13
14use std::panic::{AssertUnwindSafe, catch_unwind};
15
16use rustev_contract::dynamic::{
17    AbstentionDecision, DynamicAttempt, DynamicCapability, DynamicOutcome, DynamicRaw,
18    DynamicRefusal, DynamicResult, DynamicTiming, DynamicUnresolved, DynamicValidation,
19};
20use rustev_contract::execution::{CostPolicy, FailureClass};
21use rustev_contract::ids::DynamicRequestId;
22use rustev_contract::remote::ServedIdentity;
23use rustev_contract::run::{
24    AttemptCost, AttemptEnd, CancelAnswer, Cancellation, Charge, CostBound, RemoteState,
25};
26use rustev_contract::time::DurationMs;
27use rustev_contract::{Identified, schema};
28use rustev_core::dynamic::{self as core, ValidatedQuestion};
29use rustev_core::seams::{
30    AdapterFailure, CallContext, CancelAck, CancelSignal, DynamicBackend, DynamicCall,
31    DynamicEvidenceSink, DynamicReport, RemoteEnd,
32};
33
34use crate::clock::{Clock, Instant};
35use crate::driver::{DETAIL_BYTES, answered, escape};
36use crate::ledger::Ledger;
37use crate::sink::DeliveryFailure;
38use crate::wait::{CatchUnwind, Raced, cut, poll_once, race};
39
40/// The bounds of one dynamic request, fresh for each call.
41#[derive(Clone, Copy)]
42pub struct DynamicBounds<'a> {
43    pub clock: &'a dyn Clock,
44    /// Can only tighten the request's own `deadline_ms`.
45    pub deadline_ms: Option<u64>,
46    /// A host ledger (a spend journal, say) reserved after the request's
47    /// own hard cap, and settled with the same charge.
48    pub shared_ledger: Option<&'a Ledger>,
49    /// Where the outcome is delivered, and how long delivery may take.
50    pub sink: Option<(&'a dyn DynamicEvidenceSink, u64)>,
51}
52
53/// Whether the outcome reached the sink. A failed delivery never changes
54/// the outcome.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum DynamicDelivery {
57    NotRequested,
58    Acknowledged { receipt: String },
59    Failed(DeliveryFailure),
60}
61
62/// What `ask_dynamic` returns: the outcome, and beside it its delivery.
63#[derive(Debug, Clone, PartialEq)]
64pub struct DynamicAsked {
65    pub outcome: DynamicOutcome,
66    pub delivery: DynamicDelivery,
67}
68
69/// What was observed of the attempt, if anything was dispatched.
70struct Observed {
71    served: ServedIdentity,
72    attempt: Option<DynamicAttempt>,
73    raw: DynamicRaw,
74    validation: DynamicValidation,
75    abstention: AbstentionDecision,
76    result: DynamicResult,
77}
78
79struct Building<'q> {
80    q: &'q ValidatedQuestion,
81    backend_id: String,
82    start: Instant,
83    deadline: Instant,
84    deadline_ms: u64,
85    ledger: Ledger,
86}
87
88impl Building<'_> {
89    fn outcome(
90        &self,
91        clock: &dyn Clock,
92        capability: Option<&DynamicCapability>,
93        request: Option<DynamicRequestId>,
94        o: Observed,
95    ) -> DynamicOutcome {
96        let q = self.q.question();
97        let now = clock.now();
98        DynamicOutcome {
99            schema: schema::DYNAMIC_OUTCOME.into(),
100            request_id: q.request_id.clone(),
101            request,
102            scope: q.scope.clone(),
103            task_family: q.task_family.clone(),
104            operation: q.operation,
105            output: q.output,
106            backend_id: self.backend_id.clone(),
107            requested_artifact: q.artifact.clone(),
108            served: o.served,
109            capability: capability.and_then(|c| c.id().ok()),
110            template: capability.map(|c| c.template.clone()),
111            privacy: q.privacy.clone(),
112            attempt: o.attempt,
113            timing: DynamicTiming {
114                elapsed_ms: now.saturating_sub(self.start),
115                deadline_ms: self.deadline_ms,
116                deadline_expired: now >= self.deadline,
117            },
118            cost: self.ledger.summary(),
119            raw: o.raw,
120            validation: o.validation,
121            abstention: o.abstention,
122            result: o.result,
123        }
124    }
125
126    /// An outcome with nothing dispatched.
127    fn undispatched(
128        &self,
129        clock: &dyn Clock,
130        capability: Option<&DynamicCapability>,
131        request: Option<DynamicRequestId>,
132        u: DynamicUnresolved,
133    ) -> DynamicOutcome {
134        self.outcome(
135            clock,
136            capability,
137            request,
138            Observed {
139                served: ServedIdentity::unknown(),
140                attempt: None,
141                raw: DynamicRaw::NotDispatched,
142                validation: DynamicValidation::NotRun,
143                abstention: AbstentionDecision::NotApplied,
144                result: DynamicResult::Unresolved(u),
145            },
146        )
147    }
148}
149
150fn refused(r: DynamicRefusal) -> DynamicUnresolved {
151    DynamicUnresolved::Refused { refusal: r }
152}
153
154/// How the one attempt ended.
155enum Ended {
156    Report(DynamicReport),
157    Failed(FailureClass, String),
158    Deadline,
159    Cancelled,
160}
161
162/// Ask one dynamic question (spec 019, 3.1): `question` was validated
163/// against the host's allowlist, `backend` is the host's choice, `cx`
164/// carries the non-secret trace id and principal handle (its remaining
165/// time is set here), and `cancel` is the caller's cancellation.
166pub async fn ask_dynamic(
167    question: &ValidatedQuestion,
168    backend: &dyn DynamicBackend,
169    bounds: DynamicBounds<'_>,
170    cx: CallContext,
171    cancel: &CancelSignal,
172) -> DynamicAsked {
173    let outcome = run(question, backend, &bounds, cx, cancel).await;
174    let delivery = match bounds.sink {
175        None => DynamicDelivery::NotRequested,
176        Some((sink, timeout_ms)) => deliver(sink, bounds.clock, &outcome, timeout_ms).await,
177    };
178    DynamicAsked { outcome, delivery }
179}
180
181async fn run(
182    question: &ValidatedQuestion,
183    backend: &dyn DynamicBackend,
184    bounds: &DynamicBounds<'_>,
185    cx: CallContext,
186    cancel: &CancelSignal,
187) -> DynamicOutcome {
188    let clock = bounds.clock;
189    let q = question.question();
190    let start = clock.now();
191    let deadline_ms = q.deadline_ms.min(bounds.deadline_ms.unwrap_or(u64::MAX));
192    let b = Building {
193        q: question,
194        backend_id: backend.backend_id().to_string(),
195        start,
196        deadline: start.saturating_add(deadline_ms),
197        deadline_ms,
198        // Fresh for this request: the hard cap is the only budget.
199        ledger: Ledger::new(CostPolicy::Hard {
200            max_units: q.max_cost_units,
201        }),
202    };
203    let cancel = &cancel.child();
204    let early = |b: &Building<'_>| {
205        if cancel.is_raised() {
206            Some(DynamicUnresolved::Cancelled)
207        } else if clock.now() >= b.deadline {
208            Some(DynamicUnresolved::DeadlineExceeded)
209        } else {
210            None
211        }
212    };
213    if let Some(u) = early(&b) {
214        return b.undispatched(clock, None, None, u);
215    }
216    // Capability validation.
217    let capability = match core::admit(question, backend.dynamic_capability()) {
218        Ok(c) => c,
219        Err(r) => {
220            let c = backend.dynamic_capability();
221            let request = c.and_then(|c| core::request_identity(question, c).ok());
222            return b.undispatched(clock, c, request, refused(r));
223        }
224    };
225    let request = match core::request_identity(question, capability) {
226        Ok(id) => id,
227        Err(e) => {
228            return b.undispatched(
229                clock,
230                Some(capability),
231                None,
232                refused(DynamicRefusal::CapabilityInvalid {
233                    detail: cut(&e.to_string(), DETAIL_BYTES),
234                }),
235            );
236        }
237    };
238    let projection = core::projection(question);
239    // Cost disclosure and the hard cap, before dispatch.
240    let bound = match catch_unwind(AssertUnwindSafe(|| backend.cost_bound(&projection))) {
241        Ok(bound) => bound,
242        Err(_) => CostBound::Unknown,
243    };
244    let units = match core::admit_cost(bound, q.max_cost_units) {
245        Ok(u) => u,
246        Err(r) => return b.undispatched(clock, Some(capability), Some(request), refused(r)),
247    };
248    if b.ledger.reserve(units).is_err() {
249        return b.undispatched(
250            clock,
251            Some(capability),
252            Some(request),
253            refused(DynamicRefusal::CostAboveCap {
254                bound: units,
255                cap: q.max_cost_units,
256            }),
257        );
258    }
259    if let Some(shared) = bounds.shared_ledger
260        && shared.reserve(units).is_err()
261    {
262        b.ledger.release(units);
263        return b.undispatched(
264            clock,
265            Some(capability),
266            Some(request),
267            refused(DynamicRefusal::BudgetExhausted),
268        );
269    }
270    let release = |b: &Building<'_>| {
271        b.ledger.release(units);
272        if let Some(shared) = bounds.shared_ledger {
273            shared.release(units);
274        }
275    };
276    if let Some(u) = early(&b) {
277        release(&b);
278        return b.undispatched(clock, Some(capability), Some(request), u);
279    }
280
281    // The one attempt.
282    let attempt_id = format!("{}/1", escape(&q.request_id));
283    let now = clock.now();
284    let dispatched_ms = now.saturating_sub(start);
285    let call_cx = CallContext {
286        remaining_ms: DurationMs(b.deadline.saturating_sub(now)),
287        ..cx
288    };
289    let signal = cancel.child();
290    let call = DynamicCall {
291        projection: &projection,
292        template: &capability.template,
293        attempt_id: &attempt_id,
294        cancel: signal.clone(),
295        cx: &call_cx,
296    };
297    let (ended, cancellation, remote, charge) =
298        match catch_unwind(AssertUnwindSafe(|| backend.ask(call))) {
299            Err(p) => (
300                Ended::Failed(
301                    FailureClass::AdapterFault,
302                    format!(
303                        "adapter panicked: {}",
304                        crate::wait::panic_detail(p.as_ref())
305                    ),
306                ),
307                Cancellation::NotRequested,
308                RemoteState::PossiblyContinuing,
309                Charge::Unknown,
310            ),
311            Ok(work) => {
312                let mut work = CatchUnwind::new(work);
313                let mut timer = clock.sleep_until(b.deadline);
314                match race(&mut work, cancel, &mut timer).await {
315                    Raced::Done(Ok(report))
316                        if signal.is_raised()
317                            && matches!(report.result, Err((AdapterFailure::Cancelled, _))) =>
318                    {
319                        let (c, r, ch) = answered(report.cancel, report.charge);
320                        (Ended::Cancelled, c, r, ch)
321                    }
322                    Raced::Done(Ok(report)) => {
323                        let cancellation = if signal.is_raised() {
324                            Cancellation::Requested {
325                                answer: match report.cancel {
326                                    CancelAck::Stopped => CancelAnswer::Stopped,
327                                    _ => CancelAnswer::Unconfirmed,
328                                },
329                            }
330                        } else {
331                            Cancellation::NotRequested
332                        };
333                        let (remote, charge) = match report.remote {
334                            RemoteEnd::PossiblyContinuing if !signal.is_raised() => {
335                                (RemoteState::PossiblyContinuing, Charge::Unknown)
336                            }
337                            _ => (RemoteState::Finished, report.charge),
338                        };
339                        (Ended::Report(report), cancellation, remote, charge)
340                    }
341                    Raced::Done(Err(panic)) => (
342                        Ended::Failed(
343                            FailureClass::AdapterFault,
344                            format!("adapter panicked: {panic}"),
345                        ),
346                        Cancellation::NotRequested,
347                        RemoteState::PossiblyContinuing,
348                        Charge::Unknown,
349                    ),
350                    raced => {
351                        let why = match raced {
352                            Raced::Cancelled => Ended::Cancelled,
353                            _ => Ended::Deadline,
354                        };
355                        // Raise the attempt's signal, poll once for an
356                        // acknowledgement, then stop waiting. Dropping the
357                        // future never proves that work or charges stopped.
358                        signal.raise();
359                        let (c, r, ch) = match poll_once(&mut work).await {
360                            Some(Ok(report)) => answered(report.cancel, report.charge),
361                            Some(Err(_)) | None => (
362                                Cancellation::Requested {
363                                    answer: CancelAnswer::NotObserved,
364                                },
365                                RemoteState::PossiblyContinuing,
366                                Charge::Unknown,
367                            ),
368                        };
369                        (why, c, r, ch)
370                    }
371                }
372            }
373        };
374    let ended_ms = clock.now().saturating_sub(start);
375    b.ledger.settle(&attempt_id, units, charge, true);
376    if let Some(shared) = bounds.shared_ledger {
377        shared.settle(&attempt_id, units, charge, true);
378    }
379    let attempt = |end: AttemptEnd| DynamicAttempt {
380        attempt_id: attempt_id.clone(),
381        dispatched_ms,
382        ended_ms,
383        end,
384        cancellation,
385        remote,
386        cost: AttemptCost {
387            bound,
388            reserved: units,
389            charge,
390        },
391    };
392    let unresolved_attempt =
393        |end: AttemptEnd, served: ServedIdentity, raw: DynamicRaw, u: DynamicUnresolved| {
394            b.outcome(
395                clock,
396                Some(capability),
397                Some(request.clone()),
398                Observed {
399                    served,
400                    attempt: Some(attempt(end)),
401                    raw,
402                    validation: DynamicValidation::NotRun,
403                    abstention: AbstentionDecision::NotApplied,
404                    result: DynamicResult::Unresolved(u),
405                },
406            )
407        };
408    match ended {
409        Ended::Cancelled => unresolved_attempt(
410            AttemptEnd::Cancelled,
411            ServedIdentity::unknown(),
412            DynamicRaw::NoAnswer,
413            DynamicUnresolved::Cancelled,
414        ),
415        Ended::Deadline => unresolved_attempt(
416            AttemptEnd::Deadline,
417            ServedIdentity::unknown(),
418            DynamicRaw::NoAnswer,
419            DynamicUnresolved::DeadlineExceeded,
420        ),
421        Ended::Failed(class, detail) => {
422            let detail = cut(&detail, DETAIL_BYTES);
423            unresolved_attempt(
424                AttemptEnd::Failed {
425                    class,
426                    detail: detail.clone(),
427                },
428                ServedIdentity::unknown(),
429                DynamicRaw::Failed {
430                    class,
431                    detail: detail.clone(),
432                },
433                DynamicUnresolved::BackendFailed { class, detail },
434            )
435        }
436        Ended::Report(report) => {
437            let served = bounded_served(report.served);
438            match report.result {
439                Err((failure, detail)) => {
440                    let class = match failure {
441                        AdapterFailure::Transient => FailureClass::Transient,
442                        AdapterFailure::Overloaded => FailureClass::Overloaded,
443                        AdapterFailure::Permanent => FailureClass::Permanent,
444                        AdapterFailure::Cancelled => FailureClass::AdapterFault,
445                    };
446                    let detail = cut(&detail, DETAIL_BYTES);
447                    unresolved_attempt(
448                        AttemptEnd::Failed {
449                            class,
450                            detail: detail.clone(),
451                        },
452                        served,
453                        DynamicRaw::Failed {
454                            class,
455                            detail: detail.clone(),
456                        },
457                        DynamicUnresolved::BackendFailed { class, detail },
458                    )
459                }
460                Ok(raw) => {
461                    let raw = bounded_raw(raw);
462                    let judged = core::judge(question, capability, &served, &raw);
463                    let end = match &judged.validation {
464                        DynamicValidation::Invalid { detail } => AttemptEnd::Failed {
465                            class: FailureClass::InvalidOutput,
466                            detail: cut(detail, DETAIL_BYTES),
467                        },
468                        _ => AttemptEnd::Output,
469                    };
470                    let result = match judged.result {
471                        Ok(answer) => DynamicResult::Proposal(core::proposal(
472                            question,
473                            request.clone(),
474                            capability,
475                            &attempt_id,
476                            answer,
477                        )),
478                        Err(u) => DynamicResult::Unresolved(u),
479                    };
480                    b.outcome(
481                        clock,
482                        Some(capability),
483                        Some(request.clone()),
484                        Observed {
485                            served,
486                            attempt: Some(attempt(end)),
487                            raw: DynamicRaw::Output(raw),
488                            validation: judged.validation,
489                            abstention: judged.abstention,
490                            result,
491                        },
492                    )
493                }
494            }
495        }
496    }
497}
498
499/// A served identity cut to 256 bytes.
500fn bounded_served(mut s: ServedIdentity) -> ServedIdentity {
501    s.identity = s.identity.map(|i| cut(&i, DETAIL_BYTES));
502    s
503}
504
505/// An unrecognized excerpt cut to 256 bytes; labels and masses are kept as
506/// returned (a label longer than any declared one is invalid anyway, and is
507/// cut so the record stays bounded). A distribution with a NaN or infinite
508/// mass cannot be written as JSON, so it is recorded, and then judged, as
509/// `non_finite_mass` material with the masses as text: the outcome stays a
510/// writable record and offline reproduction judges what was recorded.
511fn bounded_raw(
512    raw: rustev_contract::dynamic::DynamicRawOutput,
513) -> rustev_contract::dynamic::DynamicRawOutput {
514    use rustev_contract::dynamic::{DynamicRawOutput as R, ResponseShape};
515    match raw {
516        R::Label(l) => R::Label(cut(&l, DETAIL_BYTES)),
517        R::Distribution(m) if m.values().any(|v| !v.is_finite()) => {
518            let text = m
519                .iter()
520                .take(rustev_contract::dynamic::MAX_CLASSIFY_LABELS + 1)
521                .map(|(k, v)| format!("{:?}={v}", k.chars().take(64).collect::<String>()))
522                .collect::<Vec<_>>()
523                .join(", ");
524            R::Unrecognized {
525                shape: ResponseShape::NonFiniteMass,
526                excerpt: cut(&text, DETAIL_BYTES),
527            }
528        }
529        R::Distribution(m) => R::Distribution(
530            m.into_iter()
531                .take(rustev_contract::dynamic::MAX_CLASSIFY_LABELS + 1)
532                .map(|(k, v)| (cut(&k, DETAIL_BYTES), v))
533                .collect(),
534        ),
535        R::Unrecognized { shape, excerpt } => R::Unrecognized {
536            shape,
537            excerpt: cut(&excerpt, DETAIL_BYTES),
538        },
539    }
540}
541
542async fn deliver(
543    sink: &dyn DynamicEvidenceSink,
544    clock: &dyn Clock,
545    outcome: &DynamicOutcome,
546    timeout_ms: u64,
547) -> DynamicDelivery {
548    let work = match catch_unwind(AssertUnwindSafe(|| sink.deliver(outcome))) {
549        Ok(f) => f,
550        Err(p) => {
551            return DynamicDelivery::Failed(DeliveryFailure {
552                detail: cut(
553                    &format!(
554                        "the sink panicked: {}",
555                        crate::wait::panic_detail(p.as_ref())
556                    ),
557                    DETAIL_BYTES,
558                ),
559                uncertain: true,
560            });
561        }
562    };
563    let mut work = CatchUnwind::new(work);
564    let mut timer = clock.sleep_until(clock.now().saturating_add(timeout_ms));
565    match race(&mut work, &CancelSignal::new(), &mut timer).await {
566        Raced::Done(Ok(Ok(receipt))) => DynamicDelivery::Acknowledged { receipt },
567        Raced::Done(Ok(Err(e))) => DynamicDelivery::Failed(DeliveryFailure {
568            detail: cut(&e, DETAIL_BYTES),
569            uncertain: false,
570        }),
571        Raced::Done(Err(p)) => DynamicDelivery::Failed(DeliveryFailure {
572            detail: cut(&format!("the sink panicked: {p}"), DETAIL_BYTES),
573            uncertain: true,
574        }),
575        Raced::Expired | Raced::Cancelled => DynamicDelivery::Failed(DeliveryFailure {
576            detail: format!("no acknowledgement within {timeout_ms} ms"),
577            uncertain: true,
578        }),
579    }
580}