1use 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 RejectionCode
31}
32
33pub const UNKNOWN_OPERATION: &str = "turnframe.operation.unknown";
58
59#[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 pub code: RejectionCode,
68 pub message_key: String,
70 #[serde(default)]
77 pub details: Box<serde_json::Value>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub explanation: Option<Box<LocalizedText>>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub argument: Option<String>,
102}
103
104impl DomainRejection {
105 #[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 #[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 #[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 #[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#[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 pub expected: CaseRef,
153 pub current_revision: CaseRevision,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
159#[non_exhaustive]
160pub enum StoreError {
161 #[error("record not found")]
164 NotFound,
165 #[error("store constraint conflict")]
167 Conflict,
168 #[error("store unavailable")]
170 Unavailable,
171 #[error("store operation timed out")]
173 Timeout,
174 #[error("stored payload could not be serialized or deserialized")]
176 Serialization,
177 #[error("stored data is corrupt")]
179 Corrupt,
180 #[error("store failure {code}")]
182 Other {
183 code: String,
185 },
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
190#[non_exhaustive]
191pub enum ExecutionError {
192 #[error(transparent)]
194 RevisionConflict(RevisionConflict),
195 #[error(transparent)]
197 Rejected(DomainRejection),
198 #[error(transparent)]
200 Store(StoreError),
201 #[error(transparent)]
203 OutcomeUnknown(UnknownOutcome),
204 #[error("idempotency key reused with a different command {command_id}")]
206 IdempotencyMismatch {
207 command_id: CommandId,
209 },
210 #[error("batch scope violation")]
212 ScopeViolation,
213 #[error("execution timed out")]
215 Timeout,
216 #[error(transparent)]
218 Erasure(ErasureError),
219 #[error("execution failure {code}")]
221 Other {
222 code: String,
224 },
225}
226
227impl From<ErasureError> for ExecutionError {
228 fn from(value: ErasureError) -> Self {
229 Self::Erasure(value)
230 }
231}
232
233#[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 pub case_ref: CaseRef,
239 pub kind: InvariantViolationKind,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
245#[serde(tag = "kind", rename_all = "snake_case")]
246#[non_exhaustive]
247pub enum InvariantViolationKind {
248 #[error("outcome present while {obligation_count} obligations remain")]
250 OutcomeWithObligations {
251 obligation_count: usize,
253 },
254 #[error("user-owned phase without a blocking interaction")]
256 MissingBlockingInteraction,
257 #[error("blocking interaction on a terminal phase")]
259 BlockingInteractionOnTerminalPhase,
260 #[error("blocking interaction on a phase not owned by the user")]
262 BlockingInteractionOnNonUserPhase,
263 #[error("blocking_interaction slot holds a non-blocking requirement")]
265 NonBlockingRequirementInBlockingSlot,
266 #[error("terminal phase without outcome")]
268 TerminalPhaseWithoutOutcome,
269 #[error("outcome present on a non-terminal phase")]
271 OutcomeOnNonTerminalPhase,
272 #[error("duplicate obligation id {obligation_id}")]
274 DuplicateObligation {
275 obligation_id: String,
277 },
278 #[error("obligation could not be serialized")]
280 UnserializableObligation,
281 #[error("blocking interaction cannot be answered: {error}")]
283 UnanswerableBlockingInteraction {
284 error: InteractionSpecError,
286 },
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
291#[serde(tag = "kind", rename_all = "snake_case")]
292#[non_exhaustive]
293pub enum TargetError {
294 #[error("ambiguous target with {candidate_count} candidates")]
296 Ambiguous {
297 candidate_count: usize,
299 },
300 #[error("target {token} missing")]
302 Missing {
303 token: TargetToken,
305 },
306 #[error("target {token} unauthorized")]
308 Unauthorized {
309 token: TargetToken,
311 },
312 #[error("target {token} stale: issued at {issued_revision}, current {current_revision}")]
314 Stale {
315 token: TargetToken,
317 issued_revision: CaseRevision,
319 current_revision: CaseRevision,
321 },
322 #[error("mention could not be resolved for workflow {workflow}")]
324 MentionUnresolved {
325 workflow: WorkflowKey,
327 },
328 #[error("no active interaction to target")]
330 NoActiveInteraction,
331 #[error("target kind not allowed by the policy of operation {operation}")]
333 PolicyMismatch {
334 operation: OperationKey,
336 },
337}
338
339#[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 #[error(transparent)]
350 Limits(PlanLimitError),
351 #[error("act {act} uses unknown operation {operation}")]
353 UnknownOperation {
354 act: ActId,
356 operation: OperationKey,
358 },
359 #[error("act {act} has invalid arguments")]
361 InvalidArguments {
362 act: ActId,
364 error: SchemaValidationError,
366 },
367 #[error("act {act} references a case that is not loaded")]
369 CaseNotLoaded {
370 act: ActId,
372 },
373 #[error("reduction plan inconsistent: {detail}")]
375 InconsistentPlan {
376 detail: String,
378 },
379 #[error("plan hash could not be computed")]
381 Hash,
382 #[error("duplicate operation {operation} in the act catalog")]
385 DuplicateOperation {
386 operation: OperationKey,
388 },
389 #[error("acts {first} and {second} contradict without a precedence rule")]
391 Contradiction {
392 first: ActId,
394 second: ActId,
396 },
397 #[error("turn compiled {actual} commands, more than the limit of {limit}")]
404 CommandBudgetExceeded {
405 limit: usize,
407 actual: usize,
409 },
410}
411
412impl From<PlanLimitError> for ReductionError {
413 fn from(value: PlanLimitError) -> Self {
414 Self::Limits(value)
415 }
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
420#[error("schema violation at {instance_path} (schema {schema_path})")]
421pub struct SchemaValidationError {
422 pub instance_path: String,
424 pub schema_path: String,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
430#[serde(tag = "kind", rename_all = "snake_case")]
431#[non_exhaustive]
432pub enum SchemaCheckError {
433 #[error("schema could not be compiled")]
435 InvalidSchema,
436 #[error(transparent)]
438 Violation(SchemaValidationError),
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
448#[serde(tag = "kind", rename_all = "snake_case")]
449#[non_exhaustive]
450pub enum InteractionSpecError {
451 #[error("duplicate option id {option_id}")]
453 DuplicateOptionId {
454 option_id: OptionId,
456 },
457 #[error("{interaction_kind:?} card needs at least {required} options, found {found}")]
459 NotEnoughOptions {
460 interaction_kind: InteractionKind,
462 required: usize,
464 found: usize,
466 },
467 #[error("{interaction_kind:?} card has no option that authorizes commands")]
469 MissingAuthorizingOption {
470 interaction_kind: InteractionKind,
472 },
473 #[error("{interaction_kind:?} card has no declining option")]
475 MissingDeclineOption {
476 interaction_kind: InteractionKind,
478 },
479 #[error("review card has no diff entries")]
481 MissingReviewEntries,
482 #[error("freeform card has no prompt")]
484 MissingFreeformPrompt,
485 #[error("freeform card has no option requiring free text")]
487 MissingFreeformOption,
488 #[error("{interaction_kind:?} cards cannot be persisted")]
490 UnsupportedKind {
491 interaction_kind: InteractionKind,
493 },
494 #[error("{interaction_kind:?} card confirming {confirms_risk:?} may not be resolved from text")]
496 TextResolutionNotAllowed {
497 interaction_kind: InteractionKind,
499 confirms_risk: RiskClass,
501 },
502}
503
504#[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 #[error(transparent)]
515 Rejected(InteractionRejection),
516 #[error("illegal interaction transition {from:?} -> {to:?} on {interaction_id}")]
518 InvalidTransition {
519 interaction_id: InteractionId,
521 from: InteractionStatus,
523 to: InteractionStatus,
525 },
526 #[error("case already has an active blocking interaction {existing}")]
528 BlockingConflict {
529 existing: InteractionId,
531 },
532 #[error("payload hash mismatch on {interaction_id}")]
534 PayloadHashMismatch {
535 interaction_id: InteractionId,
537 },
538 #[error("interaction not persisted")]
540 NotPersisted,
541 #[error(transparent)]
543 InvalidSpec(InteractionSpecError),
544 #[error("interaction time to live is out of range")]
546 InvalidTtl,
547 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
566#[serde(tag = "kind", rename_all = "snake_case")]
567#[non_exhaustive]
568pub enum PolicyError {
569 #[error("command {command_ref} requires a trusted origin")]
571 UntrustedOrigin {
572 command_ref: CommandRef,
574 },
575 #[error("command {command_ref} has a forbidden risk class")]
577 ForbiddenRiskClass {
578 command_ref: CommandRef,
580 },
581 #[error("command {command_ref} denied ({reason_key})")]
583 Denied {
584 command_ref: CommandRef,
586 reason_key: String,
588 },
589 #[error("policy source unavailable")]
591 Unavailable,
592 #[error("resource budget exhausted ({limit})")]
600 BudgetExhausted {
601 limit: String,
603 },
604}
605
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
608#[serde(tag = "kind", rename_all = "snake_case")]
609#[non_exhaustive]
610pub enum AuthorizationError {
611 #[error("conversation {conversation_id} not accessible")]
613 ConversationNotAccessible {
614 conversation_id: ConversationId,
616 },
617 #[error("forbidden ({reason_key})")]
619 Forbidden {
620 reason_key: String,
622 },
623 #[error("account mismatch")]
625 AccountMismatch,
626}
627
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
630#[serde(tag = "kind", rename_all = "snake_case")]
631#[non_exhaustive]
632pub enum InvalidInputError {
633 #[error("turn carries no text, interaction response or attachment")]
635 EmptyTurn,
636 #[error("text exceeds {max_bytes} bytes")]
638 TextTooLong {
639 max_bytes: usize,
641 },
642 #[error("more than {max} attachments")]
644 TooManyAttachments {
645 max: usize,
647 },
648 #[error("empty locale")]
650 EmptyLocale,
651 #[error("empty account id")]
653 EmptyAccount,
654}
655
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
661#[error("provider {provider_key} failed: {code:?}")]
662pub struct ProviderFailure {
663 pub provider_key: ProviderKey,
665 pub model_key: Option<ModelKey>,
667 pub code: ProviderFailureCode,
669 pub retryable: bool,
671 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub detail: Option<String>,
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
687#[serde(rename_all = "snake_case")]
688#[non_exhaustive]
689pub enum ProviderFailureCode {
690 Timeout,
692 RateLimited,
694 Authentication,
696 CredentialExpired,
705 QuotaExhausted,
711 ContextOverflow,
713 Malformed,
715 Refusal,
717 CapabilityMismatch,
719 Cancelled,
721 ServerError,
723 Other,
725}
726
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
729#[serde(tag = "kind", rename_all = "snake_case")]
730#[non_exhaustive]
731pub enum ErasureError {
732 #[error("state of workflow {workflow} could not be deserialized")]
734 StateDeserialization {
735 workflow: WorkflowKey,
737 },
738 #[error("command of workflow {workflow} could not be deserialized")]
740 CommandDeserialization {
741 workflow: WorkflowKey,
743 },
744 #[error("event of workflow {workflow} could not be deserialized")]
746 EventDeserialization {
747 workflow: WorkflowKey,
749 },
750 #[error("value of workflow {workflow} could not be serialized")]
752 Serialization {
753 workflow: WorkflowKey,
755 },
756 #[error("workflow {workflow} declares an unusable operation: {reason}")]
758 InvalidOperation {
759 workflow: WorkflowKey,
761 reason: String,
763 },
764 #[error("unknown workflow {workflow}")]
766 UnknownWorkflow {
767 workflow: WorkflowKey,
769 },
770 #[error("duplicate workflow {workflow}")]
772 DuplicateWorkflow {
773 workflow: WorkflowKey,
775 },
776 #[error("workflow {workflow} call targets a different case than the act resolved to")]
779 CaseMismatch {
780 workflow: WorkflowKey,
782 },
783 #[error("workflow {workflow} version mismatch: registered {registered}, found {found}")]
785 VersionMismatch {
786 workflow: WorkflowKey,
788 registered: WorkflowVersion,
790 found: WorkflowVersion,
792 },
793}
794
795#[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 #[error(transparent)]
807 Erasure(ErasureError),
808 #[error(transparent)]
814 Rejected(Box<DomainRejection>),
815 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
840#[serde(rename_all = "snake_case")]
841pub enum ErrorSeverity {
842 Info,
844 Warning,
846 Error,
848 Critical,
850}
851
852pub trait ErrorClassification {
854 fn retryable(&self) -> bool;
856 fn effect_may_have_happened(&self) -> bool;
858 fn user_message_key(&self) -> &'static str;
860 fn severity(&self) -> ErrorSeverity;
862 fn reconciliation_required(&self) -> bool;
864}
865
866#[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 #[error(transparent)]
877 InvalidInput(InvalidInputError),
878 #[error(transparent)]
880 Unauthorized(AuthorizationError),
881 #[error(transparent)]
883 Provider(ProviderFailure),
884 #[error(transparent)]
886 Target(TargetError),
887 #[error(transparent)]
889 Reduction(ReductionError),
890 #[error(transparent)]
892 Interaction(InteractionError),
893 #[error(transparent)]
895 Policy(PolicyError),
896 #[error(transparent)]
898 RevisionConflict(RevisionConflict),
899 #[error(transparent)]
901 DomainRejected(DomainRejection),
902 #[error(transparent)]
904 Execution(ExecutionError),
905 #[error(transparent)]
907 ExternalOutcomeUnknown(UnknownOutcome),
908 #[error(transparent)]
910 Store(StoreError),
911 #[error(transparent)]
913 InvariantViolation(InvariantViolation),
914 #[error(transparent)]
916 Erasure(ErasureError),
917 #[error("internal failure {code}")]
920 Internal {
921 code: String,
923 },
924}
925
926pub mod internal_code {
928 pub const HASH: &str = "hash";
930}
931
932impl From<HashError> for OrchestratorError {
933 fn from(_: HashError) -> Self {
934 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 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 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 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 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}