Skip to main content

llm_tool_runtime/
runtime.rs

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    /// Returns a clone of the receipts persisted into this in-memory sink.
189    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    /// Creates a runtime around the supplied registry using default policy and limits.
260    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    /// Overrides runtime-wide timeout and output-size defaults.
270    pub fn with_config(mut self, config: ToolRuntimeConfig) -> Self {
271        self.config = config;
272        self
273    }
274
275    /// Replaces the approval policy used before dispatching tools.
276    pub fn with_approval_policy(mut self, approval_policy: Arc<dyn ApprovalPolicy>) -> Self {
277        self.approval_policy = approval_policy;
278        self
279    }
280
281    /// Installs the durable receipt sink used for ForgeRaw receipt persistence.
282    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    /// Borrows the underlying tool registry.
288    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        // AUTH-008: durable receipt persistence requires a sink
1720        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        // AUTH-008: durable receipt persistence requires a sink
1754        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}