Skip to main content

turnframe_core/
error.rs

1//! The typed error family of spec §24.
2//!
3//! Every error carries identifiers and codes only. `Display` output is safe to
4//! log: it never includes secrets, free user text, quotes or payloads.
5//!
6//! [`OrchestratorError`] is the top-level sum type the runtime returns.
7//! [`ErrorClassification`] answers the five operational questions of §24 for it:
8//! is it retryable, may an effect have happened, which user-safe message key
9//! applies, how severe is it, and does it require a reconciliation job.
10
11use serde::{Deserialize, Serialize};
12
13use crate::case::CaseRef;
14use crate::command::RiskClass;
15use crate::ids::{
16    CaseRevision, CommandId, ConversationId, InteractionId, ModelKey, OperationKey, OptionId,
17    ProviderKey, TargetToken, WorkflowKey, WorkflowVersion, string_id,
18};
19use crate::interaction::{InteractionKind, InteractionRejection, InteractionStatus};
20use crate::locale::LocalizedText;
21use crate::plan::limits::PlanLimitError;
22use crate::reduce::CommandRef;
23use crate::understanding::ActId;
24
25pub use crate::event::UnknownOutcome;
26pub use crate::hash::HashError;
27
28string_id! {
29    /// Application-defined rejection code (e.g. `"trip.traveler_missing"`).
30    RejectionCode
31}
32
33/// The rejection code a workflow uses when `compile_act` does not recognise an
34/// operation at all.
35///
36/// # Why this one string is reserved
37///
38/// A workflow declares its operations in `interpretation_catalog` and turns
39/// them into commands in `compile_act`. The two are different functions and
40/// nothing relates them, so an operation added to one and forgotten in the
41/// other fails as far downstream as a mistake can: the catalogue offers it, the
42/// interpreter proposes it correctly, and the user is told his request could
43/// not be carried out — on a sentence that was understood perfectly.
44///
45/// A workflow that returns *this* code says which of the two it is, and two
46/// things follow. The state explorer walks every reachable state, asks each
47/// state's catalogue whether `compile_act` knows its operations, and reports
48/// the ones that come back with it — the drift becomes a test failure in the
49/// workflow's own suite instead of a sentence a user reads three times. And the
50/// runtime treats it as its own refusal rather than the domain's, because a
51/// catalogue and a compiler that disagree is a defect in the deployment and not
52/// an outcome for the user.
53///
54/// A workflow that uses its own code keeps today's behaviour exactly: its words
55/// reach the user and nothing checks the drift. The check sees what the
56/// workflow declares, and this constant is the declaration.
57pub const UNKNOWN_OPERATION: &str = "turnframe.operation.unknown";
58
59/// A domain refused an act or a command (spec §8.2).
60///
61/// `details` is application-defined structured data for the UI; it must not be
62/// interpolated into logs.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
64#[error("domain rejected the request with code {code} (message key {message_key})")]
65pub struct DomainRejection {
66    /// Stable machine-readable code.
67    pub code: RejectionCode,
68    /// Key of the user-facing message in the application's copy catalog.
69    pub message_key: String,
70    /// Structured details (field names, limits...).
71    ///
72    /// Boxed for the same reason [`Self::explanation`] is: a rejection travels
73    /// in the `Err` half of every compile and validate call, so the size of the
74    /// unhappy path is the size of every call's return value. A JSON document
75    /// is the largest thing here and the least often read.
76    #[serde(default)]
77    pub details: Box<serde_json::Value>,
78    /// Why, in words the user can read.
79    ///
80    /// [`Self::message_key`] names copy in the application's own catalog, which
81    /// is the right shape when an application has one and no use to the runtime
82    /// when it does not: composition has to write a sentence and cannot resolve
83    /// a key it knows nothing about. So a rejection may carry its own copy, the
84    /// way a receipt does.
85    ///
86    /// When it does, the turn tells the user the act was refused and why.
87    /// When it does not, the turn still says an act was refused, in the
88    /// runtime's own words, because the alternative is what this field was
89    /// added to fix: an assistant that cannot report a failure says something
90    /// unrelated instead, and the user concludes the write succeeded.
91    ///
92    /// Boxed because a `DomainRejection` travels inside the `Err` half of every
93    /// compile and validate call, and copy is much larger than a code: putting
94    /// it inline widens the error variant that every one of those returns.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub explanation: Option<Box<LocalizedText>>,
97    /// The argument the rejection is about, as a JSON Pointer into the act's arguments.
98    ///
99    /// Present, the value can be asked for again; absent, the act is refused as it stands.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub argument: Option<String>,
102}
103
104impl DomainRejection {
105    /// Builds a rejection without details.
106    #[must_use]
107    pub fn new(code: impl Into<RejectionCode>, message_key: impl Into<String>) -> Self {
108        Self {
109            code: code.into(),
110            explanation: None,
111            argument: None,
112            message_key: message_key.into(),
113            details: Box::new(serde_json::Value::Null),
114        }
115    }
116
117    /// Attaches structured details.
118    #[must_use]
119    pub fn with_details(mut self, details: serde_json::Value) -> Self {
120        self.details = Box::new(details);
121        self
122    }
123
124    /// Says the rejection is about one argument, which the user may give again.
125    #[must_use]
126    pub fn on_argument(mut self, pointer: impl Into<String>) -> Self {
127        self.argument = Some(pointer.into());
128        self
129    }
130
131    /// Attaches the sentence the user should read.
132    ///
133    /// Write it the way you write a receipt's body: server-authored copy, in
134    /// the languages the workflow answers in. It reaches the user as a notice
135    /// whether or not a model runs, and the narrator is shown it so its prose
136    /// does not contradict what the notice says.
137    #[must_use]
138    pub fn with_explanation(mut self, explanation: LocalizedText) -> Self {
139        self.explanation = Some(Box::new(explanation));
140        self
141    }
142}
143
144/// A command targeted a revision that is no longer current (I13).
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
146#[error(
147    "revision conflict on {}/{}: expected {}, current {current_revision}",
148    expected.workflow, expected.case_id, expected.expected_revision
149)]
150pub struct RevisionConflict {
151    /// The reference the command was planned against.
152    pub expected: CaseRef,
153    /// The revision actually found.
154    pub current_revision: CaseRevision,
155}
156
157/// Errors raised by persistence adapters.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
159#[non_exhaustive]
160pub enum StoreError {
161    /// The requested record does not exist for this account. Never reveals
162    /// whether it exists for another tenant.
163    #[error("record not found")]
164    NotFound,
165    /// A uniqueness or compare-and-swap constraint failed.
166    #[error("store constraint conflict")]
167    Conflict,
168    /// The store could not be reached.
169    #[error("store unavailable")]
170    Unavailable,
171    /// The operation timed out; a write may or may not have landed.
172    #[error("store operation timed out")]
173    Timeout,
174    /// A stored payload could not be (de)serialized.
175    #[error("stored payload could not be serialized or deserialized")]
176    Serialization,
177    /// The stored data violates an invariant the adapter relies on.
178    #[error("stored data is corrupt")]
179    Corrupt,
180    /// Adapter-specific failure identified by a stable code.
181    #[error("store failure {code}")]
182    Other {
183        /// Stable adapter-defined code.
184        code: String,
185    },
186}
187
188/// Errors raised while executing a command batch.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
190#[non_exhaustive]
191pub enum ExecutionError {
192    /// The expected revision was stale.
193    #[error(transparent)]
194    RevisionConflict(RevisionConflict),
195    /// The domain refused the command.
196    #[error(transparent)]
197    Rejected(DomainRejection),
198    /// Persistence failed.
199    #[error(transparent)]
200    Store(StoreError),
201    /// An external effect was attempted and its outcome is unknown (I15).
202    #[error(transparent)]
203    OutcomeUnknown(UnknownOutcome),
204    /// The idempotency key was seen before with a different command payload.
205    #[error("idempotency key reused with a different command {command_id}")]
206    IdempotencyMismatch {
207        /// The offending command.
208        command_id: CommandId,
209    },
210    /// The batch mixed cases while the scope required a single case.
211    #[error("batch scope violation")]
212    ScopeViolation,
213    /// Execution exceeded its time budget; a commit may have happened.
214    #[error("execution timed out")]
215    Timeout,
216    /// The erased boundary could not convert a command, event or state.
217    #[error(transparent)]
218    Erasure(ErasureError),
219    /// Executor-specific failure identified by a stable code.
220    #[error("execution failure {code}")]
221    Other {
222        /// Stable executor-defined code.
223        code: String,
224    },
225}
226
227impl From<ErasureError> for ExecutionError {
228    fn from(value: ErasureError) -> Self {
229        Self::Erasure(value)
230    }
231}
232
233/// A projection violated one of the Flow Map invariants (spec §8.4).
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
235#[error("invariant violation on {}/{}: {kind}", case_ref.workflow, case_ref.case_id)]
236pub struct InvariantViolation {
237    /// The projected case.
238    pub case_ref: CaseRef,
239    /// Which rule was broken.
240    pub kind: InvariantViolationKind,
241}
242
243/// The individual Flow Map invariants.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
245#[serde(tag = "kind", rename_all = "snake_case")]
246#[non_exhaustive]
247pub enum InvariantViolationKind {
248    /// An outcome is present while obligations remain open.
249    #[error("outcome present while {obligation_count} obligations remain")]
250    OutcomeWithObligations {
251        /// Number of open obligations.
252        obligation_count: usize,
253    },
254    /// A user-owned phase has no blocking interaction requirement (I6).
255    #[error("user-owned phase without a blocking interaction")]
256    MissingBlockingInteraction,
257    /// A terminal phase declares a blocking interaction.
258    #[error("blocking interaction on a terminal phase")]
259    BlockingInteractionOnTerminalPhase,
260    /// A system- or external-owned phase declares a blocking interaction.
261    #[error("blocking interaction on a phase not owned by the user")]
262    BlockingInteractionOnNonUserPhase,
263    /// The requirement in `blocking_interaction` is flagged non-blocking.
264    #[error("blocking_interaction slot holds a non-blocking requirement")]
265    NonBlockingRequirementInBlockingSlot,
266    /// A terminal phase has no outcome.
267    #[error("terminal phase without outcome")]
268    TerminalPhaseWithoutOutcome,
269    /// A non-terminal phase carries an outcome.
270    #[error("outcome present on a non-terminal phase")]
271    OutcomeOnNonTerminalPhase,
272    /// Two obligations serialize to the same stable identifier.
273    #[error("duplicate obligation id {obligation_id}")]
274    DuplicateObligation {
275        /// The repeated identifier (canonical JSON of the obligation).
276        obligation_id: String,
277    },
278    /// An obligation could not be serialized to derive its identifier.
279    #[error("obligation could not be serialized")]
280    UnserializableObligation,
281    /// The blocking requirement carries a payload the user could not answer.
282    #[error("blocking interaction cannot be answered: {error}")]
283    UnanswerableBlockingInteraction {
284        /// Why the card is unanswerable.
285        error: InteractionSpecError,
286    },
287}
288
289/// Target resolution failed for an act (spec §12).
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
291#[serde(tag = "kind", rename_all = "snake_case")]
292#[non_exhaustive]
293pub enum TargetError {
294    /// Several authorized cases match; a selection interaction is required.
295    #[error("ambiguous target with {candidate_count} candidates")]
296    Ambiguous {
297        /// Number of candidates.
298        candidate_count: usize,
299    },
300    /// The token was issued this turn but the case is gone.
301    #[error("target {token} missing")]
302    Missing {
303        /// The token.
304        token: TargetToken,
305    },
306    /// The token is unknown or belongs to another tenant (indistinguishable).
307    #[error("target {token} unauthorized")]
308    Unauthorized {
309        /// The token.
310        token: TargetToken,
311    },
312    /// The case moved past the revision the token was issued at.
313    #[error("target {token} stale: issued at {issued_revision}, current {current_revision}")]
314    Stale {
315        /// The token.
316        token: TargetToken,
317        /// Revision at issue time.
318        issued_revision: CaseRevision,
319        /// Revision now.
320        current_revision: CaseRevision,
321    },
322    /// A `Mention` target could not be matched to any candidate.
323    #[error("mention could not be resolved for workflow {workflow}")]
324    MentionUnresolved {
325        /// Workflow named by the mention.
326        workflow: WorkflowKey,
327    },
328    /// `ActiveInteraction` was used but no active blocking interaction exists.
329    #[error("no active interaction to target")]
330    NoActiveInteraction,
331    /// The act's target policy forbids the proposed target kind.
332    #[error("target kind not allowed by the policy of operation {operation}")]
333    PolicyMismatch {
334        /// The operation whose policy was violated.
335        operation: OperationKey,
336    },
337}
338
339/// The whole-turn reducer could not produce a plan (spec §13).
340///
341/// The wire form is adjacently tagged (`{"kind": ..., "detail": ...}`): several
342/// variants wrap another tagged error, and an internal tag would write two
343/// `kind` keys into one object, so the outer variant was lost on the way back.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
345#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
346#[non_exhaustive]
347pub enum ReductionError {
348    /// Plan limits were exceeded.
349    #[error(transparent)]
350    Limits(PlanLimitError),
351    /// An act names an operation not present in the catalog.
352    #[error("act {act} uses unknown operation {operation}")]
353    UnknownOperation {
354        /// The act.
355        act: ActId,
356        /// The operation.
357        operation: OperationKey,
358    },
359    /// An act's arguments do not satisfy the operation's input schema.
360    #[error("act {act} has invalid arguments")]
361    InvalidArguments {
362        /// The act.
363        act: ActId,
364        /// Validation detail.
365        error: SchemaValidationError,
366    },
367    /// An act references a case view that is not in the context.
368    #[error("act {act} references a case that is not loaded")]
369    CaseNotLoaded {
370        /// The act.
371        act: ActId,
372    },
373    /// The produced plan is inconsistent (missing act results, dangling refs).
374    #[error("reduction plan inconsistent: {detail}")]
375    InconsistentPlan {
376        /// Stable description of the inconsistency (no user text).
377        detail: String,
378    },
379    /// Hashing the plan failed.
380    #[error("plan hash could not be computed")]
381    Hash,
382    /// Two act definitions were offered under the same operation key, so one
383    /// would silently shadow the other.
384    #[error("duplicate operation {operation} in the act catalog")]
385    DuplicateOperation {
386        /// The repeated key.
387        operation: OperationKey,
388    },
389    /// Two acts contradict each other and no precedence rule applies.
390    #[error("acts {first} and {second} contradict without a precedence rule")]
391    Contradiction {
392        /// The first act.
393        first: ActId,
394        /// The second act.
395        second: ActId,
396    },
397    /// The turn compiled more commands than the runtime is configured to
398    /// execute.
399    ///
400    /// The whole turn is refused rather than a prefix executed: a plan that
401    /// runs half of what the user asked for is exactly the silent partial
402    /// application I10 and I11 exist to prevent.
403    #[error("turn compiled {actual} commands, more than the limit of {limit}")]
404    CommandBudgetExceeded {
405        /// The configured maximum.
406        limit: usize,
407        /// How many commands the turn compiled.
408        actual: usize,
409    },
410}
411
412impl From<PlanLimitError> for ReductionError {
413    fn from(value: PlanLimitError) -> Self {
414        Self::Limits(value)
415    }
416}
417
418/// A JSON Schema validation failure described by paths only.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
420#[error("schema violation at {instance_path} (schema {schema_path})")]
421pub struct SchemaValidationError {
422    /// JSON pointer into the validated instance.
423    pub instance_path: String,
424    /// JSON pointer into the schema.
425    pub schema_path: String,
426}
427
428/// Validating a JSON value against a schema failed.
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
430#[serde(tag = "kind", rename_all = "snake_case")]
431#[non_exhaustive]
432pub enum SchemaCheckError {
433    /// The schema itself could not be compiled.
434    #[error("schema could not be compiled")]
435    InvalidSchema,
436    /// The instance violates the schema.
437    #[error(transparent)]
438    Violation(SchemaValidationError),
439}
440
441/// A card as specified could not be answered, or claims an authority it may
442/// not have (spec §15.2, §15.7, I6).
443///
444/// Refusing at creation is deliberate: a persisted card with no usable option
445/// blocks its case forever, and a high-risk card that accepts typed text turns
446/// an inference into a confirmation.
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
448#[serde(tag = "kind", rename_all = "snake_case")]
449#[non_exhaustive]
450pub enum InteractionSpecError {
451    /// Two options share an identifier, so the answer would be ambiguous.
452    #[error("duplicate option id {option_id}")]
453    DuplicateOptionId {
454        /// The repeated identifier.
455        option_id: OptionId,
456    },
457    /// The card carries fewer options than its kind needs.
458    #[error("{interaction_kind:?} card needs at least {required} options, found {found}")]
459    NotEnoughOptions {
460        /// The kind.
461        interaction_kind: InteractionKind,
462        /// Options the kind requires.
463        required: usize,
464        /// Options found.
465        found: usize,
466    },
467    /// No option of a confirming card actually authorizes commands.
468    #[error("{interaction_kind:?} card has no option that authorizes commands")]
469    MissingAuthorizingOption {
470        /// The kind.
471        interaction_kind: InteractionKind,
472    },
473    /// No option lets the user decline, so refusing is impossible.
474    #[error("{interaction_kind:?} card has no declining option")]
475    MissingDeclineOption {
476        /// The kind.
477        interaction_kind: InteractionKind,
478    },
479    /// A review card shows no diff.
480    #[error("review card has no diff entries")]
481    MissingReviewEntries,
482    /// A free-form card has no prompt.
483    #[error("freeform card has no prompt")]
484    MissingFreeformPrompt,
485    /// A free-form card has no option that accepts the required text.
486    #[error("freeform card has no option requiring free text")]
487    MissingFreeformOption,
488    /// The kind cannot be answered through the input protocol.
489    #[error("{interaction_kind:?} cards cannot be persisted")]
490    UnsupportedKind {
491        /// The kind.
492        interaction_kind: InteractionKind,
493    },
494    /// Typed text may not resolve this card.
495    #[error("{interaction_kind:?} card confirming {confirms_risk:?} may not be resolved from text")]
496    TextResolutionNotAllowed {
497        /// The kind.
498        interaction_kind: InteractionKind,
499        /// What the card confirms.
500        confirms_risk: RiskClass,
501    },
502}
503
504/// Interaction lifecycle errors (spec §15).
505///
506/// The wire form is adjacently tagged (`{"kind": ..., "detail": ...}`): several
507/// variants wrap another tagged error, and an internal tag would write two
508/// `kind` keys into one object, so the outer variant was lost on the way back.
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
510#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
511#[non_exhaustive]
512pub enum InteractionError {
513    /// A client response was rejected by [`crate::interaction::validate_response`].
514    #[error(transparent)]
515    Rejected(InteractionRejection),
516    /// An illegal status transition was attempted.
517    #[error("illegal interaction transition {from:?} -> {to:?} on {interaction_id}")]
518    InvalidTransition {
519        /// The interaction.
520        interaction_id: InteractionId,
521        /// Current status.
522        from: InteractionStatus,
523        /// Requested status.
524        to: InteractionStatus,
525    },
526    /// A second active blocking interaction was requested for the same case (I5).
527    #[error("case already has an active blocking interaction {existing}")]
528    BlockingConflict {
529        /// The interaction already active.
530        existing: InteractionId,
531    },
532    /// The stored payload hash does not match the payload.
533    #[error("payload hash mismatch on {interaction_id}")]
534    PayloadHashMismatch {
535        /// The interaction.
536        interaction_id: InteractionId,
537    },
538    /// The interaction must be persisted before it can be referenced.
539    #[error("interaction not persisted")]
540    NotPersisted,
541    /// The card was refused before it could be created.
542    #[error(transparent)]
543    InvalidSpec(InteractionSpecError),
544    /// The time to live cannot be applied to the creation instant.
545    #[error("interaction time to live is out of range")]
546    InvalidTtl,
547    /// The payload could not be hashed.
548    #[error("interaction payload could not be hashed")]
549    Hash,
550}
551
552impl From<InteractionRejection> for InteractionError {
553    fn from(value: InteractionRejection) -> Self {
554        Self::Rejected(value)
555    }
556}
557
558impl From<InteractionSpecError> for InteractionError {
559    fn from(value: InteractionSpecError) -> Self {
560        Self::InvalidSpec(value)
561    }
562}
563
564/// Policy evaluation refused a command (spec §14.3, I12).
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
566#[serde(tag = "kind", rename_all = "snake_case")]
567#[non_exhaustive]
568pub enum PolicyError {
569    /// The command origin is not trusted enough for the policy.
570    #[error("command {command_ref} requires a trusted origin")]
571    UntrustedOrigin {
572        /// The command.
573        command_ref: CommandRef,
574    },
575    /// The risk class is forbidden in the current configuration (e.g. sandbox).
576    #[error("command {command_ref} has a forbidden risk class")]
577    ForbiddenRiskClass {
578        /// The command.
579        command_ref: CommandRef,
580    },
581    /// The policy engine denied the command with a reason key.
582    #[error("command {command_ref} denied ({reason_key})")]
583    Denied {
584        /// The command.
585        command_ref: CommandRef,
586        /// Key of the user-facing reason.
587        reason_key: String,
588    },
589    /// The policy source could not be consulted; the runtime fails closed.
590    #[error("policy source unavailable")]
591    Unavailable,
592    /// The turn spent the resource budget its orchestration mode allows
593    /// (spec §11.1), and stopped rather than continuing on credit.
594    ///
595    /// `limit` is the stable snake-case name of the bound that ran out, so an
596    /// operator can tell "the model was called too often" from "the turn took
597    /// too long" without parsing a sentence. The runtime that raises it owns
598    /// the vocabulary of names; the library only carries it.
599    #[error("resource budget exhausted ({limit})")]
600    BudgetExhausted {
601        /// Stable snake-case name of the bound that ran out.
602        limit: String,
603    },
604}
605
606/// The actor is not allowed to do what the turn asks.
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
608#[serde(tag = "kind", rename_all = "snake_case")]
609#[non_exhaustive]
610pub enum AuthorizationError {
611    /// The conversation does not belong to the actor's account.
612    #[error("conversation {conversation_id} not accessible")]
613    ConversationNotAccessible {
614        /// The conversation.
615        conversation_id: ConversationId,
616    },
617    /// The actor lacks a role or permission.
618    #[error("forbidden ({reason_key})")]
619    Forbidden {
620        /// Key of the user-facing reason.
621        reason_key: String,
622    },
623    /// The actor's account does not match the record's account.
624    #[error("account mismatch")]
625    AccountMismatch,
626}
627
628/// The turn input is malformed.
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
630#[serde(tag = "kind", rename_all = "snake_case")]
631#[non_exhaustive]
632pub enum InvalidInputError {
633    /// Neither text, nor interaction response, nor attachments were supplied.
634    #[error("turn carries no text, interaction response or attachment")]
635    EmptyTurn,
636    /// The text exceeds the configured maximum.
637    #[error("text exceeds {max_bytes} bytes")]
638    TextTooLong {
639        /// The limit.
640        max_bytes: usize,
641    },
642    /// Too many attachments.
643    #[error("more than {max} attachments")]
644    TooManyAttachments {
645        /// The limit.
646        max: usize,
647    },
648    /// The locale tag is empty.
649    #[error("empty locale")]
650    EmptyLocale,
651    /// The account identifier is empty.
652    #[error("empty account id")]
653    EmptyAccount,
654}
655
656/// Normalized description of a provider failure.
657///
658/// The provider crate maps its rich error into this so the core error family
659/// stays free of wire types. Never contains raw provider bodies.
660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
661#[error("provider {provider_key} failed: {code:?}")]
662pub struct ProviderFailure {
663    /// Provider identifier.
664    pub provider_key: ProviderKey,
665    /// Model identifier when known.
666    pub model_key: Option<ModelKey>,
667    /// Normalized failure code.
668    pub code: ProviderFailureCode,
669    /// Whether the provider crate considers it retryable.
670    pub retryable: bool,
671    /// The endpoint's own sentence about the refusal, sanitized by the adapter.
672    ///
673    /// A code says which family a failure belongs to; it cannot say what was
674    /// wrong with the request. For the one family where that is the caller's
675    /// own bug — a malformed request — a provider that is down and a function
676    /// schema missing `properties` were indistinguishable from outside, and
677    /// telling them apart took a proxy reading a body this library had already
678    /// read and discarded.
679    ///
680    /// Absent when the adapter had nothing, and never rendered to a user.
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub detail: Option<String>,
683}
684
685/// Normalized provider failure categories (spec §20.8).
686#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
687#[serde(rename_all = "snake_case")]
688#[non_exhaustive]
689pub enum ProviderFailureCode {
690    /// The request timed out.
691    Timeout,
692    /// The provider rate-limited the request.
693    RateLimited,
694    /// Authentication failed.
695    Authentication,
696    /// A credential that was valid has expired.
697    ///
698    /// Distinct from [`Authentication`](Self::Authentication) because a bad key
699    /// stays bad while an expired token becomes valid again after a refresh:
700    /// providers that issue short-lived credentials (Vertex AI bearer tokens,
701    /// Bedrock session credentials) fail this way as a matter of course, and a
702    /// replay record that could not tell the two apart would make a refresh
703    /// gap indistinguishable from a misconfiguration.
704    CredentialExpired,
705    /// The account's quota or credit balance is exhausted.
706    ///
707    /// Distinct from [`RateLimited`](Self::RateLimited) because a rate limit
708    /// clears by waiting and a quota does not: it clears when a window resets
709    /// or a human tops the account up.
710    QuotaExhausted,
711    /// The prompt exceeded the context window.
712    ContextOverflow,
713    /// The response could not be parsed.
714    Malformed,
715    /// The model refused.
716    Refusal,
717    /// The provider/model lacks a required capability (no silent downgrade).
718    CapabilityMismatch,
719    /// The request was cancelled.
720    Cancelled,
721    /// The provider returned a server error.
722    ServerError,
723    /// Anything else.
724    Other,
725}
726
727/// Errors raised by the type-erasure boundary of the workflow registry.
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
729#[serde(tag = "kind", rename_all = "snake_case")]
730#[non_exhaustive]
731pub enum ErasureError {
732    /// A stored state could not be deserialized into the workflow's state type.
733    #[error("state of workflow {workflow} could not be deserialized")]
734    StateDeserialization {
735        /// The workflow.
736        workflow: WorkflowKey,
737    },
738    /// A JSON command could not be deserialized into the workflow's command type.
739    #[error("command of workflow {workflow} could not be deserialized")]
740    CommandDeserialization {
741        /// The workflow.
742        workflow: WorkflowKey,
743    },
744    /// A JSON event could not be deserialized into the workflow's event type.
745    #[error("event of workflow {workflow} could not be deserialized")]
746    EventDeserialization {
747        /// The workflow.
748        workflow: WorkflowKey,
749    },
750    /// A typed value could not be serialized at the boundary.
751    #[error("value of workflow {workflow} could not be serialized")]
752    Serialization {
753        /// The workflow.
754        workflow: WorkflowKey,
755    },
756    /// An operation's declaration cannot be used.
757    #[error("workflow {workflow} declares an unusable operation: {reason}")]
758    InvalidOperation {
759        /// The workflow.
760        workflow: WorkflowKey,
761        /// What is wrong with the declaration.
762        reason: String,
763    },
764    /// The registry has no workflow with this key.
765    #[error("unknown workflow {workflow}")]
766    UnknownWorkflow {
767        /// The key.
768        workflow: WorkflowKey,
769    },
770    /// Two definitions were registered under the same key.
771    #[error("duplicate workflow {workflow}")]
772    DuplicateWorkflow {
773        /// The key.
774        workflow: WorkflowKey,
775    },
776    /// An erased call was asked to compile or build against a case other than
777    /// the one the act resolved to, or at another revision.
778    #[error("workflow {workflow} call targets a different case than the act resolved to")]
779    CaseMismatch {
780        /// The workflow.
781        workflow: WorkflowKey,
782    },
783    /// A stored record names a version different from the registered one.
784    #[error("workflow {workflow} version mismatch: registered {registered}, found {found}")]
785    VersionMismatch {
786        /// The key.
787        workflow: WorkflowKey,
788        /// Version in the registry.
789        registered: WorkflowVersion,
790        /// Version found on the record.
791        found: WorkflowVersion,
792    },
793}
794
795/// A domain call through the erased boundary failed either because of the
796/// boundary itself or because the domain rejected it.
797///
798/// The wire form is adjacently tagged (`{"kind": ..., "detail": ...}`): several
799/// variants wrap another tagged error, and an internal tag would write two
800/// `kind` keys into one object, so the outer variant was lost on the way back.
801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
802#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
803#[non_exhaustive]
804pub enum ErasedCallError {
805    /// The (de)serialization boundary failed.
806    #[error(transparent)]
807    Erasure(ErasureError),
808    /// The domain rejected the call.
809    ///
810    /// Boxed because this variant travels in the `Err` half of every erased
811    /// compile and validate call, and a rejection carrying its own copy is much
812    /// larger than the success it displaces.
813    #[error(transparent)]
814    Rejected(Box<DomainRejection>),
815    /// The domain built a card that cannot be answered.
816    #[error(transparent)]
817    InvalidSpec(InteractionSpecError),
818}
819
820impl From<ErasureError> for ErasedCallError {
821    fn from(value: ErasureError) -> Self {
822        Self::Erasure(value)
823    }
824}
825
826impl From<InteractionSpecError> for ErasedCallError {
827    fn from(value: InteractionSpecError) -> Self {
828        Self::InvalidSpec(value)
829    }
830}
831
832impl From<DomainRejection> for ErasedCallError {
833    fn from(value: DomainRejection) -> Self {
834        Self::Rejected(Box::new(value))
835    }
836}
837
838/// Operational severity of an error.
839#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
840#[serde(rename_all = "snake_case")]
841pub enum ErrorSeverity {
842    /// Expected in normal operation (a clarification, a rejected act).
843    Info,
844    /// Worth watching but not an outage.
845    Warning,
846    /// Something failed that should not fail.
847    Error,
848    /// A safety boundary was breached or an invariant is broken.
849    Critical,
850}
851
852/// Answers the classification questions of spec §24 for an error.
853pub trait ErrorClassification {
854    /// May the whole operation be retried without further analysis?
855    fn retryable(&self) -> bool;
856    /// May a side effect have happened despite the error?
857    fn effect_may_have_happened(&self) -> bool;
858    /// Key of a user-safe message in the application's copy catalog.
859    fn user_message_key(&self) -> &'static str;
860    /// Operational severity.
861    fn severity(&self) -> ErrorSeverity;
862    /// Must a reconciliation job run before the case is trusted again?
863    fn reconciliation_required(&self) -> bool;
864}
865
866/// Top-level error of a turn (spec §24).
867///
868/// The wire form is adjacently tagged (`{"kind": ..., "detail": ...}`): several
869/// variants wrap another tagged error, and an internal tag would write two
870/// `kind` keys into one object, so the outer variant was lost on the way back.
871#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
872#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
873#[non_exhaustive]
874pub enum OrchestratorError {
875    /// The turn input is malformed.
876    #[error(transparent)]
877    InvalidInput(InvalidInputError),
878    /// The actor is not allowed.
879    #[error(transparent)]
880    Unauthorized(AuthorizationError),
881    /// A model provider failed.
882    #[error(transparent)]
883    Provider(ProviderFailure),
884    /// Target resolution failed.
885    #[error(transparent)]
886    Target(TargetError),
887    /// Reduction failed.
888    #[error(transparent)]
889    Reduction(ReductionError),
890    /// Interaction lifecycle error.
891    #[error(transparent)]
892    Interaction(InteractionError),
893    /// Policy refused.
894    #[error(transparent)]
895    Policy(PolicyError),
896    /// A stale revision was detected.
897    #[error(transparent)]
898    RevisionConflict(RevisionConflict),
899    /// The domain rejected.
900    #[error(transparent)]
901    DomainRejected(DomainRejection),
902    /// Execution failed.
903    #[error(transparent)]
904    Execution(ExecutionError),
905    /// An external effect has an unknown outcome.
906    #[error(transparent)]
907    ExternalOutcomeUnknown(UnknownOutcome),
908    /// Persistence failed.
909    #[error(transparent)]
910    Store(StoreError),
911    /// A Flow Map invariant was violated.
912    #[error(transparent)]
913    InvariantViolation(InvariantViolation),
914    /// The erasure boundary failed.
915    #[error(transparent)]
916    Erasure(ErasureError),
917    /// A library invariant failed: canonical hashing or an internal
918    /// serialization the library itself produced. Never caused by user input.
919    #[error("internal failure {code}")]
920    Internal {
921        /// Stable code, one of the constants in [`internal_code`].
922        code: String,
923    },
924}
925
926/// Stable codes used by [`OrchestratorError::Internal`].
927pub mod internal_code {
928    /// Canonical JSON hashing failed (payload hash, plan hash, fingerprint).
929    pub const HASH: &str = "hash";
930}
931
932impl From<HashError> for OrchestratorError {
933    fn from(_: HashError) -> Self {
934        // The source carries a `serde_json::Error`, which cannot be serialized
935        // into the (serializable) error family, so only the stable code travels.
936        Self::Internal {
937            code: internal_code::HASH.to_owned(),
938        }
939    }
940}
941
942macro_rules! orchestrator_from {
943    ($($variant:ident($ty:ty)),* $(,)?) => {
944        $(
945            impl From<$ty> for OrchestratorError {
946                fn from(value: $ty) -> Self {
947                    Self::$variant(value)
948                }
949            }
950        )*
951    };
952}
953
954orchestrator_from! {
955    InvalidInput(InvalidInputError),
956    Unauthorized(AuthorizationError),
957    Provider(ProviderFailure),
958    Target(TargetError),
959    Reduction(ReductionError),
960    Interaction(InteractionError),
961    Policy(PolicyError),
962    RevisionConflict(RevisionConflict),
963    DomainRejected(DomainRejection),
964    Execution(ExecutionError),
965    ExternalOutcomeUnknown(UnknownOutcome),
966    Store(StoreError),
967    InvariantViolation(InvariantViolation),
968    Erasure(ErasureError),
969}
970
971impl ErrorClassification for StoreError {
972    fn retryable(&self) -> bool {
973        matches!(self, Self::Unavailable | Self::Timeout)
974    }
975
976    fn effect_may_have_happened(&self) -> bool {
977        matches!(self, Self::Timeout)
978    }
979
980    fn user_message_key(&self) -> &'static str {
981        match self {
982            Self::NotFound => "turnframe.error.not_found",
983            _ => "turnframe.error.temporary",
984        }
985    }
986
987    fn severity(&self) -> ErrorSeverity {
988        match self {
989            Self::NotFound | Self::Conflict => ErrorSeverity::Warning,
990            Self::Corrupt => ErrorSeverity::Critical,
991            _ => ErrorSeverity::Error,
992        }
993    }
994
995    fn reconciliation_required(&self) -> bool {
996        matches!(self, Self::Timeout | Self::Corrupt)
997    }
998}
999
1000impl ErrorClassification for ExecutionError {
1001    fn retryable(&self) -> bool {
1002        match self {
1003            Self::Store(store) => store.retryable(),
1004            _ => false,
1005        }
1006    }
1007
1008    fn effect_may_have_happened(&self) -> bool {
1009        match self {
1010            Self::OutcomeUnknown(_) | Self::Timeout => true,
1011            Self::Store(store) => store.effect_may_have_happened(),
1012            _ => false,
1013        }
1014    }
1015
1016    fn user_message_key(&self) -> &'static str {
1017        match self {
1018            Self::RevisionConflict(_) => "turnframe.error.revision_conflict",
1019            Self::Rejected(_) => "turnframe.error.domain_rejected",
1020            Self::OutcomeUnknown(_) | Self::Timeout => "turnframe.error.verification_in_progress",
1021            _ => "turnframe.error.temporary",
1022        }
1023    }
1024
1025    fn severity(&self) -> ErrorSeverity {
1026        match self {
1027            Self::RevisionConflict(_) | Self::Rejected(_) => ErrorSeverity::Warning,
1028            Self::IdempotencyMismatch { .. } | Self::ScopeViolation | Self::Erasure(_) => {
1029                ErrorSeverity::Critical
1030            }
1031            Self::Store(store) => store.severity(),
1032            _ => ErrorSeverity::Error,
1033        }
1034    }
1035
1036    fn reconciliation_required(&self) -> bool {
1037        match self {
1038            Self::OutcomeUnknown(_) | Self::Timeout => true,
1039            Self::Store(store) => store.reconciliation_required(),
1040            _ => false,
1041        }
1042    }
1043}
1044
1045impl ErrorClassification for ReductionError {
1046    fn retryable(&self) -> bool {
1047        false
1048    }
1049
1050    fn effect_may_have_happened(&self) -> bool {
1051        false
1052    }
1053
1054    fn user_message_key(&self) -> &'static str {
1055        match self {
1056            Self::Limits(_)
1057            | Self::UnknownOperation { .. }
1058            | Self::InvalidArguments { .. }
1059            | Self::CaseNotLoaded { .. }
1060            | Self::Contradiction { .. }
1061            | Self::CommandBudgetExceeded { .. } => "turnframe.error.not_understood",
1062            Self::InconsistentPlan { .. } | Self::Hash | Self::DuplicateOperation { .. } => {
1063                "turnframe.error.internal"
1064            }
1065        }
1066    }
1067
1068    fn severity(&self) -> ErrorSeverity {
1069        match self {
1070            Self::Limits(_)
1071            | Self::UnknownOperation { .. }
1072            | Self::InvalidArguments { .. }
1073            | Self::CaseNotLoaded { .. }
1074            | Self::Contradiction { .. }
1075            | Self::CommandBudgetExceeded { .. } => ErrorSeverity::Error,
1076            // Library or domain defects, not user behaviour.
1077            Self::InconsistentPlan { .. } | Self::Hash | Self::DuplicateOperation { .. } => {
1078                ErrorSeverity::Critical
1079            }
1080        }
1081    }
1082
1083    fn reconciliation_required(&self) -> bool {
1084        false
1085    }
1086}
1087
1088impl ErrorClassification for OrchestratorError {
1089    fn retryable(&self) -> bool {
1090        match self {
1091            Self::Provider(failure) => failure.retryable,
1092            Self::RevisionConflict(_) => true,
1093            Self::Reduction(inner) => inner.retryable(),
1094            Self::Execution(inner) => inner.retryable(),
1095            Self::Store(inner) => inner.retryable(),
1096            _ => false,
1097        }
1098    }
1099
1100    fn effect_may_have_happened(&self) -> bool {
1101        match self {
1102            Self::ExternalOutcomeUnknown(_) => true,
1103            Self::Execution(inner) => inner.effect_may_have_happened(),
1104            Self::Store(inner) => inner.effect_may_have_happened(),
1105            _ => false,
1106        }
1107    }
1108
1109    fn user_message_key(&self) -> &'static str {
1110        match self {
1111            Self::InvalidInput(_) => "turnframe.error.invalid_input",
1112            Self::Unauthorized(_) => "turnframe.error.unauthorized",
1113            Self::Provider(_) => "turnframe.error.assistant_unavailable",
1114            Self::Target(_) => "turnframe.error.target",
1115            Self::Reduction(inner) => inner.user_message_key(),
1116            Self::Interaction(_) => "turnframe.error.interaction",
1117            Self::Policy(_) => "turnframe.error.policy",
1118            Self::RevisionConflict(_) => "turnframe.error.revision_conflict",
1119            Self::DomainRejected(_) => "turnframe.error.domain_rejected",
1120            Self::Execution(inner) => inner.user_message_key(),
1121            Self::ExternalOutcomeUnknown(_) => "turnframe.error.verification_in_progress",
1122            Self::Store(inner) => inner.user_message_key(),
1123            Self::InvariantViolation(_) | Self::Erasure(_) | Self::Internal { .. } => {
1124                "turnframe.error.internal"
1125            }
1126        }
1127    }
1128
1129    fn severity(&self) -> ErrorSeverity {
1130        match self {
1131            Self::Target(_) | Self::DomainRejected(_) => ErrorSeverity::Info,
1132            Self::InvalidInput(_)
1133            | Self::Unauthorized(_)
1134            | Self::Interaction(_)
1135            | Self::Policy(_)
1136            | Self::RevisionConflict(_) => ErrorSeverity::Warning,
1137            Self::Provider(_) | Self::ExternalOutcomeUnknown(_) => ErrorSeverity::Error,
1138            Self::Reduction(inner) => inner.severity(),
1139            Self::Execution(inner) => inner.severity(),
1140            Self::Store(inner) => inner.severity(),
1141            Self::InvariantViolation(_) | Self::Erasure(_) | Self::Internal { .. } => {
1142                ErrorSeverity::Critical
1143            }
1144        }
1145    }
1146
1147    fn reconciliation_required(&self) -> bool {
1148        match self {
1149            Self::ExternalOutcomeUnknown(_) => true,
1150            Self::Execution(inner) => inner.reconciliation_required(),
1151            Self::Store(inner) => inner.reconciliation_required(),
1152            _ => false,
1153        }
1154    }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160    use crate::event::UnknownOutcome;
1161    use crate::ids::AttemptId;
1162
1163    /// Every variant of the top-level error, so the classification table is
1164    /// exercised as a whole rather than in three samples.
1165    fn every_orchestrator_error() -> Vec<OrchestratorError> {
1166        vec![
1167            OrchestratorError::InvalidInput(InvalidInputError::EmptyTurn),
1168            OrchestratorError::Unauthorized(AuthorizationError::AccountMismatch),
1169            OrchestratorError::Provider(ProviderFailure {
1170                provider_key: crate::ids::ProviderKey::from("p"),
1171                model_key: None,
1172                code: ProviderFailureCode::Timeout,
1173                retryable: true,
1174                detail: None,
1175            }),
1176            OrchestratorError::Target(TargetError::NoActiveInteraction),
1177            OrchestratorError::Reduction(ReductionError::Limits(PlanLimitError {
1178                kind: crate::plan::limits::PlanLimitKind::Acts,
1179                limit: 1,
1180                actual: 2,
1181            })),
1182            OrchestratorError::Reduction(ReductionError::InconsistentPlan { detail: "d".into() }),
1183            OrchestratorError::Interaction(InteractionError::NotPersisted),
1184            OrchestratorError::Policy(PolicyError::Unavailable),
1185            OrchestratorError::RevisionConflict(RevisionConflict {
1186                expected: CaseRef::new("w", "c", CaseRevision(1)),
1187                current_revision: CaseRevision(2),
1188            }),
1189            OrchestratorError::DomainRejected(DomainRejection::new("c", "k")),
1190            OrchestratorError::Execution(ExecutionError::Timeout),
1191            OrchestratorError::ExternalOutcomeUnknown(UnknownOutcome {
1192                attempt_id: AttemptId::from("a1"),
1193                remote_ref: None,
1194                reason: "timeout".into(),
1195            }),
1196            OrchestratorError::Store(StoreError::Unavailable),
1197            OrchestratorError::InvariantViolation(InvariantViolation {
1198                case_ref: CaseRef::new("w", "c", CaseRevision(1)),
1199                kind: InvariantViolationKind::TerminalPhaseWithoutOutcome,
1200            }),
1201            OrchestratorError::Erasure(ErasureError::UnknownWorkflow {
1202                workflow: WorkflowKey::from("w"),
1203            }),
1204            OrchestratorError::Internal {
1205                code: internal_code::HASH.to_owned(),
1206            },
1207        ]
1208    }
1209
1210    #[test]
1211    fn every_variant_is_classified_and_safe_to_log() {
1212        for error in every_orchestrator_error() {
1213            let key = error.user_message_key();
1214            assert!(key.starts_with("turnframe.error."), "{error:?} -> {key}");
1215            // A defect of the library or the domain is never sold to the user
1216            // as a language problem, and never quietly retried.
1217            if error.severity() == ErrorSeverity::Critical {
1218                assert!(!error.retryable(), "{error:?}");
1219            }
1220            let rendered = error.to_string();
1221            assert!(!rendered.is_empty());
1222            let json = serde_json::to_value(&error).unwrap();
1223            assert_eq!(
1224                serde_json::from_value::<OrchestratorError>(json).unwrap(),
1225                error
1226            );
1227        }
1228    }
1229
1230    #[test]
1231    fn a_structurally_broken_plan_is_a_defect_not_a_language_problem() {
1232        let broken = OrchestratorError::Reduction(ReductionError::InconsistentPlan {
1233            detail: "dangling command reference".into(),
1234        });
1235        assert_eq!(broken.severity(), ErrorSeverity::Critical);
1236        assert!(!broken.retryable());
1237    }
1238
1239    #[test]
1240    fn hashing_failures_reach_the_orchestrator_error() {
1241        // A map with non-string keys cannot be canonical JSON.
1242        let unserializable: std::collections::BTreeMap<(u8, u8), u8> =
1243            [((1, 2), 3)].into_iter().collect();
1244        let err: OrchestratorError = crate::hash::canonical_digest(&unserializable)
1245            .unwrap_err()
1246            .into();
1247        assert_eq!(
1248            err,
1249            OrchestratorError::Internal {
1250                code: internal_code::HASH.to_owned()
1251            }
1252        );
1253        assert_eq!(err.severity(), ErrorSeverity::Critical);
1254        assert!(!err.retryable());
1255        assert_eq!(err.user_message_key(), "turnframe.error.internal");
1256    }
1257
1258    #[test]
1259    fn external_unknown_is_classified_for_reconciliation() {
1260        let err = OrchestratorError::ExternalOutcomeUnknown(UnknownOutcome {
1261            attempt_id: "a1".into(),
1262            remote_ref: None,
1263            reason: "timeout".into(),
1264        });
1265        assert!(!err.retryable());
1266        assert!(err.effect_may_have_happened());
1267        assert!(err.reconciliation_required());
1268        assert_eq!(
1269            err.user_message_key(),
1270            "turnframe.error.verification_in_progress"
1271        );
1272    }
1273
1274    #[test]
1275    fn display_carries_ids_only() {
1276        let err = OrchestratorError::Reduction(ReductionError::CaseNotLoaded {
1277            act: ActId::new(crate::understanding::UnitId(2), 1),
1278        });
1279        assert_eq!(
1280            err.to_string(),
1281            "act u2.a1 references a case that is not loaded"
1282        );
1283    }
1284
1285    #[test]
1286    fn revision_conflict_is_retryable_without_effect() {
1287        let err = OrchestratorError::RevisionConflict(RevisionConflict {
1288            expected: CaseRef::new("trip", "i1", CaseRevision(3)),
1289            current_revision: CaseRevision(4),
1290        });
1291        assert!(err.retryable());
1292        assert!(!err.effect_may_have_happened());
1293        assert_eq!(err.severity(), ErrorSeverity::Warning);
1294    }
1295}