Skip to main content

rustev_contract/
run.rs

1//! Run records, `rustev.run/1` (spec 003, 3.9): what the runtime delivers for
2//! one admitted decision. The core's evidence record is carried unchanged in
3//! its own member; everything else is a runtime observation, recorded and
4//! never promised to repeat (principle XIII).
5//!
6//! Also the cost and cancellation vocabulary the backend seam reports in
7//! (spec 003, 3.5 and 3.7), so an outside reader needs this crate alone.
8
9use serde::{Deserialize, Serialize};
10
11use crate::evidence::EvidenceRecord;
12use crate::execution::FailureClass;
13use crate::ids::{ArtifactId, ExecutionPolicyId, PlanId};
14use crate::judgment::Unresolved;
15use crate::limits::RECORD_V1;
16use crate::optimization::{OptimizationOutcome, OptimizationRecord};
17use crate::remote::{MAX_TEXT_BYTES, RemoteCode, ServedIdentity};
18use crate::schema;
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
21#[serde(deny_unknown_fields)]
22pub struct RunRecord {
23    pub schema: String,
24    /// Supplied by the caller; the delivery key sinks deduplicate by.
25    pub decision_id: String,
26    pub plan_id: PlanId,
27    pub execution: RecordedExecution,
28    pub termination: Termination,
29    pub core: CoreEvidence,
30    pub timing: Timing,
31    /// In the order the core listed them.
32    pub requests: Vec<RequestRecord>,
33    pub cost: CostSummary,
34    /// `rustev.run/2` only (spec 020, 3.2): at most one entry per remote
35    /// attempt, in attempt order, at most [`MAX_REMOTE_EVIDENCE`]. Absent in
36    /// every other version, so their bytes are unchanged.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub remote_evidence: Option<Vec<RemoteEvidence>>,
39    /// `rustev.run/2` only: remote attempts beyond the bound have no entry.
40    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
41    pub remote_evidence_truncated: bool,
42    /// Spec 036: omitted when the plan has no acquisition step.
43    #[serde(
44        default,
45        skip_serializing_if = "Option::is_none",
46        deserialize_with = "crate::acquisition_record::list"
47    )]
48    #[schemars(
49        with = "Vec<crate::acquisition_record::AcquisitionRecord>",
50        length(min = 1, max = 4096)
51    )]
52    pub acquisitions: Option<Vec<crate::acquisition_record::AcquisitionRecord>>,
53    /// `rustev.run/3` only (spec 044, 3.6.6): omitted when the plan has no
54    /// decision-result cache.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub decision_cache: Option<crate::optimization::DecisionCacheRecord>,
57}
58
59// `rustev.run/3` is `rustev.run/1` with its core member holding
60// `rustev.evidence/2` (spec 032, 3.10.3); `rustev.run/2` is `rustev.run/1`
61// with a remote evidence list (spec 020, 3.2).
62crate::document::versioned_document!(
63    RunRecord,
64    [schema::RUN, schema::RUN_V2, schema::RUN_V3],
65    "rustev.run/1 or rustev.run/2 or rustev.run/3",
66    RECORD_V1,
67    check = RunRecord::parsed_members_fit
68);
69
70/// The most remote evidence entries one `rustev.run/2` record holds.
71pub const MAX_REMOTE_EVIDENCE: usize = 256;
72/// The most canonical bytes of one remote evidence entry, so the list never
73/// exceeds 2 MiB.
74pub const MAX_REMOTE_EVIDENCE_ENTRY_BYTES: usize = 8 * 1024;
75/// The most instance ids an entry names.
76pub const MAX_REMOTE_EVIDENCE_INSTANCE: usize = 8;
77
78/// Why a record has no version 1 projection, or does not fit its version.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum RunVersionError {
81    /// Only a `rustev.run/2` record projects to version 1.
82    NotVersion2,
83    /// A version member is present where the schema has none, absent where
84    /// it is required, or beyond its bounds.
85    Members(&'static str),
86}
87
88impl std::fmt::Display for RunVersionError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            RunVersionError::NotVersion2 => f.write_str("only rustev.run/2 projects to version 1"),
92            RunVersionError::Members(why) => write!(f, "run record members: {why}"),
93        }
94    }
95}
96
97impl std::error::Error for RunVersionError {}
98
99impl RunRecord {
100    /// Whether the version-specific members fit the schema: the remote
101    /// evidence list exactly in `rustev.run/2`, within its bounds.
102    pub fn check_version_members(&self) -> Result<(), RunVersionError> {
103        if self.schema != schema::RUN_V3
104            && self
105                .requests
106                .iter()
107                .any(|r| r.screen.is_some() || r.result == RequestResult::Withdrawn)
108        {
109            return Err(RunVersionError::Members("screening requires rustev.run/3"));
110        }
111        if let Some(pages) = &self.acquisitions {
112            if self.schema != schema::RUN_V3 {
113                return Err(RunVersionError::Members(
114                    "acquisitions require rustev.run/3",
115                ));
116            }
117            if pages.iter().map(|p| p.attempts.len()).sum::<usize>() > 4096 {
118                return Err(RunVersionError::Members("too many acquisition attempts"));
119            }
120        }
121        if let Some(d) = &self.decision_cache {
122            if self.schema != schema::RUN_V3 {
123                return Err(RunVersionError::Members(
124                    "decision_cache requires rustev.run/3",
125                ));
126            }
127            if d.diagnostics.len() > crate::optimization::MAX_OPTIMIZATION_DIAGNOSTICS {
128                return Err(RunVersionError::Members(
129                    "too many decision cache diagnostics",
130                ));
131            }
132        }
133        let v2 = self.schema == schema::RUN_V2;
134        match &self.remote_evidence {
135            None if v2 => Err(RunVersionError::Members(
136                "rustev.run/2 needs remote_evidence",
137            )),
138            Some(_) if !v2 => Err(RunVersionError::Members(
139                "remote_evidence is a rustev.run/2 member",
140            )),
141            _ if self.remote_evidence_truncated && !v2 => Err(RunVersionError::Members(
142                "remote_evidence_truncated is a rustev.run/2 member",
143            )),
144            Some(list) if list.len() > MAX_REMOTE_EVIDENCE => {
145                Err(RunVersionError::Members("too many remote evidence entries"))
146            }
147            Some(list) if !list.iter().all(RemoteEvidence::is_bounded) => Err(
148                RunVersionError::Members("a remote evidence entry beyond its bounds"),
149            ),
150            _ => Ok(()),
151        }
152    }
153
154    /// The parser's version rule: a record carries the remote evidence
155    /// members exactly in `rustev.run/2`, within their bounds, so a version
156    /// 1 or 3 record with them, or a version 2 record without the list, is
157    /// refused when read, not only when projected.
158    fn parsed_members_fit(&self) -> Result<(), String> {
159        if let CoreEvidence::Recorded(e) = &self.core {
160            e.screening_version()?;
161            if self.schema != schema::RUN_V3
162                && (e.extraction.is_some()
163                    || e.acceptance.is_some()
164                    || e.inputs.iter().any(|i| i.producer.is_some())
165                    || e.judgment.lineage.producers.is_some())
166            {
167                return Err("extraction evidence requires rustev.run/3".into());
168            }
169        }
170        if let CoreEvidence::Recorded(e) = &self.core
171            && e.generation.is_some()
172            && (self.schema != schema::RUN_V3 || e.schema != schema::EVIDENCE_V2)
173        {
174            return Err("generation evidence requires rustev.run/3 and rustev.evidence/2".into());
175        }
176        if let CoreEvidence::Recorded(e) = &self.core
177            && (self.schema != schema::RUN_V3 || e.schema != schema::EVIDENCE_V2)
178            && (e.acquisitions.is_some() || e.steps.iter().any(|s| s.constructed))
179        {
180            return Err("acquisition evidence requires rustev.evidence/2".into());
181        }
182
183        self.check_version_members().map_err(|e| e.to_string())
184    }
185
186    /// The deterministic version 1 projection of a `rustev.run/2` record
187    /// (spec 020, 3.2): the remote evidence removed, the schema tag
188    /// changed, every other member exactly as it is, so the canonical bytes
189    /// are the version 1 representation. Refused, never partial, for any
190    /// other record.
191    pub fn project_v1(&self) -> Result<RunRecord, RunVersionError> {
192        if self.schema != schema::RUN_V2 {
193            return Err(RunVersionError::NotVersion2);
194        }
195        self.check_version_members()?;
196        Ok(RunRecord {
197            schema: schema::RUN.into(),
198            remote_evidence: None,
199            remote_evidence_truncated: false,
200            ..self.clone()
201        })
202    }
203}
204
205/// One remote attempt's evidence reference in `rustev.run/2` (spec 020,
206/// 3.2, I-4). It names the exchange record, never embeds it: no request or
207/// response body, credential, secret address, unredacted principal
208/// reference, provider reasoning or header.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
210#[serde(deny_unknown_fields)]
211pub struct RemoteEvidence {
212    pub attempt_id: String,
213    /// The decision step and logical request (instance) the attempt served.
214    pub step: String,
215    pub instance: Vec<String>,
216    pub exchange: ExchangeReference,
217    /// What the adapter asked for; `None` when it names no model.
218    pub requested_identity: Option<String>,
219    pub served: ServedIdentity,
220    /// The adapter binding (its artifact), the protocol or foreign mapping
221    /// version, and the endpoint class (never a secret URL).
222    pub binding: ArtifactId,
223    pub protocol: String,
224    pub endpoint_class: String,
225    /// The remote error code of a failed attempt.
226    pub code: Option<RemoteCode>,
227    /// The attempt's final remote state, as the run record has it.
228    pub remote_state: RemoteState,
229    pub charge_provenance: ChargeProvenance,
230    /// Present when the attempt was a member of a batched exchange.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub batch: Option<BatchMembership>,
233    /// The principal reference's evidence digest, only when one was sent.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub principal_digest: Option<String>,
236}
237
238impl RemoteEvidence {
239    /// Every string within [`MAX_TEXT_BYTES`], the instance within
240    /// [`MAX_REMOTE_EVIDENCE_INSTANCE`] ids, and the canonical bytes within
241    /// [`MAX_REMOTE_EVIDENCE_ENTRY_BYTES`].
242    pub fn is_bounded(&self) -> bool {
243        self.strings_within(MAX_TEXT_BYTES)
244            && crate::canonical::canonical_bytes(self)
245                .is_ok_and(|b| b.len() <= MAX_REMOTE_EVIDENCE_ENTRY_BYTES)
246    }
247
248    fn strings_within(&self, max: usize) -> bool {
249        let t = |s: &str| s.len() <= max;
250        let o = |s: &Option<String>| s.as_deref().is_none_or(t);
251        t(&self.attempt_id)
252            && t(&self.step)
253            && self.instance.len() <= MAX_REMOTE_EVIDENCE_INSTANCE
254            && self.instance.iter().all(|i| t(i))
255            && self.exchange.is_bounded(max)
256            && o(&self.requested_identity)
257            && o(&self.served.identity)
258            && t(&self.protocol)
259            && t(&self.endpoint_class)
260            && self.batch.as_ref().is_none_or(|b| t(&b.batch_id))
261            && o(&self.principal_digest)
262    }
263
264    /// The entry cut to its bounds: strings to [`MAX_TEXT_BYTES`], and when
265    /// the canonical bytes are still beyond the entry bound (strings that
266    /// escape to several bytes each), to 32 bytes. Deterministic.
267    pub fn bounded(self) -> Self {
268        let e = self.cut(MAX_TEXT_BYTES);
269        if e.is_bounded() { e } else { e.cut(32) }
270    }
271
272    fn cut(mut self, max: usize) -> Self {
273        let c = |s: &mut String| {
274            if s.len() > max {
275                *s = crate::remote::cut_to(s, max);
276            }
277        };
278        c(&mut self.attempt_id);
279        c(&mut self.step);
280        self.instance.truncate(MAX_REMOTE_EVIDENCE_INSTANCE);
281        self.instance.iter_mut().for_each(c);
282        if let ExchangeReference::Retained {
283            digest,
284            schema,
285            locator,
286            ..
287        } = &mut self.exchange
288        {
289            c(digest);
290            c(schema);
291            c(locator);
292        }
293        if let Some(r) = &mut self.requested_identity {
294            c(r);
295        }
296        if let Some(s) = &mut self.served.identity {
297            c(s);
298        }
299        c(&mut self.protocol);
300        c(&mut self.endpoint_class);
301        if let Some(b) = &mut self.batch {
302            c(&mut b.batch_id);
303        }
304        if let Some(p) = &mut self.principal_digest {
305            c(p);
306        }
307        self
308    }
309}
310
311/// Where the exchange record of an attempt is, or why it cannot be named.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
313#[serde(rename_all = "snake_case", deny_unknown_fields)]
314pub enum ExchangeReference {
315    Retained {
316        /// The exchange record's record digest.
317        digest: String,
318        /// The exchange record's schema.
319        schema: String,
320        retention: RetentionClass,
321        /// An opaque host reference, never a URL with credentials.
322        locator: String,
323    },
324    Unavailable {
325        reason: EvidenceUnavailable,
326    },
327}
328
329impl ExchangeReference {
330    fn is_bounded(&self, max: usize) -> bool {
331        match self {
332            ExchangeReference::Retained {
333                digest,
334                schema,
335                locator,
336                ..
337            } => [digest, schema, locator].iter().all(|s| s.len() <= max),
338            ExchangeReference::Unavailable { .. } => true,
339        }
340    }
341}
342
343/// What the host retained of an exchange.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
345#[serde(rename_all = "snake_case")]
346pub enum RetentionClass {
347    /// The record with digests only (spec 009, 3.9.3).
348    DigestOnly,
349    /// The record and the request and response bytes.
350    BytesRetained,
351}
352
353/// Why an attempt's exchange record cannot be named (spec 020, 3.2): stated,
354/// never reconstructed.
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
356#[serde(rename_all = "snake_case")]
357pub enum EvidenceUnavailable {
358    /// Nothing was exchanged: the attempt was refused before any request.
359    NoExchange,
360    /// The exchange had not ended when the record was built.
361    Pending,
362    /// The exchange sink refused or failed the record.
363    NotDelivered,
364    /// The exchange sink accepted the record but named no locator.
365    NoLocator,
366}
367
368/// How the attempt's charge was established.
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
370#[serde(rename_all = "snake_case")]
371pub enum ChargeProvenance {
372    /// Reported by the remote side for this attempt's own exchange.
373    Reported,
374    /// A share of a batched exchange's reported charge.
375    Attributed,
376    /// No request byte left the process: nothing was charged.
377    NothingSent,
378    /// Not known; the reservation stays liability.
379    Unknown,
380}
381
382/// An attempt's place in a batched exchange.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
384#[serde(deny_unknown_fields)]
385pub struct BatchMembership {
386    pub batch_id: String,
387    pub index: u32,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
391#[serde(rename_all = "snake_case", deny_unknown_fields)]
392pub enum RecordedExecution {
393    None,
394    Declared(ExecutionPolicyId),
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
398#[serde(rename_all = "snake_case")]
399pub enum Termination {
400    /// Every request resolved and the core finished a judgment.
401    Judged,
402    /// The caller cancelled; no judgment was finished.
403    Cancelled,
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
407#[serde(rename_all = "snake_case", deny_unknown_fields)]
408pub enum CoreEvidence {
409    /// Exactly what the core's `finish` returned.
410    Recorded(Box<EvidenceRecord>),
411    NotProduced,
412}
413
414/// Monotonic milliseconds from submission; never wall-clock time.
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
416#[serde(deny_unknown_fields)]
417pub struct Timing {
418    pub queued_ms: u64,
419    pub elapsed_ms: u64,
420    pub deadline_ms: u64,
421    pub deadline_expired: bool,
422}
423
424#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
425#[serde(deny_unknown_fields)]
426pub struct RequestRecord {
427    pub step: String,
428    pub instance: Vec<String>,
429    pub result: RequestResult,
430    pub attempts: Vec<AttemptRecord>,
431    pub transitions: Vec<Transition>,
432    /// Absent for unoptimized execution, preserving existing record bytes.
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub optimization: Option<OptimizationRecord>,
435    /// Screening cache and text-unit observations (spec 035, 3.13.2).
436    #[serde(
437        default,
438        skip_serializing_if = "Option::is_none",
439        deserialize_with = "crate::screening::present"
440    )]
441    #[schemars(with = "crate::screening::ScreenRequest")]
442    pub screen: Option<crate::screening::ScreenRequest>,
443}
444
445/// Where a supplied value came from (spec 004, 3.2).
446#[derive(Debug, Clone, Copy, PartialEq)]
447pub struct SuppliedFrom<'r> {
448    /// The producing target of an output; for a failure, the `to` of the
449    /// last `fallback` transition, else 0.
450    pub target: u32,
451    /// The producing attempt: an output's last attempt; a failure's last
452    /// attempt only when no `retry` or `fallback` transition has an `after`
453    /// equal to its ordinal.
454    pub attempt: Option<&'r AttemptRecord>,
455    /// For an output reused from a cache entry (spec 017, 3.8): the attempt
456    /// of an earlier decision that produced it. Set only when the record has
457    /// no attempt of its own and its optimization record names a hit.
458    pub reused_from: Option<&'r str>,
459}
460
461impl RequestRecord {
462    /// Where this request's supplied value came from; `None` when nothing was
463    /// supplied. The one derivation runtime capture and replay both use.
464    pub fn supplied_from(&self) -> Option<SuppliedFrom<'_>> {
465        match &self.result {
466            RequestResult::NotSupplied | RequestResult::Withdrawn => None,
467            RequestResult::Output { target } => Some(SuppliedFrom {
468                target: *target,
469                attempt: self.attempts.last(),
470                reused_from: self
471                    .optimization
472                    .as_ref()
473                    .filter(|o| self.attempts.is_empty() && o.outcome == OptimizationOutcome::Hit)
474                    .and_then(|o| o.source_attempt_id.as_deref()),
475            }),
476            RequestResult::Failed(_) => {
477                let target = self
478                    .transitions
479                    .iter()
480                    .rev()
481                    .find_map(|t| match t {
482                        Transition::Fallback { to, .. } => Some(*to),
483                        _ => None,
484                    })
485                    .unwrap_or(0);
486                let n = self.attempts.len() as u32;
487                let moved_on = self.transitions.iter().any(|t| {
488                    matches!(t, Transition::Retry { after, .. } | Transition::Fallback { after, .. } if *after == n)
489                });
490                Some(SuppliedFrom {
491                    target,
492                    attempt: if moved_on { None } else { self.attempts.last() },
493                    reused_from: None,
494                })
495            }
496        }
497    }
498}
499
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
501#[serde(rename_all = "snake_case", deny_unknown_fields)]
502pub enum RequestResult {
503    /// An output was supplied, from this target (0 is the bound backend).
504    Output { target: u32 },
505    /// This runtime reason was supplied.
506    Failed(Unresolved),
507    /// Nothing was supplied: the decision was cancelled first.
508    NotSupplied,
509    /// Not dispatched because a screening refusal decided the outcome.
510    Withdrawn,
511}
512
513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
514#[serde(deny_unknown_fields)]
515pub struct AttemptRecord {
516    /// `<decision id>/<step>/<instance ids joined by ','>/<n>`, each
517    /// component with `%`, `/` and `,` percent-escaped.
518    pub attempt_id: String,
519    pub target: u32,
520    pub backend_id: String,
521    pub artifact: ArtifactId,
522    pub dispatched_ms: u64,
523    pub ended_ms: u64,
524    pub end: AttemptEnd,
525    pub cancellation: Cancellation,
526    pub remote: RemoteState,
527    pub cost: AttemptCost,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
531#[serde(rename_all = "snake_case", deny_unknown_fields)]
532pub enum AttemptEnd {
533    /// A valid output.
534    Output,
535    Failed {
536        class: FailureClass,
537        detail: String,
538    },
539    /// The decision deadline passed while it was in flight.
540    Deadline,
541    /// The caller cancelled while it was in flight.
542    Cancelled,
543}
544
545/// What is known about cancelling an attempt (spec 003, 3.7.3).
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
547#[serde(rename_all = "snake_case", deny_unknown_fields)]
548pub enum Cancellation {
549    NotRequested,
550    Requested { answer: CancelAnswer },
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
554#[serde(rename_all = "snake_case")]
555pub enum CancelAnswer {
556    /// The backend confirmed that its work and charges stopped.
557    Stopped,
558    /// The backend answered without confirming a remote stop.
559    Unconfirmed,
560    /// The runtime stopped waiting before any answer.
561    NotObserved,
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
565#[serde(rename_all = "snake_case")]
566pub enum RemoteState {
567    /// The backend returned a result of its own accord.
568    Finished,
569    /// The backend acknowledged a cancellation as stopped.
570    Stopped,
571    /// Nothing establishes that remote work or charges stopped.
572    PossiblyContinuing,
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
576#[serde(deny_unknown_fields)]
577pub struct AttemptCost {
578    pub bound: CostBound,
579    pub reserved: u64,
580    pub charge: Charge,
581}
582
583/// A backend's static cost disclosure.
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
585#[serde(rename_all = "snake_case")]
586pub enum CostModel {
587    /// Every call has an enforceable upper bound.
588    Bounded,
589    /// Calls have estimates, not bounds.
590    Estimated,
591    Unknown,
592}
593
594/// A backend's per-call cost disclosure, before dispatch.
595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
596#[serde(rename_all = "snake_case", deny_unknown_fields)]
597pub enum CostBound {
598    /// The call cannot cost more than this.
599    Bounded {
600        max_units: u64,
601    },
602    Estimated {
603        units: u64,
604    },
605    Unknown,
606}
607
608/// What an attempt cost, as far as is known when it ends.
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
610#[serde(rename_all = "snake_case", deny_unknown_fields)]
611pub enum Charge {
612    Observed { units: u64 },
613    Estimated { units: u64 },
614    Unknown,
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
618#[serde(rename_all = "snake_case", deny_unknown_fields)]
619pub enum Transition {
620    /// Retry the same target after attempt `after`.
621    Retry {
622        after: u32,
623        class: FailureClass,
624        delay_ms: u64,
625    },
626    /// Move to fallback target `to` after attempt `after`.
627    Fallback {
628        after: u32,
629        class: FailureClass,
630        to: u32,
631    },
632    /// Stop after attempt `after` (0 when nothing was dispatched).
633    Stop { after: u32, reason: String },
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
637#[serde(rename_all = "snake_case")]
638pub enum CostMode {
639    Unlimited,
640    Hard,
641    Estimated,
642}
643
644/// A decision's cost at the moment its record was built (spec 003, 3.5.6).
645#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
646#[serde(deny_unknown_fields)]
647pub struct CostSummary {
648    pub mode: CostMode,
649    /// 0 under `unlimited`.
650    pub limit: u64,
651    pub observed: u64,
652    pub estimated: u64,
653    /// Reservations kept because the final cost is unknown.
654    pub liability: u64,
655    /// Observed charges above a `bounded` reservation.
656    pub bound_violations: u64,
657    pub final_cost: FinalCost,
658    /// True only under `hard` with no liability and no bound violation.
659    pub within_guaranteed_cap: bool,
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
663#[serde(rename_all = "snake_case")]
664pub enum FinalCost {
665    Known,
666    IncludesEstimates,
667    Unknown,
668}