Skip to main content

rustev_runtime/
live.rs

1//! Live re-execution with fresh observations (spec 026).
2//!
3//! [`Runtime::reexecute_live`] is the only entry point that may request
4//! fresh external work for a previously recorded decision shape, and the
5//! only one that accepts a host [`LiveAuthorizer`]. It runs a new decision:
6//! a decision id unequal to the historical one, a snapshot whose identity the
7//! runtime computes, a fresh evaluation time, fresh admission, deadline,
8//! concurrency, retry, fallback and budget state, and new observations.
9//! Nothing is read from a replay bundle; a historical case is a link (a
10//! digest plus identities) carried into the evidence for later comparison.
11//!
12//! Immediately before every external attempt, retries and fallbacks
13//! included, and after its full worst-case exposure is reserved, the host is
14//! asked about that exact attempt. A missing, expired, mismatched, errored
15//! or refused answer prevents dispatch and is recorded as
16//! `authorization_refused`; the runtime never downgrades scope, switches
17//! targets, retries or falls back to avoid a refusal. The answer is scoped
18//! to that attempt, is never exported, and authorizes nothing downstream.
19
20use std::collections::BTreeMap;
21use std::fmt;
22use std::panic::{AssertUnwindSafe, catch_unwind};
23use std::sync::{Arc, Mutex};
24
25use rustev_contract::definition::{Operation, StepBody};
26use rustev_contract::ids::{ContentDigest, SnapshotId};
27use rustev_contract::live::{
28    Admission, AttemptRole, AuthorizationOutcome, AuthorizationReference, AuthorizationRefusal,
29    AuthorizationRequest, CapturePosture, ComparisonSection, DeliveryObservation, Dispatch,
30    Exposure, Historical, HistoricalLink, HistoricalRetention, LiveEffect, LiveReexecution,
31    MAX_EFFECTS, RetentionPosture, RunLink,
32};
33use rustev_contract::plan::{PlanExecution, PlanStepDetail};
34use rustev_contract::run::{Charge, CoreEvidence, CostBound, RunRecord, Termination};
35use rustev_contract::scope::{Handle, Scope};
36use rustev_contract::snapshot::Snapshot;
37use rustev_contract::time::Timestamp;
38use rustev_contract::{Document, Identified, schema};
39use rustev_core::evaluate::SemanticRequest;
40use rustev_core::seams::CancelSignal;
41
42use crate::driver::{DETAIL_BYTES, DecisionCtx};
43use crate::wait::cut;
44use crate::{
45    CaptureConfig, CaptureConfigError, CaptureOutcome, Completion, DecideError, Decided,
46    DecisionRequest, Delivery, DeliveryFailure, PreparedPlan, Rejection, Runtime, Target, capture,
47};
48
49/// The host's answer about one proposed attempt. In process only: it is
50/// neither serializable nor clonable, the runtime keeps nothing of it but
51/// the reference, and it authorizes no action outside that one dispatch.
52#[derive(Debug)]
53pub enum DispatchAnswer {
54    /// Send exactly the attempt whose request digest is `answers`, if it is
55    /// sent within `valid_for_ms` of the question, by the runtime's clock.
56    Allow {
57        answers: ContentDigest,
58        reference: AuthorizationReference,
59        valid_for_ms: u64,
60    },
61    Refuse {
62        reference: Option<AuthorizationReference>,
63        detail: String,
64    },
65}
66
67/// The host's authorization seam (spec 026, 3.3). Rustev mints no token:
68/// the host decides, from its own trusted facts, whether this exact attempt
69/// may be sent. `Ok(None)` is no answer (missing); `Err` is a failure of the
70/// authorizer (errored); a panic is contained and errored. Every one of
71/// them prevents dispatch.
72pub trait LiveAuthorizer: Send + Sync {
73    fn authorize(&self, request: &AuthorizationRequest) -> Result<Option<DispatchAnswer>, String>;
74}
75
76/// One live re-execution to run. Nothing here is taken from a replay bundle.
77#[derive(Debug, Clone)]
78pub struct LiveRequest {
79    /// A new decision id: never the historical one, and not running.
80    pub decision_id: String,
81    /// The current snapshot, validated by the normal snapshot contract.
82    pub snapshot: Snapshot,
83    /// The identity the caller believes the snapshot has, if it states one.
84    /// Never trusted: the runtime computes the identity, and a different
85    /// stated identity is refused.
86    pub expected_snapshot_id: Option<SnapshotId>,
87    /// Supplied anew; never read from a clock or a historical record.
88    pub evaluation_time: Timestamp,
89    /// Can only tighten the plan's `deadline_ms`.
90    pub deadline_ms: Option<u64>,
91    /// Passed through to backends, as for `decide`.
92    pub principal_handle: Vec<u8>,
93    /// Opaque, non-secret subject and scope presented to the authorizer.
94    pub subject: Handle,
95    pub scope: Scope,
96    /// The new run's own capture; off unless the host chooses it.
97    pub capture: Option<CaptureConfig>,
98    /// The historical case this run may later be compared with, by link.
99    pub historical: Option<HistoricalLink>,
100}
101
102/// Why a live re-execution was refused before admission. Nothing was
103/// presented to the authorizer, dispatched or spent.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum LiveRefusal {
106    /// The decision id is the historical decision's.
107    HistoricalDecisionId,
108    /// The historical link names a decision id outside 1 to 256 bytes.
109    InvalidHistoricalLink,
110    /// The snapshot's computed identity is not the identity stated for it.
111    SnapshotIdentity {
112        stated: SnapshotId,
113        computed: SnapshotId,
114    },
115    /// The snapshot has no canonical identity.
116    InvalidSnapshot(String),
117    Capture(CaptureConfigError),
118    /// The plan can propose more attempts than one live document records.
119    Unbounded {
120        worst_case: u64,
121        max: u64,
122    },
123    /// The runtime's own admission refusal (spec 003, 3.4.1).
124    Rejected(Rejection),
125}
126
127impl fmt::Display for LiveRefusal {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            LiveRefusal::HistoricalDecisionId => {
131                f.write_str("a live re-execution needs a new decision id, not the historical one")
132            }
133            LiveRefusal::InvalidHistoricalLink => {
134                f.write_str("the historical link's decision id is not 1 to 256 bytes")
135            }
136            LiveRefusal::SnapshotIdentity { stated, computed } => write!(
137                f,
138                "identity mismatch: the snapshot is {computed}, not the stated {stated}"
139            ),
140            LiveRefusal::InvalidSnapshot(e) => write!(f, "the snapshot has no identity: {e}"),
141            LiveRefusal::Capture(e) => write!(f, "{e}"),
142            LiveRefusal::Unbounded { worst_case, max } => write!(
143                f,
144                "the plan can propose {worst_case} attempts; a live document records at most {max}"
145            ),
146            LiveRefusal::Rejected(r) => write!(f, "not admitted: {r:?}"),
147        }
148    }
149}
150
151impl std::error::Error for LiveRefusal {}
152
153/// A finished live re-execution: the ordinary result of a new decision and
154/// its live document, returned also when evidence delivery failed, so every
155/// attempt, charge and remote state stays with the caller (spec 026, 5).
156#[derive(Debug)]
157pub struct LiveResult {
158    pub completion: Completion,
159    /// The new run record; the evidence of execution.
160    pub record: Arc<RunRecord>,
161    pub delivery: Result<Delivery, DeliveryFailure>,
162    /// `None` when capture was off.
163    pub capture: Option<CaptureOutcome>,
164    pub document: LiveReexecution,
165}
166
167/// One proposed attempt as presented, and the host's answer.
168struct Presented {
169    step: String,
170    instance: Vec<String>,
171    target: u32,
172    role: AttemptRole,
173    attempt_id: String,
174    backend_id: String,
175    artifact: rustev_contract::ids::ArtifactId,
176    request: rustev_contract::ids::RequestId,
177    presented: ContentDigest,
178    exposure: Exposure,
179    remaining_ms: u64,
180    authorization: AuthorizationOutcome,
181}
182
183/// The live path's state within one decision.
184pub(crate) struct LiveCtx<'l> {
185    authorizer: &'l dyn LiveAuthorizer,
186    subject: &'l Handle,
187    scope: &'l Scope,
188    operations: BTreeMap<String, Operation>,
189    presented: Mutex<Vec<Presented>>,
190}
191
192/// What [`LiveCtx::authorize`] needs to know about the proposed attempt.
193pub(crate) struct Proposal<'p> {
194    pub req: &'p SemanticRequest,
195    pub target_index: usize,
196    pub target: &'p Target,
197    pub role: AttemptRole,
198    pub attempt_id: &'p str,
199    pub bound: CostBound,
200    pub reserved: u64,
201}
202
203impl LiveCtx<'_> {
204    /// Present one proposed attempt to the host, after its reservation and
205    /// immediately before dispatch, and record the answer. `Ok` only for an
206    /// allow that names this exact request and is still valid now.
207    pub(crate) fn authorize(
208        &self,
209        cx: &DecisionCtx<'_, '_>,
210        p: Proposal<'_>,
211    ) -> Result<(), AuthorizationRefusal> {
212        let now = cx.rt.clock.now();
213        let identity = cx
214            .ev()
215            .request_document(
216                &p.req.step,
217                &p.req.instance,
218                p.target_index as u32,
219                self.scope,
220            )
221            .map_err(|e| e.to_string())
222            .and_then(|d| d.id().map_err(|e| e.to_string()));
223        let request = match identity {
224            Ok(id) => id,
225            // The request is pending and its target bound, so this is a
226            // defect; it still prevents dispatch rather than guessing.
227            Err(_) => return Err(AuthorizationRefusal::Errored),
228        };
229        let asked = AuthorizationRequest {
230            decision_id: cx.decision_id.to_string(),
231            subject: self.subject.clone(),
232            scope: self.scope.clone(),
233            step: p.req.step.clone(),
234            instance: p.req.instance.clone(),
235            operation: self.operations[&p.req.step],
236            target: p.target_index as u32,
237            role: p.role,
238            backend_id: p.target.backend_id.clone(),
239            artifact: p.target.artifact.clone(),
240            request: request.clone(),
241            attempt_id: p.attempt_id.to_string(),
242            exposure: Exposure {
243                bound: p.bound,
244                reserved_units: p.reserved,
245            },
246            remaining_ms: cx.deadline.saturating_sub(now),
247        };
248        let Ok(digest) = asked.digest() else {
249            return Err(AuthorizationRefusal::Errored);
250        };
251        let refused = |refusal, reference, detail: &str| AuthorizationOutcome::Refused {
252            refusal,
253            reference,
254            detail: cut(detail, DETAIL_BYTES),
255        };
256        let outcome = match catch_unwind(AssertUnwindSafe(|| self.authorizer.authorize(&asked))) {
257            Err(p) => refused(
258                AuthorizationRefusal::Errored,
259                None,
260                &format!(
261                    "authorizer panicked: {}",
262                    crate::wait::panic_detail(p.as_ref())
263                ),
264            ),
265            Ok(Err(detail)) => refused(AuthorizationRefusal::Errored, None, &detail),
266            Ok(Ok(None)) => refused(AuthorizationRefusal::Missing, None, "no answer"),
267            Ok(Ok(Some(DispatchAnswer::Refuse { reference, detail }))) => {
268                refused(AuthorizationRefusal::Refused, reference, &detail)
269            }
270            Ok(Ok(Some(DispatchAnswer::Allow {
271                answers,
272                reference,
273                valid_for_ms,
274            }))) => {
275                if answers != digest {
276                    refused(
277                        AuthorizationRefusal::Mismatched,
278                        Some(reference),
279                        "the answer names another request",
280                    )
281                } else if cx.rt.clock.now() >= now.saturating_add(valid_for_ms) {
282                    refused(
283                        AuthorizationRefusal::Expired,
284                        Some(reference),
285                        &format!("the answer was valid for {valid_for_ms} ms"),
286                    )
287                } else {
288                    AuthorizationOutcome::Allowed { reference }
289                }
290            }
291        };
292        let result = match &outcome {
293            AuthorizationOutcome::Allowed { .. } => Ok(()),
294            AuthorizationOutcome::Refused { refusal, .. } => Err(*refusal),
295        };
296        self.presented
297            .lock()
298            .unwrap_or_else(|e| e.into_inner())
299            .push(Presented {
300                step: asked.step,
301                instance: asked.instance,
302                target: asked.target,
303                role: asked.role,
304                attempt_id: asked.attempt_id,
305                backend_id: asked.backend_id,
306                artifact: asked.artifact,
307                request,
308                presented: digest,
309                exposure: asked.exposure,
310                remaining_ms: asked.remaining_ms,
311                authorization: outcome,
312            });
313        result
314    }
315}
316
317/// The worst-case number of attempts a plan can propose: its execution
318/// policy's bound, or one per possible request without a policy. A refused
319/// proposal ends its request, so it replaces an attempt, never adds one.
320fn worst_case_attempts(plan: &PreparedPlan) -> u64 {
321    match &plan.compiled().plan.execution {
322        PlanExecution::Declared(d) => d.policy.max_attempts_per_decision,
323        PlanExecution::None => plan
324            .compiled()
325            .plan
326            .steps
327            .iter()
328            .filter_map(|s| match &s.detail {
329                PlanStepDetail::Semantic(b) => Some(b.max_requests),
330                _ => None,
331            })
332            .fold(0, u64::saturating_add),
333    }
334}
335
336impl Runtime {
337    /// Re-execute a decision shape live (spec 026): a new decision, under
338    /// fresh bounds, with the host authorizing every external attempt
339    /// immediately before it is sent. The only entry point that accepts an
340    /// authorizer; `decide`, capture, replay and comparison never dispatch
341    /// on its behalf.
342    ///
343    /// Refused before admission, with nothing presented or spent: a reused
344    /// historical decision id, a snapshot whose computed identity is not the
345    /// stated one, an invalid capture configuration, a plan whose proposals
346    /// exceed the document bound, and every admission refusal of `decide`.
347    ///
348    /// ```no_run
349    /// # async fn f(rt: &rustev_runtime::Runtime, plan: &rustev_runtime::PreparedPlan,
350    /// #     live: rustev_runtime::LiveRequest, a: &dyn rustev_runtime::LiveAuthorizer,
351    /// #     req: rustev_runtime::DecisionRequest) {
352    /// let cancel = rustev_core::seams::CancelSignal::new();
353    /// let _ = rt.reexecute_live(plan, live, a, &cancel).await;
354    /// let _ = rt.decide(plan, req, &cancel).await;
355    /// # }
356    /// ```
357    ///
358    /// ```compile_fail
359    /// # async fn f(rt: &rustev_runtime::Runtime, plan: &rustev_runtime::PreparedPlan,
360    /// #     req: rustev_runtime::DecisionRequest, a: &dyn rustev_runtime::LiveAuthorizer) {
361    /// // `decide` takes no authorizer: only `reexecute_live` does.
362    /// rt.decide(plan, req, &rustev_core::seams::CancelSignal::new(), a).await;
363    /// # }
364    /// ```
365    ///
366    /// ```compile_fail
367    /// # async fn f(rt: &rustev_runtime::Runtime, plan: &rustev_runtime::PreparedPlan,
368    /// #     req: rustev_runtime::DecisionRequest, c: &rustev_runtime::CaptureConfig,
369    /// #     a: &dyn rustev_runtime::LiveAuthorizer) {
370    /// // Nor does capture.
371    /// rt.decide_with_capture(plan, req, &rustev_core::seams::CancelSignal::new(), c, a).await;
372    /// # }
373    /// ```
374    pub async fn reexecute_live(
375        &self,
376        plan: &PreparedPlan,
377        req: LiveRequest,
378        authorizer: &dyn LiveAuthorizer,
379        cancel: &CancelSignal,
380    ) -> Result<LiveResult, LiveRefusal> {
381        if req
382            .historical
383            .as_ref()
384            .is_some_and(|h| h.decision_id == req.decision_id)
385        {
386            return Err(LiveRefusal::HistoricalDecisionId);
387        }
388        if req
389            .historical
390            .as_ref()
391            .is_some_and(|h| h.decision_id.is_empty() || h.decision_id.len() > 256)
392        {
393            return Err(LiveRefusal::InvalidHistoricalLink);
394        }
395        let snapshot_id = req
396            .snapshot
397            .id()
398            .map_err(|e| LiveRefusal::InvalidSnapshot(e.to_string()))?;
399        if let Some(stated) = &req.expected_snapshot_id
400            && *stated != snapshot_id
401        {
402            return Err(LiveRefusal::SnapshotIdentity {
403                stated: stated.clone(),
404                computed: snapshot_id,
405            });
406        }
407        if let Some(c) = &req.capture {
408            c.check().map_err(LiveRefusal::Capture)?;
409        }
410        let worst_case = worst_case_attempts(plan);
411        if worst_case > MAX_EFFECTS as u64 {
412            return Err(LiveRefusal::Unbounded {
413                worst_case,
414                max: MAX_EFFECTS as u64,
415            });
416        }
417
418        let operations = plan
419            .compiled()
420            .plan
421            .definition
422            .steps
423            .iter()
424            .filter_map(|s| match &s.body {
425                StepBody::Semantic(d) => Some((s.id.clone(), d.operation)),
426                _ => None,
427            })
428            .collect();
429        let live = LiveCtx {
430            authorizer,
431            subject: &req.subject,
432            scope: &req.scope,
433            operations,
434            presented: Mutex::new(vec![]),
435        };
436        let decision = DecisionRequest {
437            decision_id: req.decision_id.clone(),
438            snapshot: req.snapshot.clone(),
439            evaluation_time: req.evaluation_time,
440            deadline_ms: req.deadline_ms,
441            principal_handle: req.principal_handle.clone(),
442        };
443        let mut capturing = req.capture.as_ref().map(capture::Capturing::new);
444        let result = self
445            .run(
446                plan,
447                decision,
448                cancel,
449                capturing.as_mut(),
450                Some(&live),
451                None,
452            )
453            .await;
454        let (record, delivery) = match result {
455            Err(DecideError::Rejected(r)) => return Err(LiveRefusal::Rejected(r)),
456            Ok(Decided {
457                record, delivery, ..
458            }) => (record, Ok(delivery)),
459            Err(DecideError::EvidenceNotDelivered { record, failure }) => (record, Err(failure)),
460        };
461        let capture = capturing.map(|c| {
462            CaptureOutcome::Captured(c.finish(
463                &req.decision_id,
464                record.termination == Termination::Cancelled,
465            ))
466        });
467        let presented = live
468            .presented
469            .into_inner()
470            .unwrap_or_else(|e| e.into_inner());
471        let document = document(&req, snapshot_id, &record, &delivery, &capture, presented);
472        let completion = match &record.core {
473            CoreEvidence::Recorded(e) if record.termination == Termination::Judged => {
474                Completion::Judged(Box::new(e.judgment.clone()))
475            }
476            _ => Completion::Cancelled,
477        };
478        Ok(LiveResult {
479            completion,
480            record,
481            delivery,
482            capture,
483            document,
484        })
485    }
486}
487
488fn document(
489    req: &LiveRequest,
490    snapshot_id: SnapshotId,
491    record: &RunRecord,
492    delivery: &Result<Delivery, DeliveryFailure>,
493    capture: &Option<CaptureOutcome>,
494    presented: Vec<Presented>,
495) -> LiveReexecution {
496    let attempts: BTreeMap<&str, &rustev_contract::run::AttemptRecord> = record
497        .requests
498        .iter()
499        .flat_map(|r| &r.attempts)
500        .map(|a| (a.attempt_id.as_str(), a))
501        .collect();
502    let effects = presented
503        .into_iter()
504        .map(|p| {
505            let dispatch = match attempts.get(p.attempt_id.as_str()) {
506                Some(a) => Dispatch::Dispatched {
507                    end: a.end.clone(),
508                    cancellation: a.cancellation,
509                    remote: a.remote,
510                    charge: a.cost.charge,
511                },
512                None => Dispatch::NotDispatched,
513            };
514            LiveEffect {
515                step: p.step,
516                instance: p.instance,
517                target: p.target,
518                role: p.role,
519                attempt_id: p.attempt_id,
520                backend_id: p.backend_id,
521                artifact: p.artifact,
522                request: p.request,
523                presented: p.presented,
524                exposure: p.exposure,
525                remaining_ms: p.remaining_ms,
526                authorization: p.authorization,
527                dispatch,
528            }
529        })
530        .collect();
531    let unreconciled = attempts
532        .values()
533        .filter(|a| a.cost.charge == Charge::Unknown)
534        .map(|a| a.attempt_id.clone())
535        .collect();
536    let delivery = match delivery {
537        Ok(Delivery::Acknowledged { receipt }) => DeliveryObservation::Acknowledged {
538            receipt: cut(receipt, DETAIL_BYTES),
539        },
540        Ok(Delivery::Queued(_)) => DeliveryObservation::Queued,
541        Ok(Delivery::Dropped { dropped_total }) => DeliveryObservation::Dropped {
542            dropped_total: *dropped_total,
543        },
544        Err(f) => DeliveryObservation::NotDelivered {
545            detail: cut(&f.detail, DETAIL_BYTES),
546            uncertain: f.uncertain,
547        },
548    };
549    let capture = match (&req.capture, capture) {
550        (Some(c), Some(CaptureOutcome::Captured(k))) => CapturePosture::On {
551            scope: c.scope.clone(),
552            max_bytes: c.max_bytes,
553            status: k.status,
554        },
555        _ => CapturePosture::Off,
556    };
557    LiveReexecution {
558        schema: schema::LIVE_REEXECUTION.into(),
559        decision_id: req.decision_id.clone(),
560        plan_id: record.plan_id.clone(),
561        snapshot_id,
562        evaluation_time_ms: req.evaluation_time,
563        subject: req.subject.clone(),
564        scope: req.scope.clone(),
565        historical: match &req.historical {
566            Some(h) => Historical::Linked(h.clone()),
567            None => Historical::None,
568        },
569        admission: Admission {
570            queued_ms: record.timing.queued_ms,
571            deadline_ms: record.timing.deadline_ms,
572        },
573        run_digest: record.record_digest().ok(),
574        run: RunLink {
575            termination: record.termination,
576            cost: record.cost,
577        },
578        effects,
579        unreconciled,
580        delivery,
581        retention: RetentionPosture {
582            capture,
583            historical: if req.historical.is_some() {
584                HistoricalRetention::NotObserved
585            } else {
586                HistoricalRetention::NoHistory
587            },
588        },
589        comparison: ComparisonSection::NotRequested,
590    }
591}