Skip to main content

aidens_contracts/
capability_turn.rs

1//! Capability, permit, turn, stop-rule, and run-report DTOs.
2//!
3//! These are orchestration receipts and display reports, not canonical tool/runtime truth.
4
5use super::*;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
8pub struct PoisonReportEntryV1 {
9    pub poison_id: ArtifactId,
10    pub source_path: String,
11    pub line_number: u64,
12    pub raw_digest: String,
13    pub raw_line: String,
14    #[serde(default, skip_serializing_if = "Vec::is_empty")]
15    pub reason_codes: Vec<String>,
16    pub recorded_at: DateTime<Utc>,
17}
18
19impl PoisonReportEntryV1 {
20    pub fn new(
21        source_path: impl Into<String>,
22        line_number: u64,
23        raw_line: impl Into<String>,
24        reason: impl Into<String>,
25    ) -> Self {
26        let raw_line = raw_line.into();
27        Self {
28            poison_id: display_only_unstable_id("poison-receipt"),
29            source_path: source_path.into(),
30            line_number,
31            raw_digest: non_authoritative_json_display_digest(&serde_json::Value::String(
32                raw_line.clone(),
33            )),
34            raw_line,
35            reason_codes: vec![reason.into()],
36            recorded_at: Utc::now(),
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42pub struct ExecutionLineageGraphV1 {
43    pub graph_id: ArtifactId,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub run_id: Option<ArtifactId>,
46    pub nodes: Vec<ExecutionLineageNodeV1>,
47    pub edges: Vec<ExecutionLineageEdgeV1>,
48    pub generated_at: DateTime<Utc>,
49}
50
51impl ExecutionLineageGraphV1 {
52    pub fn new(
53        run_id: Option<ArtifactId>,
54        nodes: Vec<ExecutionLineageNodeV1>,
55        edges: Vec<ExecutionLineageEdgeV1>,
56    ) -> Self {
57        Self {
58            graph_id: display_only_unstable_id("execution-lineage-graph"),
59            run_id,
60            nodes,
61            edges,
62            generated_at: Utc::now(),
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68pub struct ExecutionLineageNodeV1 {
69    pub receipt_id: ArtifactId,
70    pub kind: ArtifactKindV1,
71    pub content_digest: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75pub struct ExecutionLineageEdgeV1 {
76    pub parent_receipt_id: ArtifactId,
77    pub child_receipt_id: ArtifactId,
78    pub relation: String,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82#[serde(rename_all = "kebab-case")]
83pub enum ToolLifecycleStateV1 {
84    Declared,
85    Registered,
86    Executable,
87    Exposed,
88    ExposedThisTurn,
89    Invoked,
90    Succeeded,
91    Failed,
92    Hidden,
93    Blocked,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97#[serde(rename_all = "kebab-case")]
98pub enum CapabilityGateOutcomeV1 {
99    Exposed,
100    Hidden,
101    Blocked,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
105pub struct CapabilityGateDecisionV1 {
106    pub decision_id: ArtifactId,
107    pub kind: ArtifactKindV1,
108    pub capability_id: String,
109    pub outcome: CapabilityGateOutcomeV1,
110    pub lifecycle: Vec<ToolLifecycleStateV1>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub risk_class: Option<CanonicalToolSideEffectClass>,
113    #[serde(default)]
114    pub permit_required: bool,
115    pub executable_this_turn: bool,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub sandbox_root: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub approval_request: Option<ApprovalRequestV1>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub permit_grant_id: Option<ArtifactId>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub permit_use_receipt_id: Option<ArtifactId>,
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub reason_codes: Vec<String>,
126}
127
128impl CapabilityGateDecisionV1 {
129    pub fn new(
130        capability_id: impl Into<String>,
131        outcome: CapabilityGateOutcomeV1,
132        lifecycle: Vec<ToolLifecycleStateV1>,
133        executable_this_turn: bool,
134        reason_codes: Vec<String>,
135    ) -> Self {
136        Self {
137            decision_id: display_only_unstable_id("capability-gate"),
138            kind: ArtifactKindV1::ToolExposure,
139            capability_id: capability_id.into(),
140            outcome,
141            lifecycle,
142            risk_class: None,
143            permit_required: false,
144            executable_this_turn,
145            sandbox_root: None,
146            approval_request: None,
147            permit_grant_id: None,
148            permit_use_receipt_id: None,
149            reason_codes,
150        }
151    }
152
153    pub fn for_tool(draft: CapabilityGateDecisionDraftV1) -> Self {
154        Self {
155            decision_id: display_only_unstable_id("capability-gate"),
156            kind: ArtifactKindV1::ToolExposure,
157            capability_id: draft.tool_id,
158            outcome: draft.outcome,
159            lifecycle: draft.lifecycle,
160            risk_class: Some(draft.risk_class),
161            permit_required: draft.permit_required,
162            executable_this_turn: draft.executable_this_turn,
163            sandbox_root: draft.sandbox_root,
164            approval_request: draft.approval_request,
165            permit_grant_id: draft.permit_grant_id,
166            permit_use_receipt_id: draft.permit_use_receipt_id,
167            reason_codes: draft.reason_codes,
168        }
169    }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
173pub struct CapabilityGateDecisionDraftV1 {
174    pub tool_id: String,
175    pub outcome: CapabilityGateOutcomeV1,
176    pub lifecycle: Vec<ToolLifecycleStateV1>,
177    pub risk_class: CanonicalToolSideEffectClass,
178    pub permit_required: bool,
179    pub executable_this_turn: bool,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub sandbox_root: Option<String>,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub approval_request: Option<ApprovalRequestV1>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub permit_grant_id: Option<ArtifactId>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub permit_use_receipt_id: Option<ArtifactId>,
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub reason_codes: Vec<String>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
193pub struct PermitGrantV1 {
194    pub permit_id: ArtifactId,
195    pub risk_class: CanonicalToolSideEffectClass,
196    pub tool_id: String,
197    pub sandbox_root: String,
198    pub scope: String,
199    pub granted_by: String,
200    pub granted_at: DateTime<Utc>,
201    pub expires_at: Option<DateTime<Utc>>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub run_id: Option<ArtifactId>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub attempt_id: Option<ArtifactId>,
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub reason_codes: Vec<String>,
208}
209
210impl PermitGrantV1 {
211    pub fn new(
212        risk_class: CanonicalToolSideEffectClass,
213        scope: impl Into<String>,
214        granted_by: impl Into<String>,
215    ) -> Self {
216        let scope = scope.into();
217        Self {
218            permit_id: display_only_unstable_id("permit"),
219            risk_class,
220            tool_id: "*".into(),
221            sandbox_root: scope.clone(),
222            scope,
223            granted_by: granted_by.into(),
224            granted_at: Utc::now(),
225            expires_at: None,
226            run_id: None,
227            attempt_id: None,
228            reason_codes: Vec::new(),
229        }
230    }
231
232    pub fn scoped(
233        risk_class: CanonicalToolSideEffectClass,
234        tool_id: impl Into<String>,
235        sandbox_root: impl Into<String>,
236        granted_by: impl Into<String>,
237    ) -> Self {
238        let tool_id = tool_id.into();
239        let sandbox_root = sandbox_root.into();
240        Self {
241            permit_id: display_only_unstable_id("permit"),
242            risk_class,
243            tool_id: tool_id.clone(),
244            sandbox_root: sandbox_root.clone(),
245            scope: format!("tool={tool_id};sandbox={sandbox_root}"),
246            granted_by: granted_by.into(),
247            granted_at: Utc::now(),
248            expires_at: None,
249            run_id: None,
250            attempt_id: None,
251            reason_codes: Vec::new(),
252        }
253    }
254
255    pub fn for_execution_context(mut self, context: &AidensRunContextV1) -> Self {
256        self.run_id = Some(context.run_id.clone());
257        self.attempt_id = Some(context.attempt_id.clone());
258        self.scope = format!(
259            "{};run={};attempt={}",
260            self.scope, context.run_id.0, context.attempt_id.0
261        );
262        self
263    }
264
265    pub fn matches_scope(
266        &self,
267        risk_class: &CanonicalToolSideEffectClass,
268        tool_id: &str,
269        sandbox_root: &str,
270        run_id: Option<&ArtifactId>,
271        attempt_id: Option<&ArtifactId>,
272    ) -> bool {
273        if &self.risk_class != risk_class {
274            return false;
275        }
276        if self.tool_id != tool_id {
277            return false;
278        }
279        if self.sandbox_root != sandbox_root {
280            return false;
281        }
282        if self
283            .run_id
284            .as_ref()
285            .is_some_and(|grant_run_id| Some(grant_run_id) != run_id)
286        {
287            return false;
288        }
289        if self
290            .attempt_id
291            .as_ref()
292            .is_some_and(|grant_attempt_id| Some(grant_attempt_id) != attempt_id)
293        {
294            return false;
295        }
296        self.expires_at
297            .map(|expires_at| expires_at > Utc::now())
298            .unwrap_or(true)
299    }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
303pub struct ApprovalRequestV1 {
304    pub request_id: ArtifactId,
305    pub tool_id: String,
306    pub risk_class: CanonicalToolSideEffectClass,
307    pub scope: String,
308    pub sandbox_root: String,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub run_id: Option<ArtifactId>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub attempt_id: Option<ArtifactId>,
313    pub reason: String,
314    #[serde(default, skip_serializing_if = "Vec::is_empty")]
315    pub reason_codes: Vec<String>,
316    pub requested_at: DateTime<Utc>,
317}
318
319impl ApprovalRequestV1 {
320    pub fn new(
321        tool_id: impl Into<String>,
322        risk_class: CanonicalToolSideEffectClass,
323        scope: impl Into<String>,
324        reason: impl Into<String>,
325    ) -> Self {
326        let scope = scope.into();
327        Self {
328            request_id: display_only_unstable_id("approval-request"),
329            tool_id: tool_id.into(),
330            risk_class,
331            sandbox_root: scope.clone(),
332            scope,
333            run_id: None,
334            attempt_id: None,
335            reason: reason.into(),
336            reason_codes: vec!["approval-required".into()],
337            requested_at: Utc::now(),
338        }
339    }
340
341    pub fn scoped(
342        tool_id: impl Into<String>,
343        risk_class: CanonicalToolSideEffectClass,
344        sandbox_root: impl Into<String>,
345        reason: impl Into<String>,
346    ) -> Self {
347        let tool_id = tool_id.into();
348        let sandbox_root = sandbox_root.into();
349        Self {
350            request_id: display_only_unstable_id("approval-request"),
351            tool_id: tool_id.clone(),
352            risk_class,
353            scope: format!("tool={tool_id};sandbox={sandbox_root}"),
354            sandbox_root,
355            run_id: None,
356            attempt_id: None,
357            reason: reason.into(),
358            reason_codes: vec!["approval-required".into()],
359            requested_at: Utc::now(),
360        }
361    }
362
363    pub fn for_execution_context(mut self, context: &AidensRunContextV1) -> Self {
364        self.run_id = Some(context.run_id.clone());
365        self.attempt_id = Some(context.attempt_id.clone());
366        self.scope = format!(
367            "{};run={};attempt={}",
368            self.scope, context.run_id.0, context.attempt_id.0
369        );
370        self
371    }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
375pub struct ApprovalDecisionV1 {
376    pub decision_id: ArtifactId,
377    pub request_id: ArtifactId,
378    pub approved: bool,
379    pub permit_grant: Option<PermitGrantV1>,
380    pub decided_by: String,
381    pub decided_at: DateTime<Utc>,
382    #[serde(default, skip_serializing_if = "Vec::is_empty")]
383    pub reason_codes: Vec<String>,
384}
385
386impl ApprovalDecisionV1 {
387    pub fn denied(
388        request_id: ArtifactId,
389        decided_by: impl Into<String>,
390        reason: impl Into<String>,
391    ) -> Self {
392        Self {
393            decision_id: display_only_unstable_id("approval-decision"),
394            request_id,
395            approved: false,
396            permit_grant: None,
397            decided_by: decided_by.into(),
398            decided_at: Utc::now(),
399            reason_codes: vec![reason.into()],
400        }
401    }
402
403    pub fn approved(
404        request_id: ArtifactId,
405        permit_grant: PermitGrantV1,
406        decided_by: impl Into<String>,
407    ) -> Self {
408        Self {
409            decision_id: display_only_unstable_id("approval-decision"),
410            request_id,
411            approved: true,
412            permit_grant: Some(permit_grant),
413            decided_by: decided_by.into(),
414            decided_at: Utc::now(),
415            reason_codes: Vec::new(),
416        }
417    }
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
421pub struct PermitUseReportV1 {
422    pub receipt_id: ArtifactId,
423    pub kind: ArtifactKindV1,
424    pub permit_id: ArtifactId,
425    pub tool_id: String,
426    pub risk_class: CanonicalToolSideEffectClass,
427    pub sandbox_root: String,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub run_id: Option<ArtifactId>,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub attempt_id: Option<ArtifactId>,
432    pub allowed: bool,
433    #[serde(default, skip_serializing_if = "Vec::is_empty")]
434    pub reason_codes: Vec<String>,
435    pub used_at: DateTime<Utc>,
436}
437
438impl PermitUseReportV1 {
439    pub fn allowed(
440        grant: &PermitGrantV1,
441        tool_id: impl Into<String>,
442        sandbox_root: impl Into<String>,
443        run_id: Option<ArtifactId>,
444        attempt_id: Option<ArtifactId>,
445    ) -> Self {
446        Self {
447            receipt_id: display_only_unstable_id("permit-use"),
448            kind: ArtifactKindV1::PermitUse,
449            permit_id: grant.permit_id.clone(),
450            tool_id: tool_id.into(),
451            risk_class: grant.risk_class.clone(),
452            sandbox_root: sandbox_root.into(),
453            run_id,
454            attempt_id,
455            allowed: true,
456            reason_codes: vec!["permit-scope-matched".into()],
457            used_at: Utc::now(),
458        }
459    }
460
461    pub fn denied(
462        permit_id: ArtifactId,
463        tool_id: impl Into<String>,
464        risk_class: CanonicalToolSideEffectClass,
465        sandbox_root: impl Into<String>,
466        reason: impl Into<String>,
467    ) -> Self {
468        Self {
469            receipt_id: display_only_unstable_id("permit-use"),
470            kind: ArtifactKindV1::PermitUse,
471            permit_id,
472            tool_id: tool_id.into(),
473            risk_class,
474            sandbox_root: sandbox_root.into(),
475            run_id: None,
476            attempt_id: None,
477            allowed: false,
478            reason_codes: vec![reason.into()],
479            used_at: Utc::now(),
480        }
481    }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
485#[serde(rename_all = "kebab-case")]
486pub enum TurnModeV1 {
487    NoTools,
488    NativeToolLoop,
489    ParserFallback,
490    ProviderUnavailable,
491    BudgetExhausted,
492}
493
494impl fmt::Display for TurnModeV1 {
495    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
496        f.write_str(match self {
497            Self::NoTools => "no-tools",
498            Self::NativeToolLoop => "native-tool-loop",
499            Self::ParserFallback => "parser-fallback",
500            Self::ProviderUnavailable => "provider-unavailable",
501            Self::BudgetExhausted => "budget-exhausted",
502        })
503    }
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
507#[serde(rename_all = "kebab-case")]
508pub enum ToolCallSourceV1 {
509    NativeProvider,
510    ParserFallback,
511}
512
513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
514pub struct ToolCallRequestV1 {
515    pub call_id: ArtifactId,
516    pub source: ToolCallSourceV1,
517    pub tool_id: String,
518    pub input: serde_json::Value,
519    pub input_digest: String,
520    #[serde(default)]
521    pub degraded: bool,
522    pub raw_provider_text: Option<String>,
523    #[serde(default, skip_serializing_if = "Vec::is_empty")]
524    pub reason_codes: Vec<String>,
525    #[serde(default, skip_serializing_if = "Vec::is_empty")]
526    pub canonical_backpointers: Vec<CanonicalBackpointerV1>,
527    pub requested_at: DateTime<Utc>,
528}
529
530impl ToolCallRequestV1 {
531    pub fn new(
532        source: ToolCallSourceV1,
533        tool_id: impl Into<String>,
534        input: serde_json::Value,
535        raw_provider_text: Option<String>,
536        reason_codes: Vec<String>,
537    ) -> Self {
538        Self {
539            call_id: display_only_unstable_id("tool-call"),
540            source,
541            tool_id: tool_id.into(),
542            input_digest: json_digest(&input),
543            input,
544            degraded: source == ToolCallSourceV1::ParserFallback,
545            raw_provider_text,
546            reason_codes,
547            canonical_backpointers: canonical_owner_backpointer(
548                "llm-tool-runtime",
549                "ToolCall",
550                "canonical-tool-call-owner",
551            ),
552            requested_at: Utc::now(),
553        }
554    }
555}
556
557#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
558pub struct ToolCallResultV1 {
559    pub result_id: ArtifactId,
560    pub call_id: ArtifactId,
561    pub tool_id: String,
562    pub input_digest: String,
563    pub output: Option<serde_json::Value>,
564    pub output_digest: Option<String>,
565    pub succeeded: bool,
566    pub invocation_receipt_id: ArtifactId,
567    #[serde(default, skip_serializing_if = "Vec::is_empty")]
568    pub reason_codes: Vec<String>,
569    #[serde(default, skip_serializing_if = "Vec::is_empty")]
570    pub canonical_backpointers: Vec<CanonicalBackpointerV1>,
571    pub completed_at: DateTime<Utc>,
572}
573
574impl ToolCallResultV1 {
575    pub fn from_invocation(
576        request: &ToolCallRequestV1,
577        invocation_receipt: &ToolInvocationReportV1,
578    ) -> Self {
579        Self {
580            result_id: display_only_unstable_id("tool-call-result"),
581            call_id: request.call_id.clone(),
582            tool_id: request.tool_id.clone(),
583            input_digest: request.input_digest.clone(),
584            output: invocation_receipt.output.clone(),
585            output_digest: invocation_receipt.output_digest.clone(),
586            succeeded: invocation_receipt.succeeded,
587            invocation_receipt_id: invocation_receipt.receipt_id.clone(),
588            reason_codes: invocation_receipt.reason_codes.clone(),
589            canonical_backpointers: canonical_owner_backpointer(
590                "llm-tool-runtime",
591                "ToolResult",
592                "canonical-tool-result-owner",
593            ),
594            completed_at: Utc::now(),
595        }
596    }
597
598    pub fn output_text(&self) -> String {
599        self.output
600            .as_ref()
601            .and_then(|output| output.get("content"))
602            .and_then(serde_json::Value::as_str)
603            .map(str::to_string)
604            .or_else(|| self.output.as_ref().map(serde_json::Value::to_string))
605            .unwrap_or_default()
606    }
607}
608
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610pub struct TurnExecutionPlanV1 {
611    pub plan_id: ArtifactId,
612    pub mode: TurnModeV1,
613    pub provider_route: ProviderRouteReportV1,
614    pub tool_exposure_id: ArtifactId,
615    pub exposed_tool_ids: Vec<String>,
616    pub max_tool_calls: u32,
617    pub max_retries: u32,
618    pub max_turn_millis: u64,
619    #[serde(default, skip_serializing_if = "Vec::is_empty")]
620    pub reason_codes: Vec<String>,
621    pub created_at: DateTime<Utc>,
622}
623
624impl TurnExecutionPlanV1 {
625    pub fn new(
626        mode: TurnModeV1,
627        provider_route: ProviderRouteReportV1,
628        tool_exposure: &ToolExposureSetV1,
629        max_tool_calls: u32,
630        max_retries: u32,
631        max_turn_millis: u64,
632        reason_codes: Vec<String>,
633    ) -> Self {
634        Self {
635            plan_id: display_only_unstable_id("turn-plan"),
636            mode,
637            provider_route,
638            tool_exposure_id: tool_exposure.exposure_id.clone(),
639            exposed_tool_ids: tool_exposure.exposed_tool_ids.clone(),
640            max_tool_calls,
641            max_retries,
642            max_turn_millis,
643            reason_codes,
644            created_at: Utc::now(),
645        }
646    }
647}
648
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
650#[serde(rename_all = "kebab-case")]
651pub enum TurnFinalStateV1 {
652    FinalOutput,
653    ProviderUnavailable,
654    ToolBlocked,
655    ToolFailed,
656    BudgetExhausted,
657    StopRuleTriggered,
658}
659
660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
661pub struct TurnReportV1 {
662    pub receipt_id: ArtifactId,
663    pub kind: ArtifactKindV1,
664    pub run_id: ArtifactId,
665    pub attempt_id: ArtifactId,
666    pub plan_id: ArtifactId,
667    pub mode: TurnModeV1,
668    pub final_state: TurnFinalStateV1,
669    #[serde(default)]
670    pub degraded: bool,
671    #[serde(default)]
672    pub blocked: bool,
673    #[serde(default, skip_serializing_if = "Vec::is_empty")]
674    pub tool_call_ids: Vec<ArtifactId>,
675    #[serde(default, skip_serializing_if = "Vec::is_empty")]
676    pub tool_invocation_receipt_ids: Vec<ArtifactId>,
677    #[serde(default, skip_serializing_if = "Vec::is_empty")]
678    pub stop_rule_receipt_ids: Vec<ArtifactId>,
679    pub budget_exhaustion_receipt_id: Option<ArtifactId>,
680    #[serde(default, skip_serializing_if = "Vec::is_empty")]
681    pub reason_codes: Vec<String>,
682    pub started_at: DateTime<Utc>,
683    pub completed_at: Option<DateTime<Utc>>,
684}
685
686impl TurnReportV1 {
687    pub fn started(context: &AidensRunContextV1, plan: &TurnExecutionPlanV1) -> Self {
688        Self {
689            receipt_id: display_only_unstable_id("turn"),
690            kind: ArtifactKindV1::Turn,
691            run_id: context.run_id.clone(),
692            attempt_id: context.attempt_id.clone(),
693            plan_id: plan.plan_id.clone(),
694            mode: plan.mode,
695            final_state: TurnFinalStateV1::StopRuleTriggered,
696            degraded: false,
697            blocked: false,
698            tool_call_ids: Vec::new(),
699            tool_invocation_receipt_ids: Vec::new(),
700            stop_rule_receipt_ids: Vec::new(),
701            budget_exhaustion_receipt_id: None,
702            reason_codes: plan.reason_codes.clone(),
703            started_at: Utc::now(),
704            completed_at: None,
705        }
706    }
707
708    pub fn record_tool_call(
709        &mut self,
710        request: &ToolCallRequestV1,
711        invocation_receipt: &ToolInvocationReportV1,
712    ) {
713        self.tool_call_ids.push(request.call_id.clone());
714        self.tool_invocation_receipt_ids
715            .push(invocation_receipt.receipt_id.clone());
716        if request.degraded {
717            self.degraded = true;
718        }
719    }
720
721    pub fn record_stop_rule(&mut self, stop_rule: &StopRuleReportV1) {
722        self.stop_rule_receipt_ids
723            .push(stop_rule.receipt_id.clone());
724    }
725
726    pub fn record_budget_exhaustion(&mut self, budget: &BudgetExhaustionReportV1) {
727        self.budget_exhaustion_receipt_id = Some(budget.receipt_id.clone());
728        self.degraded = true;
729        self.blocked = true;
730    }
731
732    pub fn complete(mut self, final_state: TurnFinalStateV1) -> Self {
733        self.final_state = final_state;
734        self.completed_at = Some(Utc::now());
735        self
736    }
737}
738
739#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
740#[serde(rename_all = "kebab-case")]
741pub enum StopRuleV1 {
742    FinalOutput,
743    ProviderUnavailable,
744    AgencyPolicy,
745    ToolNotExposed,
746    ToolInvocationFailed,
747    RecursiveToolCall,
748    MaxToolCalls,
749    MaxRetries,
750    DeadlineExceeded,
751}
752
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
754pub struct StopRuleReportV1 {
755    pub receipt_id: ArtifactId,
756    pub kind: ArtifactKindV1,
757    pub run_id: ArtifactId,
758    pub attempt_id: ArtifactId,
759    pub rule: StopRuleV1,
760    pub triggered: bool,
761    #[serde(default, skip_serializing_if = "Vec::is_empty")]
762    pub reason_codes: Vec<String>,
763    pub checked_at: DateTime<Utc>,
764}
765
766impl StopRuleReportV1 {
767    pub fn triggered(
768        context: &AidensRunContextV1,
769        rule: StopRuleV1,
770        reason_codes: Vec<String>,
771    ) -> Self {
772        Self {
773            receipt_id: display_only_unstable_id("stop-rule"),
774            kind: ArtifactKindV1::StopRule,
775            run_id: context.run_id.clone(),
776            attempt_id: context.attempt_id.clone(),
777            rule,
778            triggered: true,
779            reason_codes,
780            checked_at: Utc::now(),
781        }
782    }
783}
784
785#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
786pub struct BudgetExhaustionReportV1 {
787    pub receipt_id: ArtifactId,
788    pub kind: ArtifactKindV1,
789    pub run_id: ArtifactId,
790    pub attempt_id: ArtifactId,
791    pub max_tool_calls: u32,
792    pub attempted_tool_calls: u32,
793    pub max_retries: u32,
794    pub retries: u32,
795    pub max_turn_millis: u64,
796    pub elapsed_millis: u64,
797    #[serde(default, skip_serializing_if = "Vec::is_empty")]
798    pub reason_codes: Vec<String>,
799    pub exhausted_at: DateTime<Utc>,
800}
801
802impl BudgetExhaustionReportV1 {
803    pub fn new(draft: BudgetExhaustionReportDraftV1) -> Self {
804        Self {
805            receipt_id: display_only_unstable_id("budget-exhaustion"),
806            kind: ArtifactKindV1::BudgetExhaustion,
807            run_id: draft.run_id,
808            attempt_id: draft.attempt_id,
809            max_tool_calls: draft.max_tool_calls,
810            attempted_tool_calls: draft.attempted_tool_calls,
811            max_retries: draft.max_retries,
812            retries: draft.retries,
813            max_turn_millis: draft.max_turn_millis,
814            elapsed_millis: draft.elapsed_millis,
815            reason_codes: draft.reason_codes,
816            exhausted_at: Utc::now(),
817        }
818    }
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
822pub struct BudgetExhaustionReportDraftV1 {
823    pub run_id: ArtifactId,
824    pub attempt_id: ArtifactId,
825    pub max_tool_calls: u32,
826    pub attempted_tool_calls: u32,
827    pub max_retries: u32,
828    pub retries: u32,
829    pub max_turn_millis: u64,
830    pub elapsed_millis: u64,
831    #[serde(default, skip_serializing_if = "Vec::is_empty")]
832    pub reason_codes: Vec<String>,
833}
834
835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
836#[serde(rename_all = "kebab-case")]
837pub enum ToolInvocationOutcomeV1 {
838    Started,
839    Succeeded,
840    Failed,
841}
842
843#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
844pub struct ToolInvocationReportV1 {
845    pub receipt_id: ArtifactId,
846    pub kind: ArtifactKindV1,
847    pub run_id: Option<ArtifactId>,
848    pub attempt_id: Option<ArtifactId>,
849    pub tool_id: String,
850    pub input: serde_json::Value,
851    pub input_digest: String,
852    pub output: Option<serde_json::Value>,
853    pub output_digest: Option<String>,
854    pub succeeded: bool,
855    pub outcome: ToolInvocationOutcomeV1,
856    pub permit_grant_id: Option<ArtifactId>,
857    pub permit_use_receipt_id: Option<ArtifactId>,
858    pub approval_decision_id: Option<ArtifactId>,
859    pub approval_request_id: Option<ArtifactId>,
860    #[serde(default, skip_serializing_if = "Vec::is_empty")]
861    pub schema_validation_receipt_ids: Vec<ArtifactId>,
862    #[serde(default, skip_serializing_if = "Vec::is_empty")]
863    pub canonical_backpointers: Vec<CanonicalBackpointerV1>,
864    pub lifecycle: Vec<ToolLifecycleStateV1>,
865    #[serde(default, skip_serializing_if = "Vec::is_empty")]
866    pub reason_codes: Vec<String>,
867    pub started_at: DateTime<Utc>,
868    pub completed_at: Option<DateTime<Utc>>,
869}
870
871pub type ToolAttemptReportV1 = ToolInvocationReportV1;
872
873impl ToolInvocationReportV1 {
874    pub fn started(tool_id: impl Into<String>, input: serde_json::Value) -> Self {
875        Self {
876            receipt_id: display_only_unstable_id("tool-invocation"),
877            kind: ArtifactKindV1::ToolInvocation,
878            run_id: None,
879            attempt_id: None,
880            tool_id: tool_id.into(),
881            input_digest: json_digest(&input),
882            input,
883            output: None,
884            output_digest: None,
885            succeeded: false,
886            outcome: ToolInvocationOutcomeV1::Started,
887            permit_grant_id: None,
888            permit_use_receipt_id: None,
889            approval_decision_id: None,
890            approval_request_id: None,
891            schema_validation_receipt_ids: Vec::new(),
892            canonical_backpointers: canonical_owner_backpointer(
893                "llm-tool-runtime",
894                "ToolReceipt",
895                "canonical-tool-receipt-owner",
896            ),
897            lifecycle: vec![ToolLifecycleStateV1::Invoked],
898            reason_codes: Vec::new(),
899            started_at: Utc::now(),
900            completed_at: None,
901        }
902    }
903
904    pub fn with_execution_context(mut self, context: &AidensRunContextV1) -> Self {
905        self.run_id = Some(context.run_id.clone());
906        self.attempt_id = Some(context.attempt_id.clone());
907        self
908    }
909
910    pub fn with_permit_grant(mut self, permit_grant_id: ArtifactId) -> Self {
911        self.permit_grant_id = Some(permit_grant_id);
912        self
913    }
914
915    pub fn with_permit_use(mut self, permit_use_receipt_id: ArtifactId) -> Self {
916        self.permit_use_receipt_id = Some(permit_use_receipt_id);
917        self
918    }
919
920    pub fn with_approval_request(mut self, approval_request_id: ArtifactId) -> Self {
921        self.approval_request_id = Some(approval_request_id);
922        self
923    }
924
925    pub fn with_approval_decision(mut self, approval_decision_id: ArtifactId) -> Self {
926        self.approval_decision_id = Some(approval_decision_id);
927        self
928    }
929
930    pub fn with_schema_validation(mut self, schema_validation_receipt_id: ArtifactId) -> Self {
931        self.schema_validation_receipt_ids
932            .push(schema_validation_receipt_id);
933        self
934    }
935
936    pub fn with_canonical_tool_receipt(mut self, receipt_id: impl Into<String>) -> Self {
937        self.canonical_backpointers
938            .push(CanonicalBackpointerV1::external(
939                "llm-tool-runtime",
940                "ToolReceipt",
941                "canonical-tool-receipt-id",
942                receipt_id,
943            ));
944        self
945    }
946
947    pub fn complete_success(mut self, output: serde_json::Value) -> Self {
948        self.output_digest = Some(json_digest(&output));
949        self.output = Some(output);
950        self.succeeded = true;
951        self.outcome = ToolInvocationOutcomeV1::Succeeded;
952        self.lifecycle.push(ToolLifecycleStateV1::Succeeded);
953        self.completed_at = Some(Utc::now());
954        self
955    }
956
957    pub fn complete_failure(mut self, reason: impl Into<String>) -> Self {
958        self.reason_codes.push(reason.into());
959        self.succeeded = false;
960        self.outcome = ToolInvocationOutcomeV1::Failed;
961        self.lifecycle.push(ToolLifecycleStateV1::Failed);
962        self.completed_at = Some(Utc::now());
963        self
964    }
965
966    pub fn complete_failure_with_output(
967        mut self,
968        reason: impl Into<String>,
969        output: serde_json::Value,
970    ) -> Self {
971        self.output_digest = Some(json_digest(&output));
972        self.output = Some(output);
973        self.complete_failure(reason)
974    }
975}
976
977#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
978pub struct RunReportV1 {
979    pub receipt_id: ArtifactId,
980    pub kind: ArtifactKindV1,
981    pub context: AidensRunContextV1,
982    pub provider_route: Option<ProviderRouteReportV1>,
983    #[serde(default, skip_serializing_if = "Vec::is_empty")]
984    pub tool_exposure_ids: Vec<ArtifactId>,
985    #[serde(default, skip_serializing_if = "Vec::is_empty")]
986    pub turn_receipts: Vec<TurnReportV1>,
987    #[serde(default, skip_serializing_if = "Vec::is_empty")]
988    pub tool_call_requests: Vec<ToolCallRequestV1>,
989    #[serde(default, skip_serializing_if = "Vec::is_empty")]
990    pub tool_call_results: Vec<ToolCallResultV1>,
991    #[serde(default, skip_serializing_if = "Vec::is_empty")]
992    pub tool_invocation_receipts: Vec<ToolInvocationReportV1>,
993    #[serde(default, skip_serializing_if = "Vec::is_empty")]
994    pub approval_requests: Vec<ApprovalRequestV1>,
995    #[serde(default, skip_serializing_if = "Vec::is_empty")]
996    pub approval_decisions: Vec<ApprovalDecisionV1>,
997    #[serde(default, skip_serializing_if = "Vec::is_empty")]
998    pub permit_use_receipts: Vec<PermitUseReportV1>,
999    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1000    pub boundary_repair_receipts: Vec<BoundaryRepairReportV1>,
1001    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1002    pub json_repair_receipts: Vec<JsonBoundaryRepairDisplayReportV1>,
1003    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1004    pub schema_validation_receipts: Vec<SchemaValidationReportV1>,
1005    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1006    pub stop_rule_receipts: Vec<StopRuleReportV1>,
1007    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1008    pub budget_exhaustion_receipts: Vec<BudgetExhaustionReportV1>,
1009    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1010    pub agency_receipt_ids: Vec<String>,
1011    pub warnings: Vec<String>,
1012    pub completed_at: Option<DateTime<Utc>>,
1013}
1014
1015impl RunReportV1 {
1016    pub fn started(context: AidensRunContextV1) -> Self {
1017        Self {
1018            receipt_id: display_only_unstable_id("receipt"),
1019            kind: ArtifactKindV1::Run,
1020            context,
1021            provider_route: None,
1022            tool_exposure_ids: Vec::new(),
1023            turn_receipts: Vec::new(),
1024            tool_call_requests: Vec::new(),
1025            tool_call_results: Vec::new(),
1026            tool_invocation_receipts: Vec::new(),
1027            approval_requests: Vec::new(),
1028            approval_decisions: Vec::new(),
1029            permit_use_receipts: Vec::new(),
1030            boundary_repair_receipts: Vec::new(),
1031            json_repair_receipts: Vec::new(),
1032            schema_validation_receipts: Vec::new(),
1033            stop_rule_receipts: Vec::new(),
1034            budget_exhaustion_receipts: Vec::new(),
1035            agency_receipt_ids: Vec::new(),
1036            warnings: Vec::new(),
1037            completed_at: None,
1038        }
1039    }
1040
1041    pub fn complete(mut self) -> Self {
1042        self.completed_at = Some(Utc::now());
1043        self
1044    }
1045}
1046
1047#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1048#[serde(rename_all = "kebab-case")]
1049pub enum AiDENsRunFailureClassV1 {
1050    None,
1051    ProviderUnavailable,
1052    ToolBlocked,
1053    ToolFailed,
1054    BudgetExhausted,
1055    VerificationUnavailable,
1056    ReplayMismatch,
1057    OperatorAbstained,
1058}
1059
1060#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1061pub struct AiDENsRunEventLogDigestV1 {
1062    pub event_log_path: String,
1063    pub digest: StackContentDigest,
1064    pub replay_normalized_digest: StackContentDigest,
1065    pub canonical_record_count: usize,
1066    pub event_count: usize,
1067    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1068    pub reason_codes: Vec<String>,
1069}
1070
1071#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1072pub struct AiDENsRunBudgetDeadlineV1 {
1073    pub max_steps: u32,
1074    pub max_tool_calls: u32,
1075    pub max_retries: u32,
1076    pub max_turn_millis: u64,
1077    pub elapsed_ms: i64,
1078    pub deadline: Option<String>,
1079    pub cost_budget_units: Option<u64>,
1080    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1081    pub degradation_markers: Vec<String>,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1085pub struct AiDENsRunReplayNormalizationV1 {
1086    pub replay_command: String,
1087    pub fixture_path: Option<String>,
1088    pub normalized_fields: Vec<String>,
1089    pub deterministic_compare: bool,
1090    pub normalized_digest: StackContentDigest,
1091    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1092    pub reason_codes: Vec<String>,
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1096pub struct AiDENsRunFailureTaxonomyV1 {
1097    pub class: AiDENsRunFailureClassV1,
1098    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1099    pub reason_codes: Vec<String>,
1100    pub degraded: bool,
1101    pub blocked: bool,
1102}