1use std::sync::Arc;
2
3use futures_util::future::{Either, select};
4use runifold_core::{
5 EffectClass, EffectEvent, EffectId, EffectRequest, Instant, RetrySafety, RunContext, RunError,
6 RunEventKind,
7};
8use serde_json::Value;
9
10use crate::{
11 EffectExecutionContext, EffectExecutorError, EffectExecutorErrorKind, EffectHandler,
12 EffectReconciler, EffectReconciliation, EffectRecord, EffectStatus, EffectStore,
13};
14
15#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum EffectRecoveryPolicy {
19 #[default]
21 RejectAmbiguous,
22 RetrySafe,
24}
25
26#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
28#[non_exhaustive]
29pub enum EffectEventPayloadPolicy {
30 #[default]
32 Redacted,
33 Full,
35}
36
37#[derive(Clone, Debug, PartialEq)]
39pub struct EffectOutcome {
40 pub effect_id: EffectId,
42 pub output: Value,
44 pub replayed: bool,
46 pub revision: u64,
48}
49
50#[derive(Clone)]
52pub struct EffectExecutor {
53 store: Arc<dyn EffectStore>,
54 event_payload_policy: EffectEventPayloadPolicy,
55}
56
57impl EffectExecutor {
58 pub fn new(store: Arc<dyn EffectStore>) -> Self {
60 Self {
61 store,
62 event_payload_policy: EffectEventPayloadPolicy::Redacted,
63 }
64 }
65
66 #[must_use]
68 pub const fn with_event_payload_policy(mut self, policy: EffectEventPayloadPolicy) -> Self {
69 self.event_payload_policy = policy;
70 self
71 }
72
73 pub async fn execute(
80 &self,
81 request: EffectRequest,
82 run: &RunContext,
83 handler: &dyn EffectHandler,
84 recovery: EffectRecoveryPolicy,
85 ) -> Result<EffectOutcome, EffectExecutorError> {
86 self.execute_inner(request, run, handler, recovery, None)
87 .await
88 }
89
90 pub async fn execute_reconciled(
102 &self,
103 request: EffectRequest,
104 run: &RunContext,
105 handler: &dyn EffectHandler,
106 reconciler: &dyn EffectReconciler,
107 recovery: EffectRecoveryPolicy,
108 ) -> Result<EffectOutcome, EffectExecutorError> {
109 self.execute_inner(request, run, handler, recovery, Some(reconciler))
110 .await
111 }
112
113 async fn execute_inner(
114 &self,
115 request: EffectRequest,
116 run: &RunContext,
117 handler: &dyn EffectHandler,
118 recovery: EffectRecoveryPolicy,
119 reconciler: Option<&dyn EffectReconciler>,
120 ) -> Result<EffectOutcome, EffectExecutorError> {
121 preflight(&request, run)?;
122 let (record, created) = self.resolve(request)?;
123 if created {
124 record_event(
125 run,
126 RunEventKind::Effect(EffectEvent::Requested {
127 effect_id: record.request.effect_id,
128 }),
129 )?;
130 }
131 match &record.status {
132 EffectStatus::Completed { output } => Ok(EffectOutcome {
133 effect_id: record.request.effect_id,
134 output: output.clone(),
135 replayed: true,
136 revision: record.revision,
137 }),
138 EffectStatus::Failed { error } => Err(EffectExecutorError::handler(error.clone())),
139 EffectStatus::Prepared => self.start(record, run, handler).await,
140 EffectStatus::Started => {
141 if let Some(reconciler) = reconciler {
142 match self.reconcile(&record, run, reconciler).await? {
143 EffectReconciliation::Completed(output) => {
144 let mut outcome = self.complete(&record, output, run)?;
145 outcome.replayed = true;
146 return Ok(outcome);
147 }
148 EffectReconciliation::NotExecuted => {
149 return self.start(record, run, handler).await;
150 }
151 EffectReconciliation::Ambiguous => {}
152 }
153 }
154 if recovery != EffectRecoveryPolicy::RetrySafe || !retry_safe(&record.request) {
155 return Err(EffectExecutorError::new(
156 EffectExecutorErrorKind::Ambiguous,
157 format!(
158 "effect `{}` may already have executed",
159 record.request.effect_id
160 ),
161 ));
162 }
163 self.start(record, run, handler).await
164 }
165 }
166 }
167
168 async fn reconcile(
169 &self,
170 record: &EffectRecord,
171 run: &RunContext,
172 reconciler: &dyn EffectReconciler,
173 ) -> Result<EffectReconciliation, EffectExecutorError> {
174 let context = EffectExecutionContext::for_run(run);
175 let cancellation = context.cancellation().clone();
176 let reconciliation = reconciler.reconcile(&record.request, context);
177 match select(Box::pin(cancellation.cancelled()), Box::pin(reconciliation)).await {
178 Either::Left(_) => Err(EffectExecutorError::new(
179 EffectExecutorErrorKind::Cancelled,
180 "effect reconciliation was cancelled and remains ambiguous",
181 )),
182 Either::Right((result, _)) => result.map_err(EffectExecutorError::reconciliation),
183 }
184 }
185
186 fn resolve(&self, request: EffectRequest) -> Result<(EffectRecord, bool), EffectExecutorError> {
187 if let Some(record) = self.store.load(request.effect_id)? {
188 validate_same_effect(&record.request, &request)?;
189 return Ok((record, false));
190 }
191 if let Some(key) = &request.idempotency_key
192 && let Some(record) = self.store.find_by_idempotency(request.capability_id, key)?
193 {
194 validate_same_effect(&record.request, &request)?;
195 return Ok((record, false));
196 }
197 let record = EffectRecord::prepared(request);
198 self.store.compare_and_swap(&record, None)?;
199 Ok((record, true))
200 }
201
202 async fn start(
203 &self,
204 record: EffectRecord,
205 run: &RunContext,
206 handler: &dyn EffectHandler,
207 ) -> Result<EffectOutcome, EffectExecutorError> {
208 let started = record.next(EffectStatus::Started)?;
209 self.store
210 .compare_and_swap(&started, Some(record.revision))?;
211 record_event(
212 run,
213 RunEventKind::Effect(EffectEvent::Started {
214 effect_id: started.request.effect_id,
215 }),
216 )?;
217
218 let context = EffectExecutionContext::for_run(run);
219 let cancellation = context.cancellation().clone();
220 let execution = handler.execute(&started.request, context);
221 let result = match select(Box::pin(cancellation.cancelled()), Box::pin(execution)).await {
222 Either::Left(_) => {
223 return Err(EffectExecutorError::new(
224 EffectExecutorErrorKind::Cancelled,
225 "effect execution was cancelled and remains ambiguous",
226 ));
227 }
228 Either::Right((result, _)) => result,
229 };
230
231 match result {
232 Ok(output) => self.complete(&started, output, run),
233 Err(error) if outcome_is_ambiguous(&error) => {
234 Err(EffectExecutorError::ambiguous_handler(error))
235 }
236 Err(error) => self.fail(&started, error, run),
237 }
238 }
239
240 fn complete(
241 &self,
242 started: &EffectRecord,
243 output: Value,
244 run: &RunContext,
245 ) -> Result<EffectOutcome, EffectExecutorError> {
246 let completed = started.next(EffectStatus::Completed {
247 output: output.clone(),
248 })?;
249 self.store
250 .compare_and_swap(&completed, Some(started.revision))?;
251 record_event(
252 run,
253 RunEventKind::Effect(EffectEvent::Completed {
254 effect_id: completed.request.effect_id,
255 output: match self.event_payload_policy {
256 EffectEventPayloadPolicy::Redacted => {
257 serde_json::json!({"runifold": {"content_recorded": false}})
258 }
259 EffectEventPayloadPolicy::Full => output.clone(),
260 },
261 }),
262 )?;
263 Ok(EffectOutcome {
264 effect_id: completed.request.effect_id,
265 output,
266 replayed: false,
267 revision: completed.revision,
268 })
269 }
270
271 fn fail(
272 &self,
273 started: &EffectRecord,
274 error: RunError,
275 run: &RunContext,
276 ) -> Result<EffectOutcome, EffectExecutorError> {
277 let failed = started.next(EffectStatus::Failed {
278 error: error.clone(),
279 })?;
280 self.store
281 .compare_and_swap(&failed, Some(started.revision))?;
282 record_event(
283 run,
284 RunEventKind::Effect(EffectEvent::Failed {
285 effect_id: failed.request.effect_id,
286 error: error.clone(),
287 }),
288 )?;
289 Err(EffectExecutorError::handler(error))
290 }
291}
292
293impl std::fmt::Debug for EffectExecutor {
294 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 formatter.write_str("EffectExecutor(..)")
296 }
297}
298
299fn preflight(request: &EffectRequest, run: &RunContext) -> Result<(), EffectExecutorError> {
300 if !run.capabilities().contains(request.capability_id) {
301 return Err(EffectExecutorError::new(
302 EffectExecutorErrorKind::CapabilityDenied,
303 "Run is not granted the effect capability",
304 ));
305 }
306 if run.cancellation().is_cancelled() {
307 return Err(EffectExecutorError::new(
308 EffectExecutorErrorKind::Cancelled,
309 "effect was cancelled before preparation",
310 ));
311 }
312 if run
313 .deadline()
314 .is_some_and(|deadline| deadline <= Instant::now())
315 {
316 return Err(EffectExecutorError::new(
317 EffectExecutorErrorKind::DeadlineExceeded,
318 "effect deadline elapsed before preparation",
319 ));
320 }
321 Ok(())
322}
323
324fn validate_same_effect(
325 existing: &EffectRequest,
326 requested: &EffectRequest,
327) -> Result<(), EffectExecutorError> {
328 let same = existing.kind == requested.kind
329 && existing.capability_id == requested.capability_id
330 && existing.input == requested.input
331 && existing.effect_class == requested.effect_class
332 && existing.idempotency_key == requested.idempotency_key;
333 if !same {
334 return Err(EffectExecutorError::new(
335 EffectExecutorErrorKind::IdempotencyConflict,
336 "effect identity or idempotency key was reused for different work",
337 ));
338 }
339 Ok(())
340}
341
342fn retry_safe(request: &EffectRequest) -> bool {
343 matches!(
344 request.effect_class,
345 EffectClass::Pure | EffectClass::ReadOnly
346 ) || matches!(request.effect_class, EffectClass::IdempotentWrite)
347 && request.idempotency_key.is_some()
348}
349
350fn outcome_is_ambiguous(error: &RunError) -> bool {
351 matches!(
352 error.retry_safety,
353 RetrySafety::RequiresIdempotency
354 | RetrySafety::UnsafeAfterVisibleOutput
355 | RetrySafety::UnsafeAfterSideEffect
356 | RetrySafety::Unknown
357 )
358}
359
360fn record_event(run: &RunContext, kind: RunEventKind) -> Result<(), EffectExecutorError> {
361 run.record(kind, None)?;
362 Ok(())
363}
364
365#[cfg(test)]
366mod tests {
367 use std::{
368 collections::BTreeMap,
369 sync::{
370 Arc,
371 atomic::{AtomicUsize, Ordering},
372 },
373 };
374
375 use runifold_core::{
376 Budget, BudgetTracker, CapabilityDescriptor, CapabilityId, CapabilityKind, CapabilitySet,
377 EffectClass, EffectEvent, EffectId, EffectKind, EffectRequest, InMemoryJournal,
378 InvocationId, RetrySafety, RiskLevel, RunContext, RunError, RunErrorKind, RunEventKind,
379 };
380 use serde_json::{Value, json};
381
382 use crate::{
383 EffectEventPayloadPolicy, EffectExecutionContext, EffectExecutor, EffectExecutorErrorKind,
384 EffectFuture, EffectHandler, EffectReconciler, EffectReconciliation, EffectRecord,
385 EffectRecoveryPolicy, EffectStatus, EffectStore, InMemoryEffectStore,
386 };
387
388 struct CountingHandler {
389 calls: AtomicUsize,
390 }
391
392 impl CountingHandler {
393 fn new() -> Self {
394 Self {
395 calls: AtomicUsize::new(0),
396 }
397 }
398 }
399
400 impl EffectHandler for CountingHandler {
401 fn execute(
402 &self,
403 request: &EffectRequest,
404 _context: EffectExecutionContext,
405 ) -> EffectFuture<'_, Result<Value, RunError>> {
406 self.calls.fetch_add(1, Ordering::SeqCst);
407 let input = request.input.clone();
408 Box::pin(async move { Ok(json!({"echo": input})) })
409 }
410 }
411
412 struct AmbiguousHandler;
413
414 impl EffectHandler for AmbiguousHandler {
415 fn execute(
416 &self,
417 _request: &EffectRequest,
418 _context: EffectExecutionContext,
419 ) -> EffectFuture<'_, Result<Value, RunError>> {
420 Box::pin(async {
421 Err(RunError {
422 kind: RunErrorKind::Transport,
423 message: "connection closed after request body was sent".into(),
424 retry_safety: RetrySafety::UnsafeAfterSideEffect,
425 metadata: BTreeMap::new(),
426 })
427 })
428 }
429 }
430
431 struct CompletedReconciler;
432
433 impl EffectReconciler for CompletedReconciler {
434 fn reconcile(
435 &self,
436 _request: &EffectRequest,
437 _context: EffectExecutionContext,
438 ) -> EffectFuture<'_, Result<EffectReconciliation, RunError>> {
439 Box::pin(async {
440 Ok(EffectReconciliation::Completed(
441 json!({"remote": "committed"}),
442 ))
443 })
444 }
445 }
446
447 struct FixedReconciler(EffectReconciliation);
448
449 impl EffectReconciler for FixedReconciler {
450 fn reconcile(
451 &self,
452 _request: &EffectRequest,
453 _context: EffectExecutionContext,
454 ) -> EffectFuture<'_, Result<EffectReconciliation, RunError>> {
455 let outcome = self.0.clone();
456 Box::pin(async move { Ok(outcome) })
457 }
458 }
459
460 struct FailedReconciler;
461
462 impl EffectReconciler for FailedReconciler {
463 fn reconcile(
464 &self,
465 _request: &EffectRequest,
466 _context: EffectExecutionContext,
467 ) -> EffectFuture<'_, Result<EffectReconciliation, RunError>> {
468 Box::pin(async {
469 Err(RunError {
470 kind: RunErrorKind::Transport,
471 message: "remote lookup unavailable".into(),
472 retry_safety: RetrySafety::Unknown,
473 metadata: BTreeMap::new(),
474 })
475 })
476 }
477 }
478
479 #[test]
480 fn completed_idempotent_effect_is_replayed_without_handler_execution() {
481 let capability = capability(EffectClass::IdempotentWrite);
482 let journal = InMemoryJournal::new();
483 let run = run_with(&capability, Some(journal.clone()));
484 let store = Arc::new(InMemoryEffectStore::new());
485 let executor = EffectExecutor::new(store);
486 let handler = CountingHandler::new();
487 let first_request = request(&capability, Some("order-7"), json!({"value": 7}));
488
489 let first = futures_executor::block_on(executor.execute(
490 first_request.clone(),
491 &run,
492 &handler,
493 EffectRecoveryPolicy::RejectAmbiguous,
494 ))
495 .unwrap();
496 let mut duplicate = first_request;
497 duplicate.effect_id = EffectId::new();
498 duplicate.invocation_id = InvocationId::new();
499 let second = futures_executor::block_on(executor.execute(
500 duplicate,
501 &run,
502 &handler,
503 EffectRecoveryPolicy::RejectAmbiguous,
504 ))
505 .unwrap();
506
507 assert!(!first.replayed);
508 assert!(second.replayed);
509 assert_eq!(second.effect_id, first.effect_id);
510 assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
511 let events = journal.events();
512 assert!(matches!(
513 events[0].kind,
514 RunEventKind::Effect(EffectEvent::Requested { .. })
515 ));
516 assert!(matches!(
517 events.last().unwrap().kind,
518 RunEventKind::Effect(EffectEvent::Completed { .. })
519 ));
520 assert!(matches!(
521 &events.last().unwrap().kind,
522 RunEventKind::Effect(EffectEvent::Completed { output, .. })
523 if output == &json!({"runifold": {"content_recorded": false}})
524 ));
525 }
526
527 #[test]
528 fn full_event_payload_capture_requires_explicit_opt_in() {
529 let capability = capability(EffectClass::Pure);
530 let journal = InMemoryJournal::new();
531 let run = run_with(&capability, Some(journal.clone()));
532 let executor = EffectExecutor::new(Arc::new(InMemoryEffectStore::new()))
533 .with_event_payload_policy(EffectEventPayloadPolicy::Full);
534 let handler = CountingHandler::new();
535
536 futures_executor::block_on(executor.execute(
537 request(&capability, None, json!("secret")),
538 &run,
539 &handler,
540 EffectRecoveryPolicy::RejectAmbiguous,
541 ))
542 .unwrap();
543
544 assert!(matches!(
545 &journal.events().last().unwrap().kind,
546 RunEventKind::Effect(EffectEvent::Completed { output, .. })
547 if output == &json!({"echo": "secret"})
548 ));
549 }
550
551 #[test]
552 fn idempotency_key_cannot_be_reused_for_different_input() {
553 let capability = capability(EffectClass::IdempotentWrite);
554 let run = run_with(&capability, None);
555 let executor = EffectExecutor::new(Arc::new(InMemoryEffectStore::new()));
556 let handler = CountingHandler::new();
557 let first = request(&capability, Some("same-key"), json!({"value": 1}));
558 futures_executor::block_on(executor.execute(
559 first,
560 &run,
561 &handler,
562 EffectRecoveryPolicy::RejectAmbiguous,
563 ))
564 .unwrap();
565 let second = request(&capability, Some("same-key"), json!({"value": 2}));
566
567 let error = futures_executor::block_on(executor.execute(
568 second,
569 &run,
570 &handler,
571 EffectRecoveryPolicy::RejectAmbiguous,
572 ))
573 .unwrap_err();
574
575 assert_eq!(error.kind, EffectExecutorErrorKind::IdempotencyConflict);
576 assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
577 }
578
579 #[test]
580 fn ambiguous_non_idempotent_effect_is_never_retried() {
581 let capability = capability(EffectClass::NonIdempotentWrite);
582 let run = run_with(&capability, None);
583 let store = Arc::new(InMemoryEffectStore::new());
584 let request = request(&capability, None, json!({"charge": 10}));
585 let prepared = EffectRecord::prepared(request.clone());
586 store.compare_and_swap(&prepared, None).unwrap();
587 let started = prepared.next(EffectStatus::Started).unwrap();
588 store.compare_and_swap(&started, Some(0)).unwrap();
589 let executor = EffectExecutor::new(store);
590 let handler = CountingHandler::new();
591
592 let error = futures_executor::block_on(executor.execute(
593 request,
594 &run,
595 &handler,
596 EffectRecoveryPolicy::RetrySafe,
597 ))
598 .unwrap_err();
599
600 assert_eq!(error.kind, EffectExecutorErrorKind::Ambiguous);
601 assert_eq!(handler.calls.load(Ordering::SeqCst), 0);
602 }
603
604 #[test]
605 fn remote_reconciliation_closes_completed_but_unrecorded_crash_window() {
606 let capability = capability(EffectClass::NonIdempotentWrite);
607 let run = run_with(&capability, None);
608 let store = Arc::new(InMemoryEffectStore::new());
609 let request = request(
610 &capability,
611 Some("remote-operation-7"),
612 json!({"charge": 10}),
613 );
614 let prepared = EffectRecord::prepared(request.clone());
615 store.compare_and_swap(&prepared, None).unwrap();
616 let started = prepared.next(EffectStatus::Started).unwrap();
617 store.compare_and_swap(&started, Some(0)).unwrap();
618 let executor = EffectExecutor::new(store.clone());
619 let handler = CountingHandler::new();
620
621 let outcome = futures_executor::block_on(executor.execute_reconciled(
622 request,
623 &run,
624 &handler,
625 &CompletedReconciler,
626 EffectRecoveryPolicy::RejectAmbiguous,
627 ))
628 .unwrap();
629
630 assert!(outcome.replayed);
631 assert_eq!(outcome.output, json!({"remote": "committed"}));
632 assert_eq!(handler.calls.load(Ordering::SeqCst), 0);
633 assert!(matches!(
634 store.load(outcome.effect_id).unwrap().unwrap().status,
635 EffectStatus::Completed { .. }
636 ));
637 }
638
639 #[test]
640 fn uncertain_handler_failure_stays_started_until_remote_reconciliation() {
641 let capability = capability(EffectClass::IdempotentWrite);
642 let run = run_with(&capability, None);
643 let store = Arc::new(InMemoryEffectStore::new());
644 let executor = EffectExecutor::new(store.clone());
645 let request = request(
646 &capability,
647 Some("remote-operation-response-loss"),
648 json!({"charge": 10}),
649 );
650
651 let error = futures_executor::block_on(executor.execute(
652 request.clone(),
653 &run,
654 &AmbiguousHandler,
655 EffectRecoveryPolicy::RejectAmbiguous,
656 ))
657 .unwrap_err();
658 assert_eq!(error.kind, EffectExecutorErrorKind::Ambiguous);
659 assert!(matches!(
660 store.load(request.effect_id).unwrap().unwrap().status,
661 EffectStatus::Started
662 ));
663
664 let outcome = futures_executor::block_on(executor.execute_reconciled(
665 request,
666 &run,
667 &CountingHandler::new(),
668 &CompletedReconciler,
669 EffectRecoveryPolicy::RejectAmbiguous,
670 ))
671 .unwrap();
672 assert!(outcome.replayed);
673 assert_eq!(outcome.output, json!({"remote": "committed"}));
674 }
675
676 #[test]
677 fn reconciliation_reexecutes_only_after_remote_not_executed_proof() {
678 let capability = capability(EffectClass::NonIdempotentWrite);
679 let run = run_with(&capability, None);
680 let store = Arc::new(InMemoryEffectStore::new());
681 let request = request(
682 &capability,
683 Some("remote-operation-8"),
684 json!({"charge": 11}),
685 );
686 let prepared = EffectRecord::prepared(request.clone());
687 store.compare_and_swap(&prepared, None).unwrap();
688 let started = prepared.next(EffectStatus::Started).unwrap();
689 store.compare_and_swap(&started, Some(0)).unwrap();
690 let executor = EffectExecutor::new(store);
691 let handler = CountingHandler::new();
692
693 let outcome = futures_executor::block_on(executor.execute_reconciled(
694 request,
695 &run,
696 &handler,
697 &FixedReconciler(EffectReconciliation::NotExecuted),
698 EffectRecoveryPolicy::RejectAmbiguous,
699 ))
700 .unwrap();
701
702 assert!(!outcome.replayed);
703 assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
704 }
705
706 #[test]
707 fn unresolved_or_failed_reconciliation_never_runs_the_handler() {
708 let capability = capability(EffectClass::NonIdempotentWrite);
709 let run = run_with(&capability, None);
710 let store = Arc::new(InMemoryEffectStore::new());
711 let request = request(
712 &capability,
713 Some("remote-operation-9"),
714 json!({"charge": 12}),
715 );
716 let prepared = EffectRecord::prepared(request.clone());
717 store.compare_and_swap(&prepared, None).unwrap();
718 let started = prepared.next(EffectStatus::Started).unwrap();
719 store.compare_and_swap(&started, Some(0)).unwrap();
720 let executor = EffectExecutor::new(store);
721 let handler = CountingHandler::new();
722
723 let ambiguous = futures_executor::block_on(executor.execute_reconciled(
724 request.clone(),
725 &run,
726 &handler,
727 &FixedReconciler(EffectReconciliation::Ambiguous),
728 EffectRecoveryPolicy::RejectAmbiguous,
729 ))
730 .unwrap_err();
731 let failed = futures_executor::block_on(executor.execute_reconciled(
732 request,
733 &run,
734 &handler,
735 &FailedReconciler,
736 EffectRecoveryPolicy::RejectAmbiguous,
737 ))
738 .unwrap_err();
739
740 assert_eq!(ambiguous.kind, EffectExecutorErrorKind::Ambiguous);
741 assert_eq!(failed.kind, EffectExecutorErrorKind::Reconciliation);
742 assert_eq!(handler.calls.load(Ordering::SeqCst), 0);
743 }
744
745 #[test]
746 fn started_idempotent_effect_can_be_explicitly_reconciled_by_retry() {
747 let capability = capability(EffectClass::IdempotentWrite);
748 let run = run_with(&capability, None);
749 let store = Arc::new(InMemoryEffectStore::new());
750 let request = request(&capability, Some("safe-key"), json!({"write": 1}));
751 let prepared = EffectRecord::prepared(request.clone());
752 store.compare_and_swap(&prepared, None).unwrap();
753 let started = prepared.next(EffectStatus::Started).unwrap();
754 store.compare_and_swap(&started, Some(0)).unwrap();
755 let executor = EffectExecutor::new(store);
756 let handler = CountingHandler::new();
757
758 let outcome = futures_executor::block_on(executor.execute(
759 request,
760 &run,
761 &handler,
762 EffectRecoveryPolicy::RetrySafe,
763 ))
764 .unwrap();
765
766 assert_eq!(outcome.revision, 3);
767 assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
768 }
769
770 #[test]
771 fn missing_capability_rejects_before_persistence_or_execution() {
772 let capability = capability(EffectClass::Pure);
773 let run = RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new());
774 let store = Arc::new(InMemoryEffectStore::new());
775 let effect = request(&capability, None, json!({}));
776 let executor = EffectExecutor::new(store.clone());
777 let handler = CountingHandler::new();
778
779 let error = futures_executor::block_on(executor.execute(
780 effect.clone(),
781 &run,
782 &handler,
783 EffectRecoveryPolicy::RejectAmbiguous,
784 ))
785 .unwrap_err();
786
787 assert_eq!(error.kind, EffectExecutorErrorKind::CapabilityDenied);
788 assert!(store.load(effect.effect_id).unwrap().is_none());
789 assert_eq!(handler.calls.load(Ordering::SeqCst), 0);
790 }
791
792 fn capability(effect: EffectClass) -> CapabilityDescriptor {
793 CapabilityDescriptor {
794 id: CapabilityId::new(),
795 name: "test-effect".into(),
796 version: "1".into(),
797 kind: CapabilityKind::Resource,
798 input_schema: json!({}),
799 output_schema: json!({}),
800 effect,
801 risk: RiskLevel::Low,
802 metadata: BTreeMap::new(),
803 }
804 }
805
806 fn request(
807 capability: &CapabilityDescriptor,
808 key: Option<&str>,
809 input: Value,
810 ) -> EffectRequest {
811 EffectRequest {
812 effect_id: EffectId::new(),
813 invocation_id: InvocationId::new(),
814 kind: EffectKind::Extension("test".into()),
815 capability_id: capability.id,
816 input,
817 effect_class: capability.effect,
818 idempotency_key: key.map(str::to_owned),
819 }
820 }
821
822 fn run_with(capability: &CapabilityDescriptor, journal: Option<InMemoryJournal>) -> RunContext {
823 let mut capabilities = CapabilitySet::new();
824 capabilities.grant(capability.clone());
825 let run = RunContext::root(BudgetTracker::new(Budget::default()), capabilities);
826 match journal {
827 Some(journal) => run.with_journal(Arc::new(journal)),
828 None => run,
829 }
830 }
831}