1pub use crate::ToolError as ToolRuntimeError;
2use crate::{
3 validate_arguments_against_schema, ApprovalGrantEffectClass, ToolApprovalKind,
4 ToolApprovalState, ToolCall, ToolDescriptor, ToolError, ToolErrorClass, ToolExecutionPermit,
5 ToolReceipt, ToolReceiptPhase, ToolReceiptResolution, ToolRegistry, ToolResult, ToolRetryOwner,
6};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use stack_ids::{ContentDigest, DigestBuilder};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use std::time::Duration;
13
14#[async_trait]
15pub trait ApprovalPolicy: Send + Sync {
16 async fn evaluate(
17 &self,
18 descriptor: &ToolDescriptor,
19 ctx: &crate::ToolCtx,
20 call: &ToolCall,
21 ) -> Result<ToolApprovalState, ToolError>;
22}
23
24#[derive(Default)]
25pub struct StaticApprovalPolicy;
26
27#[async_trait]
28impl ApprovalPolicy for StaticApprovalPolicy {
29 async fn evaluate(
30 &self,
31 descriptor: &ToolDescriptor,
32 ctx: &crate::ToolCtx,
33 call: &ToolCall,
34 ) -> Result<ToolApprovalState, ToolError> {
35 if descriptor_has_tokenless_read_only_path(descriptor) {
36 return Ok(ToolApprovalState::NotRequired);
37 }
38
39 let permit = ctx.execution_permit.as_ref().ok_or_else(|| {
40 ToolError::new(
41 ToolErrorClass::ApprovalRequired,
42 format!("tool {} requires an execution permit", descriptor.name),
43 )
44 })?;
45 if !execution_permit_authorizes(permit, descriptor, ctx, call) {
46 return Err(ToolError::new(
47 ToolErrorClass::Denied,
48 format!("execution permit does not cover tool {}", descriptor.name),
49 ));
50 }
51
52 if descriptor.approval_kind == ToolApprovalKind::None {
53 return Ok(ToolApprovalState::NotRequired);
54 }
55
56 if let Some(grant) = ctx.approval_grant.as_ref() {
57 if approval_grant_authorizes(grant, descriptor, ctx, call, &Utc::now().to_rfc3339()) {
58 return Ok(ToolApprovalState::Approved);
59 }
60 return Err(ToolError::new(
61 ToolErrorClass::Denied,
62 format!("approval grant does not cover tool {}", descriptor.name),
63 ));
64 }
65
66 Err(ToolError::new(
67 ToolErrorClass::ApprovalRequired,
68 format!("tool {} requires approval", descriptor.name),
69 ))
70 }
71}
72
73fn descriptor_has_tokenless_read_only_path(descriptor: &ToolDescriptor) -> bool {
74 descriptor.read_only
75 && matches!(
76 descriptor.side_effect_class,
77 crate::ToolSideEffectClass::ReadOnly
78 )
79}
80
81fn execution_permit_authorizes(
82 permit: &ToolExecutionPermit,
83 descriptor: &ToolDescriptor,
84 ctx: &crate::ToolCtx,
85 call: &ToolCall,
86) -> bool {
87 let Some(scope) = ctx.scope.as_ref() else {
88 return false;
89 };
90 if permit.scope().namespace() != scope.namespace {
91 return false;
92 }
93 let Ok(intent) = descriptor.describe_effect(&call.arguments) else {
94 return false;
95 };
96 permit.scope().target_key() == intent.target_key
97 && permit
98 .validate_binding(&descriptor.method_digest(), &intent.digest(), Utc::now())
99 .is_ok()
100}
101
102fn approval_grant_authorizes(
103 grant: &crate::ApprovalGrant,
104 descriptor: &ToolDescriptor,
105 ctx: &crate::ToolCtx,
106 call: &ToolCall,
107 evaluated_at: &str,
108) -> bool {
109 if grant.approver_lineage.is_empty() {
110 return false;
111 }
112 if grant
113 .expires_at
114 .as_ref()
115 .is_some_and(|expires_at| expires_at.as_str() <= evaluated_at)
116 {
117 return false;
118 }
119 if grant.scope.tool_name != descriptor.name {
120 return false;
121 }
122 if grant
123 .scope
124 .planner_stage
125 .as_ref()
126 .is_some_and(|planner_stage| planner_stage != &ctx.planner_stage)
127 {
128 return false;
129 }
130
131 let Some(required_effect_class) =
132 ApprovalGrantEffectClass::for_tool_side_effect_class(&descriptor.side_effect_class)
133 else {
134 return false;
135 };
136 if grant.scope.effect_class != required_effect_class {
137 return false;
138 }
139
140 let Some(scope) = ctx.scope.as_ref() else {
141 return false;
142 };
143 if grant.scope.namespace != scope.namespace {
144 return false;
145 }
146 if grant.scope.target_key.as_ref().is_some_and(|target_key| {
147 descriptor
148 .describe_effect(&call.arguments)
149 .map_or(true, |intent| target_key != &intent.target_key)
150 }) {
151 return false;
152 }
153 if let Some(permit) = ctx.execution_permit.as_ref() {
154 if grant
155 .decision_id
156 .as_ref()
157 .is_some_and(|decision_id| decision_id != permit.decision_id())
158 {
159 return false;
160 }
161 if grant
162 .approval_record_id
163 .as_ref()
164 .is_some_and(|approval_record_id| {
165 permit.approval_record_id() != Some(approval_record_id)
166 })
167 {
168 return false;
169 }
170 }
171
172 true
173}
174
175#[async_trait]
176pub trait ToolReceiptSink: Send + Sync {
177 async fn health_check(&self) -> Result<(), ToolError>;
178 async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError>;
179 async fn mark_unresolved(&self, preflight_receipt_id: &str) -> Result<(), ToolError>;
180}
181
182#[derive(Default)]
183pub struct InMemoryReceiptSink {
184 receipts: std::sync::Mutex<Vec<ToolReceipt>>,
185}
186
187impl InMemoryReceiptSink {
188 pub fn receipts(&self) -> Vec<ToolReceipt> {
190 self.receipts
191 .lock()
192 .unwrap_or_else(|poisoned| poisoned.into_inner())
193 .clone()
194 }
195}
196
197#[async_trait]
198impl ToolReceiptSink for InMemoryReceiptSink {
199 async fn health_check(&self) -> Result<(), ToolError> {
200 Ok(())
201 }
202
203 async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError> {
204 self.receipts
205 .lock()
206 .unwrap_or_else(|poisoned| poisoned.into_inner())
207 .push(receipt.clone());
208 Ok(())
209 }
210
211 async fn mark_unresolved(&self, preflight_receipt_id: &str) -> Result<(), ToolError> {
212 let mut receipts = self
213 .receipts
214 .lock()
215 .unwrap_or_else(|poisoned| poisoned.into_inner());
216 let receipt = receipts
217 .iter_mut()
218 .find(|receipt| receipt.receipt_id == preflight_receipt_id)
219 .ok_or_else(|| {
220 ToolError::new(
221 ToolErrorClass::ReceiptPersistence,
222 "preflight receipt not found for unresolved marking",
223 )
224 })?;
225 receipt.resolution = ToolReceiptResolution::Unresolved;
226 Ok(())
227 }
228}
229
230#[derive(Debug, Clone)]
231pub struct ToolRuntimeConfig {
232 pub default_timeout_ms: u64,
233 pub default_output_size_limit_bytes: usize,
234}
235
236impl Default for ToolRuntimeConfig {
237 fn default() -> Self {
238 Self {
239 default_timeout_ms: 30_000,
240 default_output_size_limit_bytes: 256 * 1024,
241 }
242 }
243}
244
245#[derive(Debug, Clone)]
246pub struct ToolExecution {
247 pub result: Result<ToolResult, ToolError>,
248 pub receipt: ToolReceipt,
249}
250
251pub struct ToolRuntime {
252 registry: ToolRegistry,
253 approval_policy: Arc<dyn ApprovalPolicy>,
254 receipt_sink: Option<Arc<dyn ToolReceiptSink>>,
255 config: ToolRuntimeConfig,
256}
257
258impl ToolRuntime {
259 pub fn new(registry: ToolRegistry) -> Self {
261 Self {
262 registry,
263 approval_policy: Arc::new(StaticApprovalPolicy),
264 receipt_sink: None,
265 config: ToolRuntimeConfig::default(),
266 }
267 }
268
269 pub fn with_config(mut self, config: ToolRuntimeConfig) -> Self {
271 self.config = config;
272 self
273 }
274
275 pub fn with_approval_policy(mut self, approval_policy: Arc<dyn ApprovalPolicy>) -> Self {
277 self.approval_policy = approval_policy;
278 self
279 }
280
281 pub fn with_receipt_sink(mut self, receipt_sink: Arc<dyn ToolReceiptSink>) -> Self {
283 self.receipt_sink = Some(receipt_sink);
284 self
285 }
286
287 pub fn registry(&self) -> &ToolRegistry {
289 &self.registry
290 }
291
292 pub async fn execute(
293 &self,
294 ctx: &crate::ToolCtx,
295 call: &ToolCall,
296 permit: Option<Arc<ToolExecutionPermit>>,
297 cancel: Option<&AtomicBool>,
298 ) -> ToolExecution {
299 let started_at = Utc::now();
300 let mut execution_ctx = ctx.clone();
301 execution_ctx.execution_permit = permit.clone();
302 let descriptor = match self.registry.get(&call.descriptor_name) {
303 Some(tool) => tool.descriptor().clone(),
304 None => {
305 let error = ToolError::new(
306 ToolErrorClass::UnknownTool,
307 format!("unknown tool {}", call.descriptor_name),
308 );
309 let receipt = self.failure_receipt(ctx, call, started_at, error.clone(), None);
310 return ToolExecution {
311 result: Err(error),
312 receipt,
313 };
314 }
315 };
316
317 if descriptor.version != call.descriptor_version {
318 let error = ToolError::new(
319 ToolErrorClass::ProviderContract,
320 format!(
321 "tool version mismatch for {}: expected {}, got {}",
322 descriptor.name, descriptor.version, call.descriptor_version
323 ),
324 );
325 let receipt =
326 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
327 return ToolExecution {
328 result: Err(error),
329 receipt,
330 };
331 }
332
333 if let Err(error) =
334 validate_arguments_against_schema(&descriptor.input_schema, &call.arguments)
335 {
336 let receipt =
337 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
338 return ToolExecution {
339 result: Err(error),
340 receipt,
341 };
342 }
343
344 let approval_state = match self
345 .approval_policy
346 .evaluate(&descriptor, &execution_ctx, call)
347 .await
348 {
349 Ok(state) => state,
350 Err(error) => {
351 let receipt =
352 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
353 return ToolExecution {
354 result: Err(error),
355 receipt,
356 };
357 }
358 };
359
360 if matches!(approval_state, ToolApprovalState::Denied) {
361 let error = ToolError::new(
362 ToolErrorClass::Denied,
363 format!("tool {} was denied by approval policy", descriptor.name),
364 );
365 let receipt =
366 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
367 return ToolExecution {
368 result: Err(error),
369 receipt,
370 };
371 }
372
373 let Some(tool) = self.registry.get(&call.descriptor_name) else {
374 let error = ToolError::new(
375 ToolErrorClass::UnknownTool,
376 format!("tool {} disappeared before execution", call.descriptor_name),
377 );
378 let receipt =
379 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
380 return ToolExecution {
381 result: Err(error),
382 receipt,
383 };
384 };
385
386 let effectful = !descriptor_has_tokenless_read_only_path(&descriptor);
387 if cfg!(not(debug_assertions)) && effectful && !descriptor.receipt_persistence.is_durable()
388 {
389 let error = ToolError::new(
390 ToolErrorClass::ReceiptPersistence,
391 format!(
392 "effectful tool {} requires durable receipt persistence in release mode",
393 descriptor.name
394 ),
395 );
396 let receipt = self.failure_receipt_with_approval(
397 ctx,
398 call,
399 started_at,
400 error.clone(),
401 &descriptor,
402 approval_state,
403 );
404 return ToolExecution {
405 result: Err(error),
406 receipt,
407 };
408 }
409
410 let preflight_receipt = if descriptor.receipt_persistence.is_durable() {
411 let Some(sink) = &self.receipt_sink else {
412 let error = ToolError::new(
413 ToolErrorClass::ReceiptPersistence,
414 format!("tool {} requires a durable receipt sink", descriptor.name),
415 );
416 let receipt = self.failure_receipt_with_approval(
417 ctx,
418 call,
419 started_at,
420 error.clone(),
421 &descriptor,
422 approval_state,
423 );
424 return ToolExecution {
425 result: Err(error),
426 receipt,
427 };
428 };
429 if let Err(error) = sink.health_check().await {
430 let receipt = self.failure_receipt_with_approval(
431 ctx,
432 call,
433 started_at,
434 error.clone(),
435 &descriptor,
436 approval_state,
437 );
438 return ToolExecution {
439 result: Err(error),
440 receipt,
441 };
442 }
443 let preflight = self.preflight_receipt(
444 &execution_ctx,
445 call,
446 &descriptor,
447 started_at,
448 approval_state.clone(),
449 );
450 if let Err(error) = sink.persist(&preflight).await {
451 let receipt = self.failure_receipt_with_approval(
452 ctx,
453 call,
454 started_at,
455 error.clone(),
456 &descriptor,
457 approval_state,
458 );
459 return ToolExecution {
460 result: Err(error),
461 receipt,
462 };
463 }
464 Some(preflight)
465 } else {
466 None
467 };
468
469 if effectful {
470 let Some(permit) = permit.as_ref() else {
471 let error = ToolError::new(
472 ToolErrorClass::ApprovalRequired,
473 format!("tool {} requires an execution permit", descriptor.name),
474 );
475 let receipt = self.failure_receipt_with_approval(
476 ctx,
477 call,
478 started_at,
479 error.clone(),
480 &descriptor,
481 approval_state,
482 );
483 return ToolExecution {
484 result: Err(error),
485 receipt,
486 };
487 };
488 if let Err(error) = permit.consume() {
489 if let (Some(sink), Some(preflight)) = (&self.receipt_sink, &preflight_receipt) {
490 let _ = sink.mark_unresolved(&preflight.receipt_id).await;
491 }
492 let receipt = self.failure_receipt_with_approval(
493 &execution_ctx,
494 call,
495 started_at,
496 error.clone(),
497 &descriptor,
498 approval_state,
499 );
500 return ToolExecution {
501 result: Err(error),
502 receipt,
503 };
504 }
505 }
506
507 let timeout_ms = resolve_timeout_ms(&descriptor, ctx, self.config.default_timeout_ms);
508 let fut = async {
509 if let Some(flag) = cancel {
510 if flag.load(Ordering::Relaxed) {
511 return Err(ToolError::new(ToolErrorClass::Cancelled, "tool cancelled"));
512 }
513 }
514
515 let execute = tool.invoke(&execution_ctx, call);
516 match cancel {
517 Some(flag) => {
518 tokio::select! {
519 result = execute => result,
520 _ = wait_for_cancel(flag) => Err(ToolError::new(ToolErrorClass::Cancelled, "tool cancelled")),
521 }
522 }
523 None => execute.await,
524 }
525 };
526
527 let result = match tokio::time::timeout(Duration::from_millis(timeout_ms), fut).await {
528 Ok(inner) => inner,
529 Err(_) => Err(ToolError::new(
530 ToolErrorClass::Timeout,
531 format!("tool {} timed out after {}ms", descriptor.name, timeout_ms),
532 )),
533 };
534
535 let result = match result {
536 Ok(tool_result) => {
537 if let Err(error) =
538 enforce_output_size_limit(&descriptor, &tool_result, &self.config)
539 {
540 Err(error)
541 } else {
542 Ok(tool_result)
543 }
544 }
545 Err(error) => Err(error),
546 };
547
548 let mut receipt = match &result {
549 Ok(tool_result) => self.success_receipt(
550 &execution_ctx,
551 call,
552 &descriptor,
553 started_at,
554 tool_result,
555 approval_state.clone(),
556 ),
557 Err(error) => self.failure_receipt_with_approval(
558 &execution_ctx,
559 call,
560 started_at,
561 error.clone(),
562 &descriptor,
563 approval_state.clone(),
564 ),
565 };
566
567 if result.is_err() {
568 if let Some(action) = &descriptor.rollback_contract {
569 if let Err(error) = action.compensate(&receipt).await {
570 return ToolExecution {
571 result: Err(error),
572 receipt,
573 };
574 }
575 }
576 }
577
578 if let Some(preflight) = &preflight_receipt {
579 receipt.preflight_receipt_id = Some(preflight.receipt_id.clone());
580 }
581
582 if descriptor.receipt_persistence.is_durable() {
583 if let Some(sink) = &self.receipt_sink {
584 if let Err(error) = sink.persist(&receipt).await {
585 if let Some(preflight) = &preflight_receipt {
586 let _ = sink.mark_unresolved(&preflight.receipt_id).await;
587 }
588 let mut receipt = self.failure_receipt_with_approval(
589 &execution_ctx,
590 call,
591 started_at,
592 error.clone(),
593 &descriptor,
594 approval_state,
595 );
596 receipt.resolution = ToolReceiptResolution::Unresolved;
597 receipt.preflight_receipt_id = preflight_receipt
598 .as_ref()
599 .map(|preflight| preflight.receipt_id.clone());
600 return ToolExecution {
601 result: Err(error),
602 receipt,
603 };
604 }
605 } else {
606 let error = ToolError::new(
607 ToolErrorClass::ReceiptPersistence,
608 format!("tool {} requires a durable receipt sink", descriptor.name),
609 );
610 let receipt = self.failure_receipt_with_approval(
611 ctx,
612 call,
613 started_at,
614 error.clone(),
615 &descriptor,
616 approval_state,
617 );
618 return ToolExecution {
619 result: Err(error),
620 receipt,
621 };
622 }
623 }
624
625 ToolExecution { result, receipt }
626 }
627
628 fn success_receipt(
629 &self,
630 ctx: &crate::ToolCtx,
631 call: &ToolCall,
632 descriptor: &ToolDescriptor,
633 started_at: DateTime<Utc>,
634 result: &ToolResult,
635 approval_state: ToolApprovalState,
636 ) -> ToolReceipt {
637 ToolReceipt {
638 receipt_id: uuid::Uuid::new_v4().to_string(),
639 tool_name: descriptor.name.clone(),
640 tool_version: descriptor.version.clone(),
641 backend_kind: descriptor.backend_kind.clone(),
642 input_digest: digest_json(&call.arguments),
643 output_digest_or_refs: output_digest_or_refs(result),
644 policy_hash: policy_hash(descriptor),
645 approval_state,
646 phase: ToolReceiptPhase::Outcome,
647 resolution: ToolReceiptResolution::Resolved,
648 authority_lineage: authority_lineage(ctx, call, Some(descriptor)),
649 host_identity: ctx.caller.clone(),
650 started_at: started_at.to_rfc3339(),
651 finished_at: Utc::now().to_rfc3339(),
652 trace_ctx: ctx.trace_ctx.clone(),
653 attempt_id: ctx.attempt_id.clone(),
654 trial_id: ctx.trial_id.clone(),
655 planner_stage: ctx.planner_stage.clone(),
656 deadline: ctx.deadline.clone(),
657 workload_class: ctx.workload_class.clone(),
658 budget_context: ctx.budget_context.clone(),
659 parent_receipt_id: ctx.parent_receipt_id.clone(),
660 preflight_receipt_id: None,
661 family_receipt_id: ctx.family_receipt_id.clone(),
662 replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
663 remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
664 remote_slice_result_id: ctx.remote_slice_result_id.clone(),
665 attestation_envelope_id: ctx.attestation_envelope_id.clone(),
666 cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
667 error_class: None,
668 retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
669 replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
670 tool_run_id: call.tool_run_id.clone(),
671 provider_call_id: call.provider_call_id.clone(),
672 }
673 }
674
675 fn failure_receipt(
676 &self,
677 ctx: &crate::ToolCtx,
678 call: &ToolCall,
679 started_at: DateTime<Utc>,
680 error: ToolError,
681 descriptor: Option<&ToolDescriptor>,
682 ) -> ToolReceipt {
683 let descriptor_backend_kind = descriptor
684 .map(|desc| desc.backend_kind.clone())
685 .unwrap_or(crate::ToolBackendKind::LocalFunction);
686 let tool_version = descriptor
687 .map(|desc| desc.version.clone())
688 .unwrap_or_else(|| call.descriptor_version.clone());
689 let policy_hash = descriptor
690 .map(policy_hash)
691 .unwrap_or_else(|| ContentDigest::compute(b"missing_policy"));
692
693 ToolReceipt {
694 receipt_id: uuid::Uuid::new_v4().to_string(),
695 tool_name: call.descriptor_name.clone(),
696 tool_version,
697 backend_kind: descriptor_backend_kind,
698 input_digest: digest_json(&call.arguments),
699 output_digest_or_refs: serde_json::json!({"error": error.message}),
700 policy_hash,
701 approval_state: ToolApprovalState::Denied,
702 phase: ToolReceiptPhase::Outcome,
703 resolution: ToolReceiptResolution::Resolved,
704 authority_lineage: authority_lineage(ctx, call, descriptor),
705 host_identity: ctx.caller.clone(),
706 started_at: started_at.to_rfc3339(),
707 finished_at: Utc::now().to_rfc3339(),
708 trace_ctx: ctx.trace_ctx.clone(),
709 attempt_id: ctx.attempt_id.clone(),
710 trial_id: ctx.trial_id.clone(),
711 planner_stage: ctx.planner_stage.clone(),
712 deadline: ctx.deadline.clone(),
713 workload_class: ctx.workload_class.clone(),
714 budget_context: ctx.budget_context.clone(),
715 parent_receipt_id: ctx.parent_receipt_id.clone(),
716 preflight_receipt_id: None,
717 family_receipt_id: ctx.family_receipt_id.clone(),
718 replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
719 remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
720 remote_slice_result_id: ctx.remote_slice_result_id.clone(),
721 attestation_envelope_id: ctx.attestation_envelope_id.clone(),
722 cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
723 error_class: Some(error.class),
724 retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
725 replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
726 tool_run_id: call.tool_run_id.clone(),
727 provider_call_id: call.provider_call_id.clone(),
728 }
729 }
730
731 fn failure_receipt_with_approval(
732 &self,
733 ctx: &crate::ToolCtx,
734 call: &ToolCall,
735 started_at: DateTime<Utc>,
736 error: ToolError,
737 descriptor: &ToolDescriptor,
738 approval_state: ToolApprovalState,
739 ) -> ToolReceipt {
740 ToolReceipt {
741 receipt_id: uuid::Uuid::new_v4().to_string(),
742 tool_name: descriptor.name.clone(),
743 tool_version: descriptor.version.clone(),
744 backend_kind: descriptor.backend_kind.clone(),
745 input_digest: digest_json(&call.arguments),
746 output_digest_or_refs: serde_json::json!({"error": error.message}),
747 policy_hash: policy_hash(descriptor),
748 approval_state,
749 phase: ToolReceiptPhase::Outcome,
750 resolution: ToolReceiptResolution::Resolved,
751 authority_lineage: authority_lineage(ctx, call, Some(descriptor)),
752 host_identity: ctx.caller.clone(),
753 started_at: started_at.to_rfc3339(),
754 finished_at: Utc::now().to_rfc3339(),
755 trace_ctx: ctx.trace_ctx.clone(),
756 attempt_id: ctx.attempt_id.clone(),
757 trial_id: ctx.trial_id.clone(),
758 planner_stage: ctx.planner_stage.clone(),
759 deadline: ctx.deadline.clone(),
760 workload_class: ctx.workload_class.clone(),
761 budget_context: ctx.budget_context.clone(),
762 parent_receipt_id: ctx.parent_receipt_id.clone(),
763 preflight_receipt_id: None,
764 family_receipt_id: ctx.family_receipt_id.clone(),
765 replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
766 remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
767 remote_slice_result_id: ctx.remote_slice_result_id.clone(),
768 attestation_envelope_id: ctx.attestation_envelope_id.clone(),
769 cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
770 error_class: Some(error.class),
771 retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
772 replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
773 tool_run_id: call.tool_run_id.clone(),
774 provider_call_id: call.provider_call_id.clone(),
775 }
776 }
777
778 fn preflight_receipt(
779 &self,
780 ctx: &crate::ToolCtx,
781 call: &ToolCall,
782 descriptor: &ToolDescriptor,
783 started_at: DateTime<Utc>,
784 approval_state: ToolApprovalState,
785 ) -> ToolReceipt {
786 let mut receipt = self.failure_receipt_with_approval(
787 ctx,
788 call,
789 started_at,
790 ToolError::new(ToolErrorClass::Execution, "effect outcome pending"),
791 descriptor,
792 approval_state,
793 );
794 receipt.phase = ToolReceiptPhase::Preflight;
795 receipt.resolution = ToolReceiptResolution::Pending;
796 receipt.error_class = None;
797 receipt.output_digest_or_refs = serde_json::json!({"outcome": "pending"});
798 receipt
799 }
800}
801
802fn authority_lineage(
803 ctx: &crate::ToolCtx,
804 call: &ToolCall,
805 descriptor: Option<&ToolDescriptor>,
806) -> Vec<crate::AuthorityLineageEntry> {
807 let permit_id = ctx
808 .execution_permit
809 .as_ref()
810 .map(|permit| permit.execution_permit_id().as_str().to_owned())
811 .unwrap_or_else(|| "tokenless-read-only".into());
812 let policy_version = descriptor
813 .map(|descriptor| format!("{}:{}", descriptor.version, policy_hash(descriptor).hex()))
814 .unwrap_or_else(|| call.descriptor_version.clone());
815 ["request", "policy", "approval", "permit", "effect"]
816 .into_iter()
817 .map(|origin_class| crate::AuthorityLineageEntry {
818 origin_class: origin_class.into(),
819 principal: ctx.caller.clone(),
820 permit_id: permit_id.clone(),
821 policy_version: policy_version.clone(),
822 })
823 .collect()
824}
825
826fn resolve_timeout_ms(
827 descriptor: &ToolDescriptor,
828 ctx: &crate::ToolCtx,
829 default_timeout_ms: u64,
830) -> u64 {
831 let mut timeout_ms = if descriptor.timeout_ms == 0 {
832 default_timeout_ms
833 } else {
834 descriptor.timeout_ms
835 };
836
837 if let Some(deadline) = &ctx.deadline {
838 if let Ok(deadline_at) = DateTime::parse_from_rfc3339(deadline) {
839 let remaining = deadline_at
840 .with_timezone(&Utc)
841 .signed_duration_since(Utc::now())
842 .num_milliseconds()
843 .max(1) as u64;
844 timeout_ms = timeout_ms.min(remaining);
845 }
846 }
847
848 timeout_ms
849}
850
851fn enforce_output_size_limit(
852 descriptor: &ToolDescriptor,
853 result: &ToolResult,
854 config: &ToolRuntimeConfig,
855) -> Result<(), ToolError> {
856 let output_size_limit = descriptor
857 .output_size_limit_bytes
858 .unwrap_or(config.default_output_size_limit_bytes);
859 let output_json = serde_json::to_vec(result).unwrap_or_default();
860
861 if output_json.len() > output_size_limit {
862 return Err(ToolError::new(
863 ToolErrorClass::OutputTooLarge,
864 format!(
865 "tool {} output exceeded {} bytes",
866 descriptor.name, output_size_limit
867 ),
868 ));
869 }
870
871 Ok(())
872}
873
874fn digest_json(value: &serde_json::Value) -> ContentDigest {
875 let mut builder = DigestBuilder::new();
876 if let Err(e) = builder.update_json(value) {
877 tracing::warn!(error = %e, "digest_json: update_json failed; finalizing partial digest");
878 }
879 builder.finalize()
880}
881
882fn policy_hash(descriptor: &ToolDescriptor) -> ContentDigest {
883 let payload = serde_json::json!({
884 "read_only": descriptor.read_only,
885 "side_effect_class": descriptor.side_effect_class,
886 "idempotency_class": descriptor.idempotency_class,
887 "approval_kind": descriptor.approval_kind,
888 "exposure_mode": descriptor.exposure_mode,
889 "receipt_persistence": descriptor.receipt_persistence,
890 });
891 digest_json(&payload)
892}
893
894fn output_digest_or_refs(result: &ToolResult) -> serde_json::Value {
895 match result.mode {
896 crate::ToolOutputMode::ArtifactRefs => serde_json::json!({
897 "artifacts": result.payload,
898 }),
899 _ => serde_json::json!({
900 "digest": digest_json(&result.payload).hex().to_string(),
901 }),
902 }
903}
904
905async fn wait_for_cancel(flag: &AtomicBool) {
906 while !flag.load(Ordering::Relaxed) {
907 tokio::time::sleep(Duration::from_millis(10)).await;
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914 use crate::{
915 CompensatingAction, Tool, ToolApprovalKind, ToolBackendKind, ToolBudgetContext, ToolCall,
916 ToolCtx, ToolDescriptor, ToolErrorClass, ToolExposureMode, ToolExposurePolicy,
917 ToolIdempotencyClass, ToolOriginKind, ToolOutputMode, ToolPlannerStage,
918 ToolReceiptPersistence, ToolResult, ToolSideEffectClass,
919 };
920 use async_trait::async_trait;
921 use semantic_memory_forge::FORGE_TOOL_RECEIPT_V2_SCHEMA;
922 use serde_json::json;
923 use stack_ids::{AttemptId, TraceCtx, TrialId};
924 use std::sync::atomic::AtomicBool;
925
926 #[derive(Clone)]
927 struct EchoTool {
928 descriptor: ToolDescriptor,
929 }
930
931 struct SlowTool {
932 descriptor: ToolDescriptor,
933 delay_ms: u64,
934 }
935
936 struct FailingTool {
937 descriptor: ToolDescriptor,
938 }
939
940 #[derive(Clone)]
941 struct TestRollback {
942 called: Arc<AtomicBool>,
943 }
944
945 #[async_trait]
946 impl CompensatingAction for TestRollback {
947 async fn compensate(&self, receipt: &crate::ToolReceipt) -> Result<(), ToolError> {
948 let _ = receipt.replay_link.as_deref();
949 self.called.store(true, Ordering::SeqCst);
950 Ok(())
951 }
952 }
953
954 #[async_trait]
955 impl Tool for FailingTool {
956 fn descriptor(&self) -> &ToolDescriptor {
957 &self.descriptor
958 }
959
960 async fn invoke(&self, _ctx: &ToolCtx, _call: &ToolCall) -> Result<ToolResult, ToolError> {
961 Err(ToolError::new(
962 ToolErrorClass::Execution,
963 "execution failed for rollback coverage",
964 ))
965 }
966 }
967
968 #[async_trait]
969 impl Tool for EchoTool {
970 fn descriptor(&self) -> &ToolDescriptor {
971 &self.descriptor
972 }
973
974 async fn invoke(&self, _ctx: &ToolCtx, call: &ToolCall) -> Result<ToolResult, ToolError> {
975 Ok(ToolResult::json(call.arguments.clone()))
976 }
977 }
978
979 #[async_trait]
980 impl Tool for SlowTool {
981 fn descriptor(&self) -> &ToolDescriptor {
982 &self.descriptor
983 }
984
985 async fn invoke(&self, _ctx: &ToolCtx, _call: &ToolCall) -> Result<ToolResult, ToolError> {
986 tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
987 Ok(ToolResult::json(json!({"message": "slow"})))
988 }
989 }
990
991 struct RecordingSink(std::sync::Mutex<Vec<ToolReceipt>>);
992
993 #[async_trait]
994 impl ToolReceiptSink for RecordingSink {
995 async fn health_check(&self) -> Result<(), ToolError> {
996 Ok(())
997 }
998
999 async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError> {
1000 self.0.lock().unwrap().push(receipt.clone());
1001 Ok(())
1002 }
1003
1004 async fn mark_unresolved(&self, preflight_receipt_id: &str) -> Result<(), ToolError> {
1005 let mut receipts = self.0.lock().unwrap();
1006 let receipt = receipts
1007 .iter_mut()
1008 .find(|receipt| receipt.receipt_id == preflight_receipt_id)
1009 .ok_or_else(|| {
1010 ToolError::new(ToolErrorClass::ReceiptPersistence, "missing preflight")
1011 })?;
1012 receipt.resolution = ToolReceiptResolution::Unresolved;
1013 Ok(())
1014 }
1015 }
1016
1017 fn echo_descriptor() -> ToolDescriptor {
1018 ToolDescriptor {
1019 name: "echo".into(),
1020 version: "1.0.0".into(),
1021 description: Some("Echo the input back".into()),
1022 backend_kind: ToolBackendKind::LocalFunction,
1023 input_schema: json!({
1024 "type": "object",
1025 "required": ["message"],
1026 "properties": {"message": {"type": "string"}},
1027 "additionalProperties": false
1028 }),
1029 output_mode: ToolOutputMode::StructuredJson,
1030 read_only: true,
1031 side_effect_class: ToolSideEffectClass::ReadOnly,
1032 idempotency_class: ToolIdempotencyClass::Idempotent,
1033 approval_kind: ToolApprovalKind::None,
1034 timeout_ms: 1_000,
1035 concurrency_key: None,
1036 cache_ttl_ms: None,
1037 exposure_mode: ToolExposureMode::Auto,
1038 mcp_surface_kind: crate::McpSurfaceKind::Tool,
1039 exposure_policy: ToolExposurePolicy {
1040 max_calls_per_turn: 1,
1041 ..Default::default()
1042 },
1043 receipt_persistence: ToolReceiptPersistence::ForgeRaw,
1044 effect_target: crate::EffectTargetSpec {
1045 aliases: vec!["target".into()],
1046 compound: Vec::new(),
1047 },
1048 output_size_limit_bytes: None,
1049 provider_payload: None,
1050 rollback_contract: None,
1051 }
1052 }
1053
1054 fn tool_ctx() -> ToolCtx {
1055 ToolCtx {
1056 trace_ctx: TraceCtx::generate(),
1057 attempt_id: AttemptId::generate(),
1058 trial_id: TrialId::generate(),
1059 deadline: None,
1060 workload_class: None,
1061 budget_context: None,
1062 scope: None,
1063 dry_run: false,
1064 approval_grant: None,
1065 execution_permit: None,
1066 idempotency_key: None,
1067 caller: "test-host".into(),
1068 planner_stage: ToolPlannerStage::Execution,
1069 parent_receipt_id: None,
1070 family_receipt_id: None,
1071 replay_parent_receipt_id: None,
1072 remote_oracle_lease_id: None,
1073 remote_slice_result_id: None,
1074 attestation_envelope_id: None,
1075 cross_runtime_replay_ticket_id: None,
1076 retry_owner: Some(ToolRetryOwner::LlmPipeline),
1077 }
1078 }
1079
1080 fn execution_permit_for(target_key: &str) -> crate::ToolExecutionPermit {
1081 let mut descriptor = echo_descriptor();
1082 descriptor.name = "submit_patch".into();
1083 descriptor.read_only = false;
1084 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1085 let intent = descriptor
1086 .describe_effect(&json!({"target": target_key, "message": "hello"}))
1087 .unwrap();
1088 crate::ToolExecutionPermit::new(
1089 stack_ids::ExecutionPermitId::generate(),
1090 stack_ids::PolicyDecisionId::generate(),
1091 None,
1092 "runtime-tests",
1093 target_key,
1094 descriptor.method_digest(),
1095 intent.digest(),
1096 Some(Utc::now() + chrono::Duration::minutes(5)),
1097 uuid::Uuid::new_v4().to_string(),
1098 )
1099 }
1100
1101 fn approval_grant_for(
1102 tool_name: &str,
1103 effect_class: crate::ApprovalGrantEffectClass,
1104 namespace: &str,
1105 target_key: Option<&str>,
1106 ) -> crate::ApprovalGrant {
1107 crate::ApprovalGrant::new(
1108 "policy-1",
1109 crate::ApprovalGrantScope {
1110 namespace: namespace.into(),
1111 target_key: target_key.map(str::to_owned),
1112 tool_name: tool_name.into(),
1113 effect_class,
1114 planner_stage: Some(ToolPlannerStage::Execution),
1115 },
1116 vec!["operator".into()],
1117 "2026-03-12T00:00:00Z",
1118 None,
1119 crate::ApprovalGrantCitation {
1120 applicability_context_id: stack_ids::ApplicabilityContextId::new(
1121 "applicability-context-1",
1122 ),
1123 profile_set_id: stack_ids::ProfileSetId::new("profile-set-1"),
1124 composition_receipt_id: stack_ids::CompositionReceiptId::new(
1125 "composition-receipt-1",
1126 ),
1127 effective_constitution_id: stack_ids::EffectiveConstitutionId::new(
1128 "effective-constitution-1",
1129 ),
1130 compiled_obligation_set_id: stack_ids::CompiledObligationSetId::new(
1131 "compiled-obligation-set-1",
1132 ),
1133 },
1134 )
1135 }
1136
1137 #[tokio::test]
1138 async fn runtime_dispatches_and_persists_receipts() {
1139 let mut registry = ToolRegistry::new();
1140 registry.register(EchoTool {
1141 descriptor: echo_descriptor(),
1142 });
1143 let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
1144 let runtime = ToolRuntime::new(registry).with_receipt_sink(sink.clone());
1145 let call = ToolCall::new(
1146 "echo",
1147 "1.0.0",
1148 json!({"message": "hello"}),
1149 ToolOriginKind::Test,
1150 );
1151
1152 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
1153 let value = execution.result.unwrap().payload;
1154 assert_eq!(value["message"], "hello");
1155 assert!(execution.receipt.error_class.is_none());
1156 let receipts = sink.0.lock().unwrap();
1157 assert_eq!(receipts.len(), 2);
1158 assert_eq!(receipts[0].phase, ToolReceiptPhase::Preflight);
1159 assert_eq!(receipts[1].phase, ToolReceiptPhase::Outcome);
1160 }
1161
1162 #[tokio::test]
1163 async fn runtime_returns_structured_unknown_tool_failure() {
1164 let runtime = ToolRuntime::new(ToolRegistry::new());
1165 let call = ToolCall::new("missing", "1.0.0", json!({}), ToolOriginKind::Test);
1166
1167 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
1168 let error = execution.result.unwrap_err();
1169
1170 assert_eq!(error.class, ToolErrorClass::UnknownTool);
1171 assert_eq!(
1172 execution.receipt.error_class,
1173 Some(ToolErrorClass::UnknownTool)
1174 );
1175 }
1176
1177 #[tokio::test]
1178 async fn runtime_returns_structured_timeout_failure() {
1179 let mut registry = ToolRegistry::new();
1180 let mut descriptor = echo_descriptor();
1181 descriptor.name = "slow".into();
1182 descriptor.timeout_ms = 1;
1183 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1184 registry.register(SlowTool {
1185 descriptor,
1186 delay_ms: 20,
1187 });
1188 let runtime = ToolRuntime::new(registry);
1189 let call = ToolCall::new(
1190 "slow",
1191 "1.0.0",
1192 json!({"message": "slow"}),
1193 ToolOriginKind::Test,
1194 );
1195
1196 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
1197 let error = execution.result.unwrap_err();
1198
1199 assert_eq!(error.class, ToolErrorClass::Timeout);
1200 assert_eq!(execution.receipt.error_class, Some(ToolErrorClass::Timeout));
1201 }
1202
1203 #[tokio::test]
1204 async fn runtime_returns_structured_cancelled_failure() {
1205 let mut registry = ToolRegistry::new();
1206 let mut descriptor = echo_descriptor();
1207 descriptor.name = "slow".into();
1208 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1209 registry.register(SlowTool {
1210 descriptor,
1211 delay_ms: 20,
1212 });
1213 let runtime = ToolRuntime::new(registry);
1214 let call = ToolCall::new(
1215 "slow",
1216 "1.0.0",
1217 json!({"message": "slow"}),
1218 ToolOriginKind::Test,
1219 );
1220 let cancel = AtomicBool::new(true);
1221
1222 let execution = runtime
1223 .execute(&tool_ctx(), &call, None, Some(&cancel))
1224 .await;
1225 let error = execution.result.unwrap_err();
1226
1227 assert_eq!(error.class, ToolErrorClass::Cancelled);
1228 assert_eq!(
1229 execution.receipt.error_class,
1230 Some(ToolErrorClass::Cancelled)
1231 );
1232 }
1233
1234 #[tokio::test]
1235 async fn runtime_returns_structured_receipt_persistence_failure() {
1236 let mut registry = ToolRegistry::new();
1237 registry.register(EchoTool {
1238 descriptor: echo_descriptor(),
1239 });
1240 let runtime = ToolRuntime::new(registry);
1241 let call = ToolCall::new(
1242 "echo",
1243 "1.0.0",
1244 json!({"message": "hello"}),
1245 ToolOriginKind::Test,
1246 );
1247
1248 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
1249 let error = execution.result.unwrap_err();
1250
1251 assert_eq!(error.class, ToolErrorClass::ReceiptPersistence);
1252 assert_eq!(
1253 execution.receipt.error_class,
1254 Some(ToolErrorClass::ReceiptPersistence)
1255 );
1256 }
1257
1258 #[tokio::test]
1259 async fn runtime_receipt_carries_budget_and_lineage_context() {
1260 let mut registry = ToolRegistry::new();
1261 registry.register(EchoTool {
1262 descriptor: echo_descriptor(),
1263 });
1264 let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
1265 let runtime = ToolRuntime::new(registry).with_receipt_sink(sink.clone());
1266 let call = ToolCall::new(
1267 "echo",
1268 "1.0.0",
1269 json!({"message": "lineage"}),
1270 ToolOriginKind::Test,
1271 );
1272 let mut ctx = tool_ctx();
1273 ctx.deadline = Some("2026-03-12T00:00:10Z".into());
1274 ctx.workload_class = Some("verification".into());
1275 ctx.budget_context = Some(ToolBudgetContext {
1276 budget_kind: Some("control_plane".into()),
1277 max_steps: Some(3),
1278 time_budget_ms: Some(750),
1279 cost_budget_units: Some(12),
1280 });
1281 ctx.parent_receipt_id = Some("dispatch-1".into());
1282 ctx.family_receipt_id = Some("attempt-family-1".into());
1283 ctx.replay_parent_receipt_id = Some("retry-0".into());
1284 ctx.remote_oracle_lease_id = Some(stack_ids::RemoteOracleLeaseId::new("lease-1"));
1285 ctx.remote_slice_result_id = Some(stack_ids::RemoteSliceResultId::new("result-1"));
1286 ctx.attestation_envelope_id = Some(stack_ids::AttestationEnvelopeId::new("attestation-1"));
1287 ctx.cross_runtime_replay_ticket_id = Some(stack_ids::CrossRuntimeReplayTicketId::new(
1288 "cross-runtime-replay-ticket-1",
1289 ));
1290 ctx.planner_stage = ToolPlannerStage::Audit;
1291
1292 let execution = runtime.execute(&ctx, &call, None, None).await;
1293 assert!(execution.result.is_ok());
1294 assert_eq!(
1295 execution.receipt.deadline.as_deref(),
1296 Some("2026-03-12T00:00:10Z")
1297 );
1298 assert_eq!(
1299 execution.receipt.workload_class.as_deref(),
1300 Some("verification")
1301 );
1302 assert_eq!(
1303 execution
1304 .receipt
1305 .budget_context
1306 .as_ref()
1307 .and_then(|budget| budget.max_steps),
1308 Some(3)
1309 );
1310 assert_eq!(
1311 execution.receipt.parent_receipt_id.as_deref(),
1312 Some("dispatch-1")
1313 );
1314 assert_eq!(
1315 execution.receipt.family_receipt_id.as_deref(),
1316 Some("attempt-family-1")
1317 );
1318 assert_eq!(
1319 execution.receipt.replay_parent_receipt_id.as_deref(),
1320 Some("retry-0")
1321 );
1322 assert_eq!(
1323 execution
1324 .receipt
1325 .remote_oracle_lease_id
1326 .as_ref()
1327 .map(|id| id.as_str()),
1328 Some("remote-oracle-lease:lease-1")
1329 );
1330 assert_eq!(
1331 execution
1332 .receipt
1333 .remote_slice_result_id
1334 .as_ref()
1335 .map(|id| id.as_str()),
1336 Some("remote-slice-result:result-1")
1337 );
1338 assert_eq!(
1339 execution
1340 .receipt
1341 .attestation_envelope_id
1342 .as_ref()
1343 .map(|id| id.as_str()),
1344 Some("attestation-envelope:attestation-1")
1345 );
1346 assert_eq!(
1347 execution
1348 .receipt
1349 .cross_runtime_replay_ticket_id
1350 .as_ref()
1351 .map(|id| id.as_str()),
1352 Some("cross-runtime-replay-ticket:cross-runtime-replay-ticket-1")
1353 );
1354 assert_eq!(execution.receipt.planner_stage, ToolPlannerStage::Audit);
1355 assert_eq!(sink.0.lock().unwrap().len(), 2);
1356 }
1357
1358 #[tokio::test]
1359 async fn runtime_receipt_normalizes_into_canonical_forge_receipt() {
1360 let mut registry = ToolRegistry::new();
1361 registry.register(EchoTool {
1362 descriptor: echo_descriptor(),
1363 });
1364 let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
1365 let runtime = ToolRuntime::new(registry).with_receipt_sink(sink);
1366 let call = ToolCall::new(
1367 "echo",
1368 "1.0.0",
1369 json!({"message": "canonical"}),
1370 ToolOriginKind::Test,
1371 );
1372 let mut ctx = tool_ctx();
1373 ctx.deadline = Some("2026-03-12T00:00:10Z".into());
1374 ctx.workload_class = Some("verification".into());
1375 ctx.budget_context = Some(ToolBudgetContext {
1376 budget_kind: Some("control_plane".into()),
1377 max_steps: Some(3),
1378 time_budget_ms: Some(750),
1379 cost_budget_units: Some(12),
1380 });
1381 ctx.parent_receipt_id = Some("dispatch-1".into());
1382 ctx.family_receipt_id = Some("attempt-family-1".into());
1383 ctx.replay_parent_receipt_id = Some("retry-0".into());
1384
1385 let execution = runtime.execute(&ctx, &call, None, None).await;
1386 let receipt = execution.receipt;
1387 let forge_receipt = receipt.to_forge_tool_receipt_v2(json!({
1388 "origin_kind": "test",
1389 "payload_kind": "echo",
1390 }));
1391
1392 assert_eq!(forge_receipt.schema_version, FORGE_TOOL_RECEIPT_V2_SCHEMA);
1393 assert_eq!(forge_receipt.receipt_id, receipt.receipt_id);
1394 assert_eq!(forge_receipt.trace_ctx, receipt.trace_ctx);
1395 assert_eq!(forge_receipt.attempt_id, receipt.attempt_id);
1396 assert_eq!(forge_receipt.trial_id, receipt.trial_id);
1397 assert_eq!(forge_receipt.backend_kind, "local_function");
1398 assert_eq!(forge_receipt.approval_state, "not_required");
1399 assert_eq!(forge_receipt.planner_stage, "execution");
1400 assert_eq!(forge_receipt.retry_owner, "llm_pipeline");
1401 assert_eq!(
1402 forge_receipt.parent_receipt_id.as_deref(),
1403 Some("dispatch-1")
1404 );
1405 assert_eq!(
1406 forge_receipt.family_receipt_id.as_deref(),
1407 Some("attempt-family-1")
1408 );
1409
1410 let reparsed: semantic_memory_forge::ForgeToolReceiptV2 =
1411 serde_json::from_str(&serde_json::to_string(&forge_receipt).unwrap()).unwrap();
1412 assert_eq!(reparsed.receipt_id, forge_receipt.receipt_id);
1413 assert_eq!(reparsed.trace_ctx, forge_receipt.trace_ctx);
1414 assert_eq!(reparsed.raw_payload["payload_kind"], "echo");
1415 }
1416
1417 #[tokio::test]
1418 async fn runtime_requires_execution_permit_for_write_tools() {
1419 let mut registry = ToolRegistry::new();
1420 let mut descriptor = echo_descriptor();
1421 descriptor.name = "submit_patch".into();
1422 descriptor.read_only = false;
1423 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1424 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1425 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1426 descriptor.input_schema = json!({
1427 "type": "object",
1428 "required": ["target", "message"],
1429 "properties": {
1430 "target": {"type": "string"},
1431 "message": {"type": "string"}
1432 },
1433 "additionalProperties": false
1434 });
1435 registry.register(EchoTool { descriptor });
1436 let runtime = ToolRuntime::new(registry);
1437 let call = ToolCall::new(
1438 "submit_patch",
1439 "1.0.0",
1440 json!({"target": "src/lib.rs", "message": "hello"}),
1441 ToolOriginKind::Test,
1442 );
1443
1444 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
1445 let error = execution.result.unwrap_err();
1446
1447 assert_eq!(error.class, ToolErrorClass::ApprovalRequired);
1448 }
1449
1450 #[tokio::test]
1451 async fn runtime_requires_typed_grant_for_write_tools_even_with_permit() {
1452 let mut registry = ToolRegistry::new();
1453 let mut descriptor = echo_descriptor();
1454 descriptor.name = "submit_patch".into();
1455 descriptor.read_only = false;
1456 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1457 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1458 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1459 descriptor.input_schema = json!({
1460 "type": "object",
1461 "required": ["target", "message"],
1462 "properties": {
1463 "target": {"type": "string"},
1464 "message": {"type": "string"}
1465 },
1466 "additionalProperties": false
1467 });
1468 registry.register(EchoTool { descriptor });
1469 let runtime = ToolRuntime::new(registry);
1470 let call = ToolCall::new(
1471 "submit_patch",
1472 "1.0.0",
1473 json!({"target": "src/lib.rs", "message": "hello"}),
1474 ToolOriginKind::Test,
1475 );
1476 let mut ctx = tool_ctx();
1477 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1478 let permit = execution_permit_for("src/lib.rs");
1479
1480 let execution = runtime
1481 .execute(&ctx, &call, Some(Arc::new(permit)), None)
1482 .await;
1483 let error = execution.result.unwrap_err();
1484
1485 assert_eq!(error.class, ToolErrorClass::ApprovalRequired);
1486 }
1487
1488 #[tokio::test]
1489 async fn runtime_rejects_out_of_scope_execution_permit_for_write_tools() {
1490 let mut registry = ToolRegistry::new();
1491 let mut descriptor = echo_descriptor();
1492 descriptor.name = "submit_patch".into();
1493 descriptor.read_only = false;
1494 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1495 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1496 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1497 descriptor.input_schema = json!({
1498 "type": "object",
1499 "required": ["target", "message"],
1500 "properties": {
1501 "target": {"type": "string"},
1502 "message": {"type": "string"}
1503 },
1504 "additionalProperties": false
1505 });
1506 registry.register(EchoTool { descriptor });
1507 let runtime = ToolRuntime::new(registry);
1508 let call = ToolCall::new(
1509 "submit_patch",
1510 "1.0.0",
1511 json!({"target": "src/lib.rs", "message": "hello"}),
1512 ToolOriginKind::Test,
1513 );
1514 let mut ctx = tool_ctx();
1515 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1516 let permit = execution_permit_for("other.rs");
1517
1518 let execution = runtime
1519 .execute(&ctx, &call, Some(Arc::new(permit)), None)
1520 .await;
1521 let error = execution.result.unwrap_err();
1522
1523 assert_eq!(error.class, ToolErrorClass::Denied);
1524 }
1525
1526 #[tokio::test]
1527 async fn runtime_rejects_out_of_scope_grant_for_write_tools() {
1528 let mut registry = ToolRegistry::new();
1529 let mut descriptor = echo_descriptor();
1530 descriptor.name = "submit_patch".into();
1531 descriptor.read_only = false;
1532 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1533 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1534 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1535 descriptor.input_schema = json!({
1536 "type": "object",
1537 "required": ["target", "message"],
1538 "properties": {
1539 "target": {"type": "string"},
1540 "message": {"type": "string"}
1541 },
1542 "additionalProperties": false
1543 });
1544 registry.register(EchoTool { descriptor });
1545 let runtime = ToolRuntime::new(registry);
1546 let call = ToolCall::new(
1547 "submit_patch",
1548 "1.0.0",
1549 json!({"target": "src/lib.rs", "message": "hello"}),
1550 ToolOriginKind::Test,
1551 );
1552 let mut ctx = tool_ctx();
1553 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1554 ctx.approval_grant = Some(approval_grant_for(
1555 "submit_patch",
1556 crate::ApprovalGrantEffectClass::Write,
1557 "runtime-tests",
1558 Some("other.rs"),
1559 ));
1560 let permit = execution_permit_for("src/lib.rs");
1561
1562 let execution = runtime
1563 .execute(&ctx, &call, Some(Arc::new(permit)), None)
1564 .await;
1565 let error = execution.result.unwrap_err();
1566
1567 assert_eq!(error.class, ToolErrorClass::Denied);
1568 }
1569
1570 #[tokio::test]
1571 async fn runtime_rejects_expired_grant_for_write_tools() {
1572 let mut registry = ToolRegistry::new();
1573 let mut descriptor = echo_descriptor();
1574 descriptor.name = "submit_patch".into();
1575 descriptor.read_only = false;
1576 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1577 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1578 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1579 descriptor.input_schema = json!({
1580 "type": "object",
1581 "required": ["target", "message"],
1582 "properties": {
1583 "target": {"type": "string"},
1584 "message": {"type": "string"}
1585 },
1586 "additionalProperties": false
1587 });
1588 registry.register(EchoTool { descriptor });
1589 let runtime = ToolRuntime::new(registry);
1590 let call = ToolCall::new(
1591 "submit_patch",
1592 "1.0.0",
1593 json!({"target": "src/lib.rs", "message": "hello"}),
1594 ToolOriginKind::Test,
1595 );
1596 let mut ctx = tool_ctx();
1597 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1598 let mut grant = approval_grant_for(
1599 "submit_patch",
1600 crate::ApprovalGrantEffectClass::Write,
1601 "runtime-tests",
1602 Some("src/lib.rs"),
1603 );
1604 grant.expires_at = Some("2000-01-01T00:00:00Z".into());
1605 ctx.approval_grant = Some(grant);
1606 let permit = execution_permit_for("src/lib.rs");
1607
1608 let execution = runtime
1609 .execute(&ctx, &call, Some(Arc::new(permit)), None)
1610 .await;
1611 let error = execution.result.unwrap_err();
1612
1613 assert_eq!(error.class, ToolErrorClass::Denied);
1614 }
1615
1616 #[tokio::test]
1617 async fn runtime_rejects_mismatched_effect_grant_for_write_tools() {
1618 let mut registry = ToolRegistry::new();
1619 let mut descriptor = echo_descriptor();
1620 descriptor.name = "submit_patch".into();
1621 descriptor.read_only = false;
1622 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1623 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1624 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1625 descriptor.input_schema = json!({
1626 "type": "object",
1627 "required": ["target", "message"],
1628 "properties": {
1629 "target": {"type": "string"},
1630 "message": {"type": "string"}
1631 },
1632 "additionalProperties": false
1633 });
1634 registry.register(EchoTool { descriptor });
1635 let runtime = ToolRuntime::new(registry);
1636 let call = ToolCall::new(
1637 "submit_patch",
1638 "1.0.0",
1639 json!({"target": "src/lib.rs", "message": "hello"}),
1640 ToolOriginKind::Test,
1641 );
1642 let mut ctx = tool_ctx();
1643 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1644 ctx.approval_grant = Some(approval_grant_for(
1645 "submit_patch",
1646 crate::ApprovalGrantEffectClass::Analysis,
1647 "runtime-tests",
1648 Some("src/lib.rs"),
1649 ));
1650 let permit = execution_permit_for("src/lib.rs");
1651
1652 let execution = runtime
1653 .execute(&ctx, &call, Some(Arc::new(permit)), None)
1654 .await;
1655 let error = execution.result.unwrap_err();
1656
1657 assert_eq!(error.class, ToolErrorClass::Denied);
1658 }
1659
1660 #[tokio::test]
1661 async fn runtime_accepts_matching_typed_grant_and_permit_for_write_tools() {
1662 let mut registry = ToolRegistry::new();
1663 let mut descriptor = echo_descriptor();
1664 descriptor.name = "submit_patch".into();
1665 descriptor.read_only = false;
1666 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1667 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1668 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1669 descriptor.input_schema = json!({
1670 "type": "object",
1671 "required": ["target", "message"],
1672 "properties": {
1673 "target": {"type": "string"},
1674 "message": {"type": "string"}
1675 },
1676 "additionalProperties": false
1677 });
1678 registry.register(EchoTool { descriptor });
1679 let runtime = ToolRuntime::new(registry);
1680 let call = ToolCall::new(
1681 "submit_patch",
1682 "1.0.0",
1683 json!({"target": "src/lib.rs", "message": "hello"}),
1684 ToolOriginKind::Test,
1685 );
1686 let mut ctx = tool_ctx();
1687 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1688 ctx.approval_grant = Some(approval_grant_for(
1689 "submit_patch",
1690 crate::ApprovalGrantEffectClass::Write,
1691 "runtime-tests",
1692 Some("src/lib.rs"),
1693 ));
1694 let permit = execution_permit_for("src/lib.rs");
1695
1696 let execution = runtime
1697 .execute(&ctx, &call, Some(Arc::new(permit)), None)
1698 .await;
1699 assert!(execution.result.is_ok());
1700 assert_eq!(
1701 execution.receipt.approval_state,
1702 ToolApprovalState::Approved
1703 );
1704 assert!(execution.receipt.verify_authority_lineage().is_ok());
1705 let mut incomplete = execution.receipt.clone();
1706 incomplete.authority_lineage.clear();
1707 assert!(incomplete.verify_authority_lineage().is_err());
1708 }
1709
1710 #[tokio::test]
1711 async fn runtime_calls_compensation_contract_on_failure() {
1712 let mut registry = ToolRegistry::new();
1713 let called = Arc::new(AtomicBool::new(false));
1714 let mut descriptor = echo_descriptor();
1715 descriptor.name = "failing_tool".into();
1716 descriptor.rollback_contract = Some(Arc::new(TestRollback {
1717 called: called.clone(),
1718 }));
1719 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1721 registry.register(FailingTool { descriptor });
1722 let runtime = ToolRuntime::new(registry);
1723
1724 let execution = runtime
1725 .execute(
1726 &tool_ctx(),
1727 &ToolCall::new(
1728 "failing_tool",
1729 "1.0.0",
1730 json!({"message": "will fail"}),
1731 ToolOriginKind::Test,
1732 ),
1733 None,
1734 None,
1735 )
1736 .await;
1737
1738 assert!(execution.result.is_err());
1739 assert_eq!(
1740 execution.receipt.error_class,
1741 Some(ToolErrorClass::Execution)
1742 );
1743 assert!(called.load(Ordering::SeqCst));
1744 }
1745
1746 #[tokio::test]
1747 async fn runtime_does_not_call_compensation_contract_without_contract() {
1748 let mut registry = ToolRegistry::new();
1749 let called = Arc::new(AtomicBool::new(false));
1750 let mut descriptor = echo_descriptor();
1751 descriptor.name = "failing_tool".into();
1752 descriptor.rollback_contract = None;
1753 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1755 registry.register(FailingTool { descriptor });
1756 let runtime = ToolRuntime::new(registry);
1757
1758 let execution = runtime
1759 .execute(
1760 &tool_ctx(),
1761 &ToolCall::new(
1762 "failing_tool",
1763 "1.0.0",
1764 json!({"message": "will fail"}),
1765 ToolOriginKind::Test,
1766 ),
1767 None,
1768 None,
1769 )
1770 .await;
1771
1772 assert!(execution.result.is_err());
1773 assert_eq!(
1774 execution.receipt.error_class,
1775 Some(ToolErrorClass::Execution)
1776 );
1777 assert!(!called.load(Ordering::SeqCst));
1778 }
1779}