1use 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#[derive(Debug)]
53pub enum DispatchAnswer {
54 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
67pub trait LiveAuthorizer: Send + Sync {
73 fn authorize(&self, request: &AuthorizationRequest) -> Result<Option<DispatchAnswer>, String>;
74}
75
76#[derive(Debug, Clone)]
78pub struct LiveRequest {
79 pub decision_id: String,
81 pub snapshot: Snapshot,
83 pub expected_snapshot_id: Option<SnapshotId>,
87 pub evaluation_time: Timestamp,
89 pub deadline_ms: Option<u64>,
91 pub principal_handle: Vec<u8>,
93 pub subject: Handle,
95 pub scope: Scope,
96 pub capture: Option<CaptureConfig>,
98 pub historical: Option<HistoricalLink>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum LiveRefusal {
106 HistoricalDecisionId,
108 InvalidHistoricalLink,
110 SnapshotIdentity {
112 stated: SnapshotId,
113 computed: SnapshotId,
114 },
115 InvalidSnapshot(String),
117 Capture(CaptureConfigError),
118 Unbounded {
120 worst_case: u64,
121 max: u64,
122 },
123 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#[derive(Debug)]
157pub struct LiveResult {
158 pub completion: Completion,
159 pub record: Arc<RunRecord>,
161 pub delivery: Result<Delivery, DeliveryFailure>,
162 pub capture: Option<CaptureOutcome>,
164 pub document: LiveReexecution,
165}
166
167struct 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
183pub(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
192pub(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 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 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
317fn 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 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}