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, ToolReceiptPersistence, 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, 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    ctx: &crate::ToolCtx,
84    call: &ToolCall,
85) -> bool {
86    let Some(scope) = ctx.scope.as_ref() else {
87        return false;
88    };
89    if permit.scope().namespace() != scope.namespace {
90        return false;
91    }
92    let Some(target_key) = extract_target_key(call) else {
93        return false;
94    };
95    permit.scope().target_key() == target_key
96}
97
98fn approval_grant_authorizes(
99    grant: &crate::ApprovalGrant,
100    descriptor: &ToolDescriptor,
101    ctx: &crate::ToolCtx,
102    call: &ToolCall,
103    evaluated_at: &str,
104) -> bool {
105    if grant.approver_lineage.is_empty() {
106        return false;
107    }
108    if grant
109        .expires_at
110        .as_ref()
111        .is_some_and(|expires_at| expires_at.as_str() <= evaluated_at)
112    {
113        return false;
114    }
115    if grant.scope.tool_name != descriptor.name {
116        return false;
117    }
118    if grant
119        .scope
120        .planner_stage
121        .as_ref()
122        .is_some_and(|planner_stage| planner_stage != &ctx.planner_stage)
123    {
124        return false;
125    }
126
127    let Some(required_effect_class) =
128        ApprovalGrantEffectClass::for_tool_side_effect_class(&descriptor.side_effect_class)
129    else {
130        return false;
131    };
132    if grant.scope.effect_class != required_effect_class {
133        return false;
134    }
135
136    let Some(scope) = ctx.scope.as_ref() else {
137        return false;
138    };
139    if grant.scope.namespace != scope.namespace {
140        return false;
141    }
142    if grant
143        .scope
144        .target_key
145        .as_ref()
146        .is_some_and(|target_key| Some(target_key.as_str()) != extract_target_key(call))
147    {
148        return false;
149    }
150    if let Some(permit) = ctx.execution_permit.as_ref() {
151        if grant
152            .decision_id
153            .as_ref()
154            .is_some_and(|decision_id| decision_id != permit.decision_id())
155        {
156            return false;
157        }
158        if grant
159            .approval_record_id
160            .as_ref()
161            .is_some_and(|approval_record_id| {
162                permit.approval_record_id() != Some(approval_record_id)
163            })
164        {
165            return false;
166        }
167    }
168
169    true
170}
171
172fn extract_target_key(call: &ToolCall) -> Option<&str> {
173    ["target", "artifact_id", "candidate_id", "task_id"]
174        .into_iter()
175        .find_map(|key| call.arguments.get(key).and_then(|value| value.as_str()))
176}
177
178#[async_trait]
179pub trait ToolReceiptSink: Send + Sync {
180    async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError>;
181}
182
183#[derive(Default)]
184pub struct InMemoryReceiptSink {
185    receipts: std::sync::Mutex<Vec<ToolReceipt>>,
186}
187
188impl InMemoryReceiptSink {
189    /// Returns a clone of the receipts persisted into this in-memory sink.
190    pub fn receipts(&self) -> Vec<ToolReceipt> {
191        self.receipts
192            .lock()
193            .unwrap_or_else(|poisoned| poisoned.into_inner())
194            .clone()
195    }
196}
197
198#[async_trait]
199impl ToolReceiptSink for InMemoryReceiptSink {
200    async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError> {
201        self.receipts
202            .lock()
203            .unwrap_or_else(|poisoned| poisoned.into_inner())
204            .push(receipt.clone());
205        Ok(())
206    }
207}
208
209#[derive(Debug, Clone)]
210pub struct ToolRuntimeConfig {
211    pub default_timeout_ms: u64,
212    pub default_output_size_limit_bytes: usize,
213}
214
215impl Default for ToolRuntimeConfig {
216    fn default() -> Self {
217        Self {
218            default_timeout_ms: 30_000,
219            default_output_size_limit_bytes: 256 * 1024,
220        }
221    }
222}
223
224#[derive(Debug, Clone)]
225pub struct ToolExecution {
226    pub result: Result<ToolResult, ToolError>,
227    pub receipt: ToolReceipt,
228}
229
230pub struct ToolRuntime {
231    registry: ToolRegistry,
232    approval_policy: Arc<dyn ApprovalPolicy>,
233    receipt_sink: Option<Arc<dyn ToolReceiptSink>>,
234    config: ToolRuntimeConfig,
235}
236
237impl ToolRuntime {
238    /// Creates a runtime around the supplied registry using default policy and limits.
239    pub fn new(registry: ToolRegistry) -> Self {
240        Self {
241            registry,
242            approval_policy: Arc::new(StaticApprovalPolicy),
243            receipt_sink: None,
244            config: ToolRuntimeConfig::default(),
245        }
246    }
247
248    /// Overrides runtime-wide timeout and output-size defaults.
249    pub fn with_config(mut self, config: ToolRuntimeConfig) -> Self {
250        self.config = config;
251        self
252    }
253
254    /// Replaces the approval policy used before dispatching tools.
255    pub fn with_approval_policy(mut self, approval_policy: Arc<dyn ApprovalPolicy>) -> Self {
256        self.approval_policy = approval_policy;
257        self
258    }
259
260    /// Installs the durable receipt sink used for ForgeRaw receipt persistence.
261    pub fn with_receipt_sink(mut self, receipt_sink: Arc<dyn ToolReceiptSink>) -> Self {
262        self.receipt_sink = Some(receipt_sink);
263        self
264    }
265
266    /// Borrows the underlying tool registry.
267    pub fn registry(&self) -> &ToolRegistry {
268        &self.registry
269    }
270
271    pub async fn execute(
272        &self,
273        ctx: &crate::ToolCtx,
274        call: &ToolCall,
275        permit: Option<&ToolExecutionPermit>,
276        cancel: Option<&AtomicBool>,
277    ) -> ToolExecution {
278        let started_at = Utc::now();
279        let mut execution_ctx = ctx.clone();
280        execution_ctx.execution_permit = permit.cloned();
281        let descriptor = match self.registry.get(&call.descriptor_name) {
282            Some(tool) => tool.descriptor().clone(),
283            None => {
284                let error = ToolError::new(
285                    ToolErrorClass::UnknownTool,
286                    format!("unknown tool {}", call.descriptor_name),
287                );
288                let receipt = self.failure_receipt(ctx, call, started_at, error.clone(), None);
289                return ToolExecution {
290                    result: Err(error),
291                    receipt,
292                };
293            }
294        };
295
296        if descriptor.version != call.descriptor_version {
297            let error = ToolError::new(
298                ToolErrorClass::ProviderContract,
299                format!(
300                    "tool version mismatch for {}: expected {}, got {}",
301                    descriptor.name, descriptor.version, call.descriptor_version
302                ),
303            );
304            let receipt =
305                self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
306            return ToolExecution {
307                result: Err(error),
308                receipt,
309            };
310        }
311
312        if let Err(error) =
313            validate_arguments_against_schema(&descriptor.input_schema, &call.arguments)
314        {
315            let receipt =
316                self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
317            return ToolExecution {
318                result: Err(error),
319                receipt,
320            };
321        }
322
323        let approval_state = match self
324            .approval_policy
325            .evaluate(&descriptor, &execution_ctx, call)
326            .await
327        {
328            Ok(state) => state,
329            Err(error) => {
330                let receipt =
331                    self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
332                return ToolExecution {
333                    result: Err(error),
334                    receipt,
335                };
336            }
337        };
338
339        if matches!(approval_state, ToolApprovalState::Denied) {
340            let error = ToolError::new(
341                ToolErrorClass::Denied,
342                format!("tool {} was denied by approval policy", descriptor.name),
343            );
344            let receipt =
345                self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
346            return ToolExecution {
347                result: Err(error),
348                receipt,
349            };
350        }
351
352        let Some(tool) = self.registry.get(&call.descriptor_name) else {
353            let error = ToolError::new(
354                ToolErrorClass::UnknownTool,
355                format!("tool {} disappeared before execution", call.descriptor_name),
356            );
357            let receipt =
358                self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
359            return ToolExecution {
360                result: Err(error),
361                receipt,
362            };
363        };
364        let timeout_ms = resolve_timeout_ms(&descriptor, ctx, self.config.default_timeout_ms);
365        let fut = async {
366            if let Some(flag) = cancel {
367                if flag.load(Ordering::Relaxed) {
368                    return Err(ToolError::new(ToolErrorClass::Cancelled, "tool cancelled"));
369                }
370            }
371
372            let execute = tool.invoke(&execution_ctx, call);
373            match cancel {
374                Some(flag) => {
375                    tokio::select! {
376                        result = execute => result,
377                        _ = wait_for_cancel(flag) => Err(ToolError::new(ToolErrorClass::Cancelled, "tool cancelled")),
378                    }
379                }
380                None => execute.await,
381            }
382        };
383
384        let result = match tokio::time::timeout(Duration::from_millis(timeout_ms), fut).await {
385            Ok(inner) => inner,
386            Err(_) => Err(ToolError::new(
387                ToolErrorClass::Timeout,
388                format!("tool {} timed out after {}ms", descriptor.name, timeout_ms),
389            )),
390        };
391
392        let result = match result {
393            Ok(tool_result) => {
394                if let Err(error) =
395                    enforce_output_size_limit(&descriptor, &tool_result, &self.config)
396                {
397                    Err(error)
398                } else {
399                    Ok(tool_result)
400                }
401            }
402            Err(error) => Err(error),
403        };
404
405        let receipt = match &result {
406            Ok(tool_result) => self.success_receipt(
407                ctx,
408                call,
409                &descriptor,
410                started_at,
411                tool_result,
412                approval_state.clone(),
413            ),
414            Err(error) => self.failure_receipt_with_approval(
415                ctx,
416                call,
417                started_at,
418                error.clone(),
419                &descriptor,
420                approval_state.clone(),
421            ),
422        };
423
424        if descriptor.receipt_persistence == ToolReceiptPersistence::ForgeRaw {
425            if let Some(sink) = &self.receipt_sink {
426                if let Err(error) = sink.persist(&receipt).await {
427                    let receipt = self.failure_receipt_with_approval(
428                        ctx,
429                        call,
430                        started_at,
431                        error.clone(),
432                        &descriptor,
433                        approval_state,
434                    );
435                    return ToolExecution {
436                        result: Err(error),
437                        receipt,
438                    };
439                }
440            } else {
441                let error = ToolError::new(
442                    ToolErrorClass::ReceiptPersistence,
443                    format!("tool {} requires a durable receipt sink", descriptor.name),
444                );
445                let receipt = self.failure_receipt_with_approval(
446                    ctx,
447                    call,
448                    started_at,
449                    error.clone(),
450                    &descriptor,
451                    approval_state,
452                );
453                return ToolExecution {
454                    result: Err(error),
455                    receipt,
456                };
457            }
458        }
459
460        ToolExecution { result, receipt }
461    }
462
463    fn success_receipt(
464        &self,
465        ctx: &crate::ToolCtx,
466        call: &ToolCall,
467        descriptor: &ToolDescriptor,
468        started_at: DateTime<Utc>,
469        result: &ToolResult,
470        approval_state: ToolApprovalState,
471    ) -> ToolReceipt {
472        ToolReceipt {
473            receipt_id: uuid::Uuid::new_v4().to_string(),
474            tool_name: descriptor.name.clone(),
475            tool_version: descriptor.version.clone(),
476            backend_kind: descriptor.backend_kind.clone(),
477            input_digest: digest_json(&call.arguments),
478            output_digest_or_refs: output_digest_or_refs(result),
479            policy_hash: policy_hash(descriptor),
480            approval_state,
481            host_identity: ctx.caller.clone(),
482            started_at: started_at.to_rfc3339(),
483            finished_at: Utc::now().to_rfc3339(),
484            trace_ctx: ctx.trace_ctx.clone(),
485            attempt_id: ctx.attempt_id.clone(),
486            trial_id: ctx.trial_id.clone(),
487            planner_stage: ctx.planner_stage.clone(),
488            deadline: ctx.deadline.clone(),
489            workload_class: ctx.workload_class.clone(),
490            budget_context: ctx.budget_context.clone(),
491            parent_receipt_id: ctx.parent_receipt_id.clone(),
492            family_receipt_id: ctx.family_receipt_id.clone(),
493            replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
494            remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
495            remote_slice_result_id: ctx.remote_slice_result_id.clone(),
496            attestation_envelope_id: ctx.attestation_envelope_id.clone(),
497            cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
498            error_class: None,
499            retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
500            replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
501            tool_run_id: call.tool_run_id.clone(),
502            provider_call_id: call.provider_call_id.clone(),
503        }
504    }
505
506    fn failure_receipt(
507        &self,
508        ctx: &crate::ToolCtx,
509        call: &ToolCall,
510        started_at: DateTime<Utc>,
511        error: ToolError,
512        descriptor: Option<&ToolDescriptor>,
513    ) -> ToolReceipt {
514        let descriptor_backend_kind = descriptor
515            .map(|desc| desc.backend_kind.clone())
516            .unwrap_or(crate::ToolBackendKind::LocalFunction);
517        let tool_version = descriptor
518            .map(|desc| desc.version.clone())
519            .unwrap_or_else(|| call.descriptor_version.clone());
520        let policy_hash = descriptor
521            .map(policy_hash)
522            .unwrap_or_else(|| ContentDigest::compute(b"missing_policy"));
523
524        ToolReceipt {
525            receipt_id: uuid::Uuid::new_v4().to_string(),
526            tool_name: call.descriptor_name.clone(),
527            tool_version,
528            backend_kind: descriptor_backend_kind,
529            input_digest: digest_json(&call.arguments),
530            output_digest_or_refs: serde_json::json!({"error": error.message}),
531            policy_hash,
532            approval_state: ToolApprovalState::Denied,
533            host_identity: ctx.caller.clone(),
534            started_at: started_at.to_rfc3339(),
535            finished_at: Utc::now().to_rfc3339(),
536            trace_ctx: ctx.trace_ctx.clone(),
537            attempt_id: ctx.attempt_id.clone(),
538            trial_id: ctx.trial_id.clone(),
539            planner_stage: ctx.planner_stage.clone(),
540            deadline: ctx.deadline.clone(),
541            workload_class: ctx.workload_class.clone(),
542            budget_context: ctx.budget_context.clone(),
543            parent_receipt_id: ctx.parent_receipt_id.clone(),
544            family_receipt_id: ctx.family_receipt_id.clone(),
545            replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
546            remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
547            remote_slice_result_id: ctx.remote_slice_result_id.clone(),
548            attestation_envelope_id: ctx.attestation_envelope_id.clone(),
549            cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
550            error_class: Some(error.class),
551            retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
552            replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
553            tool_run_id: call.tool_run_id.clone(),
554            provider_call_id: call.provider_call_id.clone(),
555        }
556    }
557
558    fn failure_receipt_with_approval(
559        &self,
560        ctx: &crate::ToolCtx,
561        call: &ToolCall,
562        started_at: DateTime<Utc>,
563        error: ToolError,
564        descriptor: &ToolDescriptor,
565        approval_state: ToolApprovalState,
566    ) -> ToolReceipt {
567        ToolReceipt {
568            receipt_id: uuid::Uuid::new_v4().to_string(),
569            tool_name: descriptor.name.clone(),
570            tool_version: descriptor.version.clone(),
571            backend_kind: descriptor.backend_kind.clone(),
572            input_digest: digest_json(&call.arguments),
573            output_digest_or_refs: serde_json::json!({"error": error.message}),
574            policy_hash: policy_hash(descriptor),
575            approval_state,
576            host_identity: ctx.caller.clone(),
577            started_at: started_at.to_rfc3339(),
578            finished_at: Utc::now().to_rfc3339(),
579            trace_ctx: ctx.trace_ctx.clone(),
580            attempt_id: ctx.attempt_id.clone(),
581            trial_id: ctx.trial_id.clone(),
582            planner_stage: ctx.planner_stage.clone(),
583            deadline: ctx.deadline.clone(),
584            workload_class: ctx.workload_class.clone(),
585            budget_context: ctx.budget_context.clone(),
586            parent_receipt_id: ctx.parent_receipt_id.clone(),
587            family_receipt_id: ctx.family_receipt_id.clone(),
588            replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
589            remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
590            remote_slice_result_id: ctx.remote_slice_result_id.clone(),
591            attestation_envelope_id: ctx.attestation_envelope_id.clone(),
592            cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
593            error_class: Some(error.class),
594            retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
595            replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
596            tool_run_id: call.tool_run_id.clone(),
597            provider_call_id: call.provider_call_id.clone(),
598        }
599    }
600}
601
602fn resolve_timeout_ms(
603    descriptor: &ToolDescriptor,
604    ctx: &crate::ToolCtx,
605    default_timeout_ms: u64,
606) -> u64 {
607    let mut timeout_ms = if descriptor.timeout_ms == 0 {
608        default_timeout_ms
609    } else {
610        descriptor.timeout_ms
611    };
612
613    if let Some(deadline) = &ctx.deadline {
614        if let Ok(deadline_at) = DateTime::parse_from_rfc3339(deadline) {
615            let remaining = deadline_at
616                .with_timezone(&Utc)
617                .signed_duration_since(Utc::now())
618                .num_milliseconds()
619                .max(1) as u64;
620            timeout_ms = timeout_ms.min(remaining);
621        }
622    }
623
624    timeout_ms
625}
626
627fn enforce_output_size_limit(
628    descriptor: &ToolDescriptor,
629    result: &ToolResult,
630    config: &ToolRuntimeConfig,
631) -> Result<(), ToolError> {
632    let output_size_limit = descriptor
633        .output_size_limit_bytes
634        .unwrap_or(config.default_output_size_limit_bytes);
635    let output_json = serde_json::to_vec(result).unwrap_or_default();
636
637    if output_json.len() > output_size_limit {
638        return Err(ToolError::new(
639            ToolErrorClass::OutputTooLarge,
640            format!(
641                "tool {} output exceeded {} bytes",
642                descriptor.name, output_size_limit
643            ),
644        ));
645    }
646
647    Ok(())
648}
649
650fn digest_json(value: &serde_json::Value) -> ContentDigest {
651    let mut builder = DigestBuilder::new();
652    if let Err(e) = builder.update_json(value) {
653        tracing::warn!(error = %e, "digest_json: update_json failed; finalizing partial digest");
654    }
655    builder.finalize()
656}
657
658fn policy_hash(descriptor: &ToolDescriptor) -> ContentDigest {
659    let payload = serde_json::json!({
660        "read_only": descriptor.read_only,
661        "side_effect_class": descriptor.side_effect_class,
662        "idempotency_class": descriptor.idempotency_class,
663        "approval_kind": descriptor.approval_kind,
664        "exposure_mode": descriptor.exposure_mode,
665        "receipt_persistence": descriptor.receipt_persistence,
666    });
667    digest_json(&payload)
668}
669
670fn output_digest_or_refs(result: &ToolResult) -> serde_json::Value {
671    match result.mode {
672        crate::ToolOutputMode::ArtifactRefs => serde_json::json!({
673            "artifacts": result.payload,
674        }),
675        _ => serde_json::json!({
676            "digest": digest_json(&result.payload).hex().to_string(),
677        }),
678    }
679}
680
681async fn wait_for_cancel(flag: &AtomicBool) {
682    while !flag.load(Ordering::Relaxed) {
683        tokio::time::sleep(Duration::from_millis(10)).await;
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use crate::{
691        Tool, ToolApprovalKind, ToolBackendKind, ToolBudgetContext, ToolCall, ToolCtx,
692        ToolDescriptor, ToolExposureMode, ToolExposurePolicy, ToolIdempotencyClass, ToolOriginKind,
693        ToolOutputMode, ToolPlannerStage, ToolReceiptPersistence, ToolResult, ToolSideEffectClass,
694    };
695    use async_trait::async_trait;
696    use semantic_memory_forge::FORGE_TOOL_RECEIPT_V2_SCHEMA;
697    use serde_json::json;
698    use stack_ids::{AttemptId, TraceCtx, TrialId};
699
700    #[derive(Clone)]
701    struct EchoTool {
702        descriptor: ToolDescriptor,
703    }
704
705    struct SlowTool {
706        descriptor: ToolDescriptor,
707        delay_ms: u64,
708    }
709
710    #[async_trait]
711    impl Tool for EchoTool {
712        fn descriptor(&self) -> &ToolDescriptor {
713            &self.descriptor
714        }
715
716        async fn invoke(&self, _ctx: &ToolCtx, call: &ToolCall) -> Result<ToolResult, ToolError> {
717            Ok(ToolResult::json(call.arguments.clone()))
718        }
719    }
720
721    #[async_trait]
722    impl Tool for SlowTool {
723        fn descriptor(&self) -> &ToolDescriptor {
724            &self.descriptor
725        }
726
727        async fn invoke(&self, _ctx: &ToolCtx, _call: &ToolCall) -> Result<ToolResult, ToolError> {
728            tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
729            Ok(ToolResult::json(json!({"message": "slow"})))
730        }
731    }
732
733    struct RecordingSink(std::sync::Mutex<Vec<ToolReceipt>>);
734
735    #[async_trait]
736    impl ToolReceiptSink for RecordingSink {
737        async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError> {
738            self.0.lock().unwrap().push(receipt.clone());
739            Ok(())
740        }
741    }
742
743    fn echo_descriptor() -> ToolDescriptor {
744        ToolDescriptor {
745            name: "echo".into(),
746            version: "1.0.0".into(),
747            description: Some("Echo the input back".into()),
748            backend_kind: ToolBackendKind::LocalFunction,
749            input_schema: json!({
750                "type": "object",
751                "required": ["message"],
752                "properties": {"message": {"type": "string"}},
753                "additionalProperties": false
754            }),
755            output_mode: ToolOutputMode::StructuredJson,
756            read_only: true,
757            side_effect_class: ToolSideEffectClass::ReadOnly,
758            idempotency_class: ToolIdempotencyClass::Idempotent,
759            approval_kind: ToolApprovalKind::None,
760            timeout_ms: 1_000,
761            concurrency_key: None,
762            cache_ttl_ms: None,
763            exposure_mode: ToolExposureMode::Auto,
764            mcp_surface_kind: crate::McpSurfaceKind::Tool,
765            exposure_policy: ToolExposurePolicy {
766                max_calls_per_turn: 1,
767                ..Default::default()
768            },
769            receipt_persistence: ToolReceiptPersistence::ForgeRaw,
770            output_size_limit_bytes: None,
771            provider_payload: None,
772        }
773    }
774
775    fn tool_ctx() -> ToolCtx {
776        ToolCtx {
777            trace_ctx: TraceCtx::generate(),
778            attempt_id: AttemptId::generate(),
779            trial_id: TrialId::generate(),
780            deadline: None,
781            workload_class: None,
782            budget_context: None,
783            scope: None,
784            dry_run: false,
785            approval_grant: None,
786            execution_permit: None,
787            idempotency_key: None,
788            caller: "test-host".into(),
789            planner_stage: ToolPlannerStage::Execution,
790            parent_receipt_id: None,
791            family_receipt_id: None,
792            replay_parent_receipt_id: None,
793            remote_oracle_lease_id: None,
794            remote_slice_result_id: None,
795            attestation_envelope_id: None,
796            cross_runtime_replay_ticket_id: None,
797            retry_owner: Some(ToolRetryOwner::LlmPipeline),
798        }
799    }
800
801    fn execution_permit_for(target_key: &str) -> crate::ToolExecutionPermit {
802        crate::ToolExecutionPermit::new(
803            stack_ids::ExecutionPermitId::generate(),
804            stack_ids::PolicyDecisionId::generate(),
805            None,
806            "runtime-tests",
807            target_key,
808        )
809    }
810
811    fn approval_grant_for(
812        tool_name: &str,
813        effect_class: crate::ApprovalGrantEffectClass,
814        namespace: &str,
815        target_key: Option<&str>,
816    ) -> crate::ApprovalGrant {
817        crate::ApprovalGrant::new(
818            "policy-1",
819            crate::ApprovalGrantScope {
820                namespace: namespace.into(),
821                target_key: target_key.map(str::to_owned),
822                tool_name: tool_name.into(),
823                effect_class,
824                planner_stage: Some(ToolPlannerStage::Execution),
825            },
826            vec!["operator".into()],
827            "2026-03-12T00:00:00Z",
828            None,
829            crate::ApprovalGrantCitation {
830                applicability_context_id: stack_ids::ApplicabilityContextId::new(
831                    "applicability-context-1",
832                ),
833                profile_set_id: stack_ids::ProfileSetId::new("profile-set-1"),
834                composition_receipt_id: stack_ids::CompositionReceiptId::new(
835                    "composition-receipt-1",
836                ),
837                effective_constitution_id: stack_ids::EffectiveConstitutionId::new(
838                    "effective-constitution-1",
839                ),
840                compiled_obligation_set_id: stack_ids::CompiledObligationSetId::new(
841                    "compiled-obligation-set-1",
842                ),
843            },
844        )
845    }
846
847    #[tokio::test]
848    async fn runtime_dispatches_and_persists_receipts() {
849        let mut registry = ToolRegistry::new();
850        registry.register(EchoTool {
851            descriptor: echo_descriptor(),
852        });
853        let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
854        let runtime = ToolRuntime::new(registry).with_receipt_sink(sink.clone());
855        let call = ToolCall::new(
856            "echo",
857            "1.0.0",
858            json!({"message": "hello"}),
859            ToolOriginKind::Test,
860        );
861
862        let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
863        let value = execution.result.unwrap().payload;
864        assert_eq!(value["message"], "hello");
865        assert!(execution.receipt.error_class.is_none());
866        assert_eq!(sink.0.lock().unwrap().len(), 1);
867    }
868
869    #[tokio::test]
870    async fn runtime_returns_structured_unknown_tool_failure() {
871        let runtime = ToolRuntime::new(ToolRegistry::new());
872        let call = ToolCall::new("missing", "1.0.0", json!({}), ToolOriginKind::Test);
873
874        let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
875        let error = execution.result.unwrap_err();
876
877        assert_eq!(error.class, ToolErrorClass::UnknownTool);
878        assert_eq!(
879            execution.receipt.error_class,
880            Some(ToolErrorClass::UnknownTool)
881        );
882    }
883
884    #[tokio::test]
885    async fn runtime_returns_structured_timeout_failure() {
886        let mut registry = ToolRegistry::new();
887        let mut descriptor = echo_descriptor();
888        descriptor.name = "slow".into();
889        descriptor.timeout_ms = 1;
890        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
891        registry.register(SlowTool {
892            descriptor,
893            delay_ms: 20,
894        });
895        let runtime = ToolRuntime::new(registry);
896        let call = ToolCall::new(
897            "slow",
898            "1.0.0",
899            json!({"message": "slow"}),
900            ToolOriginKind::Test,
901        );
902
903        let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
904        let error = execution.result.unwrap_err();
905
906        assert_eq!(error.class, ToolErrorClass::Timeout);
907        assert_eq!(execution.receipt.error_class, Some(ToolErrorClass::Timeout));
908    }
909
910    #[tokio::test]
911    async fn runtime_returns_structured_cancelled_failure() {
912        let mut registry = ToolRegistry::new();
913        let mut descriptor = echo_descriptor();
914        descriptor.name = "slow".into();
915        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
916        registry.register(SlowTool {
917            descriptor,
918            delay_ms: 20,
919        });
920        let runtime = ToolRuntime::new(registry);
921        let call = ToolCall::new(
922            "slow",
923            "1.0.0",
924            json!({"message": "slow"}),
925            ToolOriginKind::Test,
926        );
927        let cancel = AtomicBool::new(true);
928
929        let execution = runtime
930            .execute(&tool_ctx(), &call, None, Some(&cancel))
931            .await;
932        let error = execution.result.unwrap_err();
933
934        assert_eq!(error.class, ToolErrorClass::Cancelled);
935        assert_eq!(
936            execution.receipt.error_class,
937            Some(ToolErrorClass::Cancelled)
938        );
939    }
940
941    #[tokio::test]
942    async fn runtime_returns_structured_receipt_persistence_failure() {
943        let mut registry = ToolRegistry::new();
944        registry.register(EchoTool {
945            descriptor: echo_descriptor(),
946        });
947        let runtime = ToolRuntime::new(registry);
948        let call = ToolCall::new(
949            "echo",
950            "1.0.0",
951            json!({"message": "hello"}),
952            ToolOriginKind::Test,
953        );
954
955        let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
956        let error = execution.result.unwrap_err();
957
958        assert_eq!(error.class, ToolErrorClass::ReceiptPersistence);
959        assert_eq!(
960            execution.receipt.error_class,
961            Some(ToolErrorClass::ReceiptPersistence)
962        );
963    }
964
965    #[tokio::test]
966    async fn runtime_receipt_carries_budget_and_lineage_context() {
967        let mut registry = ToolRegistry::new();
968        registry.register(EchoTool {
969            descriptor: echo_descriptor(),
970        });
971        let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
972        let runtime = ToolRuntime::new(registry).with_receipt_sink(sink.clone());
973        let call = ToolCall::new(
974            "echo",
975            "1.0.0",
976            json!({"message": "lineage"}),
977            ToolOriginKind::Test,
978        );
979        let mut ctx = tool_ctx();
980        ctx.deadline = Some("2026-03-12T00:00:10Z".into());
981        ctx.workload_class = Some("verification".into());
982        ctx.budget_context = Some(ToolBudgetContext {
983            budget_kind: Some("control_plane".into()),
984            max_steps: Some(3),
985            time_budget_ms: Some(750),
986            cost_budget_units: Some(12),
987        });
988        ctx.parent_receipt_id = Some("dispatch-1".into());
989        ctx.family_receipt_id = Some("attempt-family-1".into());
990        ctx.replay_parent_receipt_id = Some("retry-0".into());
991        ctx.remote_oracle_lease_id = Some(stack_ids::RemoteOracleLeaseId::new("lease-1"));
992        ctx.remote_slice_result_id = Some(stack_ids::RemoteSliceResultId::new("result-1"));
993        ctx.attestation_envelope_id = Some(stack_ids::AttestationEnvelopeId::new("attestation-1"));
994        ctx.cross_runtime_replay_ticket_id = Some(stack_ids::CrossRuntimeReplayTicketId::new(
995            "cross-runtime-replay-ticket-1",
996        ));
997        ctx.planner_stage = ToolPlannerStage::Audit;
998
999        let execution = runtime.execute(&ctx, &call, None, None).await;
1000        assert!(execution.result.is_ok());
1001        assert_eq!(
1002            execution.receipt.deadline.as_deref(),
1003            Some("2026-03-12T00:00:10Z")
1004        );
1005        assert_eq!(
1006            execution.receipt.workload_class.as_deref(),
1007            Some("verification")
1008        );
1009        assert_eq!(
1010            execution
1011                .receipt
1012                .budget_context
1013                .as_ref()
1014                .and_then(|budget| budget.max_steps),
1015            Some(3)
1016        );
1017        assert_eq!(
1018            execution.receipt.parent_receipt_id.as_deref(),
1019            Some("dispatch-1")
1020        );
1021        assert_eq!(
1022            execution.receipt.family_receipt_id.as_deref(),
1023            Some("attempt-family-1")
1024        );
1025        assert_eq!(
1026            execution.receipt.replay_parent_receipt_id.as_deref(),
1027            Some("retry-0")
1028        );
1029        assert_eq!(
1030            execution
1031                .receipt
1032                .remote_oracle_lease_id
1033                .as_ref()
1034                .map(|id| id.as_str()),
1035            Some("lease-1")
1036        );
1037        assert_eq!(
1038            execution
1039                .receipt
1040                .remote_slice_result_id
1041                .as_ref()
1042                .map(|id| id.as_str()),
1043            Some("result-1")
1044        );
1045        assert_eq!(
1046            execution
1047                .receipt
1048                .attestation_envelope_id
1049                .as_ref()
1050                .map(|id| id.as_str()),
1051            Some("attestation-1")
1052        );
1053        assert_eq!(
1054            execution
1055                .receipt
1056                .cross_runtime_replay_ticket_id
1057                .as_ref()
1058                .map(|id| id.as_str()),
1059            Some("cross-runtime-replay-ticket-1")
1060        );
1061        assert_eq!(execution.receipt.planner_stage, ToolPlannerStage::Audit);
1062        assert_eq!(sink.0.lock().unwrap().len(), 1);
1063    }
1064
1065    #[tokio::test]
1066    async fn runtime_receipt_normalizes_into_canonical_forge_receipt() {
1067        let mut registry = ToolRegistry::new();
1068        registry.register(EchoTool {
1069            descriptor: echo_descriptor(),
1070        });
1071        let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
1072        let runtime = ToolRuntime::new(registry).with_receipt_sink(sink);
1073        let call = ToolCall::new(
1074            "echo",
1075            "1.0.0",
1076            json!({"message": "canonical"}),
1077            ToolOriginKind::Test,
1078        );
1079        let mut ctx = tool_ctx();
1080        ctx.deadline = Some("2026-03-12T00:00:10Z".into());
1081        ctx.workload_class = Some("verification".into());
1082        ctx.budget_context = Some(ToolBudgetContext {
1083            budget_kind: Some("control_plane".into()),
1084            max_steps: Some(3),
1085            time_budget_ms: Some(750),
1086            cost_budget_units: Some(12),
1087        });
1088        ctx.parent_receipt_id = Some("dispatch-1".into());
1089        ctx.family_receipt_id = Some("attempt-family-1".into());
1090        ctx.replay_parent_receipt_id = Some("retry-0".into());
1091
1092        let execution = runtime.execute(&ctx, &call, None, None).await;
1093        let receipt = execution.receipt;
1094        let forge_receipt = receipt.to_forge_tool_receipt_v2(json!({
1095            "origin_kind": "test",
1096            "payload_kind": "echo",
1097        }));
1098
1099        assert_eq!(forge_receipt.schema_version, FORGE_TOOL_RECEIPT_V2_SCHEMA);
1100        assert_eq!(forge_receipt.receipt_id, receipt.receipt_id);
1101        assert_eq!(forge_receipt.trace_ctx, receipt.trace_ctx);
1102        assert_eq!(forge_receipt.attempt_id, receipt.attempt_id);
1103        assert_eq!(forge_receipt.trial_id, receipt.trial_id);
1104        assert_eq!(forge_receipt.backend_kind, "local_function");
1105        assert_eq!(forge_receipt.approval_state, "not_required");
1106        assert_eq!(forge_receipt.planner_stage, "execution");
1107        assert_eq!(forge_receipt.retry_owner, "llm_pipeline");
1108        assert_eq!(
1109            forge_receipt.parent_receipt_id.as_deref(),
1110            Some("dispatch-1")
1111        );
1112        assert_eq!(
1113            forge_receipt.family_receipt_id.as_deref(),
1114            Some("attempt-family-1")
1115        );
1116
1117        let reparsed: semantic_memory_forge::ForgeToolReceiptV2 =
1118            serde_json::from_str(&serde_json::to_string(&forge_receipt).unwrap()).unwrap();
1119        assert_eq!(reparsed.receipt_id, forge_receipt.receipt_id);
1120        assert_eq!(reparsed.trace_ctx, forge_receipt.trace_ctx);
1121        assert_eq!(reparsed.raw_payload["payload_kind"], "echo");
1122    }
1123
1124    #[tokio::test]
1125    async fn runtime_requires_execution_permit_for_write_tools() {
1126        let mut registry = ToolRegistry::new();
1127        let mut descriptor = echo_descriptor();
1128        descriptor.name = "submit_patch".into();
1129        descriptor.read_only = false;
1130        descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1131        descriptor.approval_kind = ToolApprovalKind::UserRequired;
1132        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1133        descriptor.input_schema = json!({
1134            "type": "object",
1135            "required": ["target", "message"],
1136            "properties": {
1137                "target": {"type": "string"},
1138                "message": {"type": "string"}
1139            },
1140            "additionalProperties": false
1141        });
1142        registry.register(EchoTool { descriptor });
1143        let runtime = ToolRuntime::new(registry);
1144        let call = ToolCall::new(
1145            "submit_patch",
1146            "1.0.0",
1147            json!({"target": "src/lib.rs", "message": "hello"}),
1148            ToolOriginKind::Test,
1149        );
1150
1151        let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
1152        let error = execution.result.unwrap_err();
1153
1154        assert_eq!(error.class, ToolErrorClass::ApprovalRequired);
1155    }
1156
1157    #[tokio::test]
1158    async fn runtime_requires_typed_grant_for_write_tools_even_with_permit() {
1159        let mut registry = ToolRegistry::new();
1160        let mut descriptor = echo_descriptor();
1161        descriptor.name = "submit_patch".into();
1162        descriptor.read_only = false;
1163        descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1164        descriptor.approval_kind = ToolApprovalKind::UserRequired;
1165        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1166        descriptor.input_schema = json!({
1167            "type": "object",
1168            "required": ["target", "message"],
1169            "properties": {
1170                "target": {"type": "string"},
1171                "message": {"type": "string"}
1172            },
1173            "additionalProperties": false
1174        });
1175        registry.register(EchoTool { descriptor });
1176        let runtime = ToolRuntime::new(registry);
1177        let call = ToolCall::new(
1178            "submit_patch",
1179            "1.0.0",
1180            json!({"target": "src/lib.rs", "message": "hello"}),
1181            ToolOriginKind::Test,
1182        );
1183        let mut ctx = tool_ctx();
1184        ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1185        let permit = execution_permit_for("src/lib.rs");
1186
1187        let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1188        let error = execution.result.unwrap_err();
1189
1190        assert_eq!(error.class, ToolErrorClass::ApprovalRequired);
1191    }
1192
1193    #[tokio::test]
1194    async fn runtime_rejects_out_of_scope_execution_permit_for_write_tools() {
1195        let mut registry = ToolRegistry::new();
1196        let mut descriptor = echo_descriptor();
1197        descriptor.name = "submit_patch".into();
1198        descriptor.read_only = false;
1199        descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1200        descriptor.approval_kind = ToolApprovalKind::UserRequired;
1201        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1202        descriptor.input_schema = json!({
1203            "type": "object",
1204            "required": ["target", "message"],
1205            "properties": {
1206                "target": {"type": "string"},
1207                "message": {"type": "string"}
1208            },
1209            "additionalProperties": false
1210        });
1211        registry.register(EchoTool { descriptor });
1212        let runtime = ToolRuntime::new(registry);
1213        let call = ToolCall::new(
1214            "submit_patch",
1215            "1.0.0",
1216            json!({"target": "src/lib.rs", "message": "hello"}),
1217            ToolOriginKind::Test,
1218        );
1219        let mut ctx = tool_ctx();
1220        ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1221        let permit = execution_permit_for("other.rs");
1222
1223        let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1224        let error = execution.result.unwrap_err();
1225
1226        assert_eq!(error.class, ToolErrorClass::Denied);
1227    }
1228
1229    #[tokio::test]
1230    async fn runtime_rejects_out_of_scope_grant_for_write_tools() {
1231        let mut registry = ToolRegistry::new();
1232        let mut descriptor = echo_descriptor();
1233        descriptor.name = "submit_patch".into();
1234        descriptor.read_only = false;
1235        descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1236        descriptor.approval_kind = ToolApprovalKind::UserRequired;
1237        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1238        descriptor.input_schema = json!({
1239            "type": "object",
1240            "required": ["target", "message"],
1241            "properties": {
1242                "target": {"type": "string"},
1243                "message": {"type": "string"}
1244            },
1245            "additionalProperties": false
1246        });
1247        registry.register(EchoTool { descriptor });
1248        let runtime = ToolRuntime::new(registry);
1249        let call = ToolCall::new(
1250            "submit_patch",
1251            "1.0.0",
1252            json!({"target": "src/lib.rs", "message": "hello"}),
1253            ToolOriginKind::Test,
1254        );
1255        let mut ctx = tool_ctx();
1256        ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1257        ctx.approval_grant = Some(approval_grant_for(
1258            "submit_patch",
1259            crate::ApprovalGrantEffectClass::Write,
1260            "runtime-tests",
1261            Some("other.rs"),
1262        ));
1263        let permit = execution_permit_for("src/lib.rs");
1264
1265        let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1266        let error = execution.result.unwrap_err();
1267
1268        assert_eq!(error.class, ToolErrorClass::Denied);
1269    }
1270
1271    #[tokio::test]
1272    async fn runtime_rejects_expired_grant_for_write_tools() {
1273        let mut registry = ToolRegistry::new();
1274        let mut descriptor = echo_descriptor();
1275        descriptor.name = "submit_patch".into();
1276        descriptor.read_only = false;
1277        descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1278        descriptor.approval_kind = ToolApprovalKind::UserRequired;
1279        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1280        descriptor.input_schema = json!({
1281            "type": "object",
1282            "required": ["target", "message"],
1283            "properties": {
1284                "target": {"type": "string"},
1285                "message": {"type": "string"}
1286            },
1287            "additionalProperties": false
1288        });
1289        registry.register(EchoTool { descriptor });
1290        let runtime = ToolRuntime::new(registry);
1291        let call = ToolCall::new(
1292            "submit_patch",
1293            "1.0.0",
1294            json!({"target": "src/lib.rs", "message": "hello"}),
1295            ToolOriginKind::Test,
1296        );
1297        let mut ctx = tool_ctx();
1298        ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1299        let mut grant = approval_grant_for(
1300            "submit_patch",
1301            crate::ApprovalGrantEffectClass::Write,
1302            "runtime-tests",
1303            Some("src/lib.rs"),
1304        );
1305        grant.expires_at = Some("2000-01-01T00:00:00Z".into());
1306        ctx.approval_grant = Some(grant);
1307        let permit = execution_permit_for("src/lib.rs");
1308
1309        let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1310        let error = execution.result.unwrap_err();
1311
1312        assert_eq!(error.class, ToolErrorClass::Denied);
1313    }
1314
1315    #[tokio::test]
1316    async fn runtime_rejects_mismatched_effect_grant_for_write_tools() {
1317        let mut registry = ToolRegistry::new();
1318        let mut descriptor = echo_descriptor();
1319        descriptor.name = "submit_patch".into();
1320        descriptor.read_only = false;
1321        descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1322        descriptor.approval_kind = ToolApprovalKind::UserRequired;
1323        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1324        descriptor.input_schema = json!({
1325            "type": "object",
1326            "required": ["target", "message"],
1327            "properties": {
1328                "target": {"type": "string"},
1329                "message": {"type": "string"}
1330            },
1331            "additionalProperties": false
1332        });
1333        registry.register(EchoTool { descriptor });
1334        let runtime = ToolRuntime::new(registry);
1335        let call = ToolCall::new(
1336            "submit_patch",
1337            "1.0.0",
1338            json!({"target": "src/lib.rs", "message": "hello"}),
1339            ToolOriginKind::Test,
1340        );
1341        let mut ctx = tool_ctx();
1342        ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1343        ctx.approval_grant = Some(approval_grant_for(
1344            "submit_patch",
1345            crate::ApprovalGrantEffectClass::Analysis,
1346            "runtime-tests",
1347            Some("src/lib.rs"),
1348        ));
1349        let permit = execution_permit_for("src/lib.rs");
1350
1351        let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1352        let error = execution.result.unwrap_err();
1353
1354        assert_eq!(error.class, ToolErrorClass::Denied);
1355    }
1356
1357    #[tokio::test]
1358    async fn runtime_accepts_matching_typed_grant_and_permit_for_write_tools() {
1359        let mut registry = ToolRegistry::new();
1360        let mut descriptor = echo_descriptor();
1361        descriptor.name = "submit_patch".into();
1362        descriptor.read_only = false;
1363        descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1364        descriptor.approval_kind = ToolApprovalKind::UserRequired;
1365        descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1366        descriptor.input_schema = json!({
1367            "type": "object",
1368            "required": ["target", "message"],
1369            "properties": {
1370                "target": {"type": "string"},
1371                "message": {"type": "string"}
1372            },
1373            "additionalProperties": false
1374        });
1375        registry.register(EchoTool { descriptor });
1376        let runtime = ToolRuntime::new(registry);
1377        let call = ToolCall::new(
1378            "submit_patch",
1379            "1.0.0",
1380            json!({"target": "src/lib.rs", "message": "hello"}),
1381            ToolOriginKind::Test,
1382        );
1383        let mut ctx = tool_ctx();
1384        ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1385        ctx.approval_grant = Some(approval_grant_for(
1386            "submit_patch",
1387            crate::ApprovalGrantEffectClass::Write,
1388            "runtime-tests",
1389            Some("src/lib.rs"),
1390        ));
1391        let permit = execution_permit_for("src/lib.rs");
1392
1393        let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1394        assert!(execution.result.is_ok());
1395        assert_eq!(
1396            execution.receipt.approval_state,
1397            ToolApprovalState::Approved
1398        );
1399    }
1400}