use serde::{Deserialize, Serialize};
use crate::case::CaseRef;
use crate::command::RiskClass;
use crate::ids::{
CaseRevision, CommandId, ConversationId, InteractionId, ModelKey, OperationKey, OptionId,
ProviderKey, TargetToken, WorkflowKey, WorkflowVersion, string_id,
};
use crate::interaction::{InteractionKind, InteractionRejection, InteractionStatus};
use crate::locale::LocalizedText;
use crate::plan::limits::PlanLimitError;
use crate::reduce::CommandRef;
use crate::understanding::ActId;
pub use crate::event::UnknownOutcome;
pub use crate::hash::HashError;
string_id! {
RejectionCode
}
pub const UNKNOWN_OPERATION: &str = "turnframe.operation.unknown";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[error("domain rejected the request with code {code} (message key {message_key})")]
pub struct DomainRejection {
pub code: RejectionCode,
pub message_key: String,
#[serde(default)]
pub details: Box<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<Box<LocalizedText>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument: Option<String>,
}
impl DomainRejection {
#[must_use]
pub fn new(code: impl Into<RejectionCode>, message_key: impl Into<String>) -> Self {
Self {
code: code.into(),
explanation: None,
argument: None,
message_key: message_key.into(),
details: Box::new(serde_json::Value::Null),
}
}
#[must_use]
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Box::new(details);
self
}
#[must_use]
pub fn on_argument(mut self, pointer: impl Into<String>) -> Self {
self.argument = Some(pointer.into());
self
}
#[must_use]
pub fn with_explanation(mut self, explanation: LocalizedText) -> Self {
self.explanation = Some(Box::new(explanation));
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[error(
"revision conflict on {}/{}: expected {}, current {current_revision}",
expected.workflow, expected.case_id, expected.expected_revision
)]
pub struct RevisionConflict {
pub expected: CaseRef,
pub current_revision: CaseRevision,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[non_exhaustive]
pub enum StoreError {
#[error("record not found")]
NotFound,
#[error("store constraint conflict")]
Conflict,
#[error("store unavailable")]
Unavailable,
#[error("store operation timed out")]
Timeout,
#[error("stored payload could not be serialized or deserialized")]
Serialization,
#[error("stored data is corrupt")]
Corrupt,
#[error("store failure {code}")]
Other {
code: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[non_exhaustive]
pub enum ExecutionError {
#[error(transparent)]
RevisionConflict(RevisionConflict),
#[error(transparent)]
Rejected(DomainRejection),
#[error(transparent)]
Store(StoreError),
#[error(transparent)]
OutcomeUnknown(UnknownOutcome),
#[error("idempotency key reused with a different command {command_id}")]
IdempotencyMismatch {
command_id: CommandId,
},
#[error("batch scope violation")]
ScopeViolation,
#[error("execution timed out")]
Timeout,
#[error(transparent)]
Erasure(ErasureError),
#[error("execution failure {code}")]
Other {
code: String,
},
}
impl From<ErasureError> for ExecutionError {
fn from(value: ErasureError) -> Self {
Self::Erasure(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[error("invariant violation on {}/{}: {kind}", case_ref.workflow, case_ref.case_id)]
pub struct InvariantViolation {
pub case_ref: CaseRef,
pub kind: InvariantViolationKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum InvariantViolationKind {
#[error("outcome present while {obligation_count} obligations remain")]
OutcomeWithObligations {
obligation_count: usize,
},
#[error("user-owned phase without a blocking interaction")]
MissingBlockingInteraction,
#[error("blocking interaction on a terminal phase")]
BlockingInteractionOnTerminalPhase,
#[error("blocking interaction on a phase not owned by the user")]
BlockingInteractionOnNonUserPhase,
#[error("blocking_interaction slot holds a non-blocking requirement")]
NonBlockingRequirementInBlockingSlot,
#[error("terminal phase without outcome")]
TerminalPhaseWithoutOutcome,
#[error("outcome present on a non-terminal phase")]
OutcomeOnNonTerminalPhase,
#[error("duplicate obligation id {obligation_id}")]
DuplicateObligation {
obligation_id: String,
},
#[error("obligation could not be serialized")]
UnserializableObligation,
#[error("blocking interaction cannot be answered: {error}")]
UnanswerableBlockingInteraction {
error: InteractionSpecError,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum TargetError {
#[error("ambiguous target with {candidate_count} candidates")]
Ambiguous {
candidate_count: usize,
},
#[error("target {token} missing")]
Missing {
token: TargetToken,
},
#[error("target {token} unauthorized")]
Unauthorized {
token: TargetToken,
},
#[error("target {token} stale: issued at {issued_revision}, current {current_revision}")]
Stale {
token: TargetToken,
issued_revision: CaseRevision,
current_revision: CaseRevision,
},
#[error("mention could not be resolved for workflow {workflow}")]
MentionUnresolved {
workflow: WorkflowKey,
},
#[error("no active interaction to target")]
NoActiveInteraction,
#[error("target kind not allowed by the policy of operation {operation}")]
PolicyMismatch {
operation: OperationKey,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReductionError {
#[error(transparent)]
Limits(PlanLimitError),
#[error("act {act} uses unknown operation {operation}")]
UnknownOperation {
act: ActId,
operation: OperationKey,
},
#[error("act {act} has invalid arguments")]
InvalidArguments {
act: ActId,
error: SchemaValidationError,
},
#[error("act {act} references a case that is not loaded")]
CaseNotLoaded {
act: ActId,
},
#[error("reduction plan inconsistent: {detail}")]
InconsistentPlan {
detail: String,
},
#[error("plan hash could not be computed")]
Hash,
#[error("duplicate operation {operation} in the act catalog")]
DuplicateOperation {
operation: OperationKey,
},
#[error("acts {first} and {second} contradict without a precedence rule")]
Contradiction {
first: ActId,
second: ActId,
},
#[error("turn compiled {actual} commands, more than the limit of {limit}")]
CommandBudgetExceeded {
limit: usize,
actual: usize,
},
}
impl From<PlanLimitError> for ReductionError {
fn from(value: PlanLimitError) -> Self {
Self::Limits(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[error("schema violation at {instance_path} (schema {schema_path})")]
pub struct SchemaValidationError {
pub instance_path: String,
pub schema_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum SchemaCheckError {
#[error("schema could not be compiled")]
InvalidSchema,
#[error(transparent)]
Violation(SchemaValidationError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum InteractionSpecError {
#[error("duplicate option id {option_id}")]
DuplicateOptionId {
option_id: OptionId,
},
#[error("{interaction_kind:?} card needs at least {required} options, found {found}")]
NotEnoughOptions {
interaction_kind: InteractionKind,
required: usize,
found: usize,
},
#[error("{interaction_kind:?} card has no option that authorizes commands")]
MissingAuthorizingOption {
interaction_kind: InteractionKind,
},
#[error("{interaction_kind:?} card has no declining option")]
MissingDeclineOption {
interaction_kind: InteractionKind,
},
#[error("review card has no diff entries")]
MissingReviewEntries,
#[error("freeform card has no prompt")]
MissingFreeformPrompt,
#[error("freeform card has no option requiring free text")]
MissingFreeformOption,
#[error("{interaction_kind:?} cards cannot be persisted")]
UnsupportedKind {
interaction_kind: InteractionKind,
},
#[error("{interaction_kind:?} card confirming {confirms_risk:?} may not be resolved from text")]
TextResolutionNotAllowed {
interaction_kind: InteractionKind,
confirms_risk: RiskClass,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
#[non_exhaustive]
pub enum InteractionError {
#[error(transparent)]
Rejected(InteractionRejection),
#[error("illegal interaction transition {from:?} -> {to:?} on {interaction_id}")]
InvalidTransition {
interaction_id: InteractionId,
from: InteractionStatus,
to: InteractionStatus,
},
#[error("case already has an active blocking interaction {existing}")]
BlockingConflict {
existing: InteractionId,
},
#[error("payload hash mismatch on {interaction_id}")]
PayloadHashMismatch {
interaction_id: InteractionId,
},
#[error("interaction not persisted")]
NotPersisted,
#[error(transparent)]
InvalidSpec(InteractionSpecError),
#[error("interaction time to live is out of range")]
InvalidTtl,
#[error("interaction payload could not be hashed")]
Hash,
}
impl From<InteractionRejection> for InteractionError {
fn from(value: InteractionRejection) -> Self {
Self::Rejected(value)
}
}
impl From<InteractionSpecError> for InteractionError {
fn from(value: InteractionSpecError) -> Self {
Self::InvalidSpec(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum PolicyError {
#[error("command {command_ref} requires a trusted origin")]
UntrustedOrigin {
command_ref: CommandRef,
},
#[error("command {command_ref} has a forbidden risk class")]
ForbiddenRiskClass {
command_ref: CommandRef,
},
#[error("command {command_ref} denied ({reason_key})")]
Denied {
command_ref: CommandRef,
reason_key: String,
},
#[error("policy source unavailable")]
Unavailable,
#[error("resource budget exhausted ({limit})")]
BudgetExhausted {
limit: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AuthorizationError {
#[error("conversation {conversation_id} not accessible")]
ConversationNotAccessible {
conversation_id: ConversationId,
},
#[error("forbidden ({reason_key})")]
Forbidden {
reason_key: String,
},
#[error("account mismatch")]
AccountMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum InvalidInputError {
#[error("turn carries no text, interaction response or attachment")]
EmptyTurn,
#[error("text exceeds {max_bytes} bytes")]
TextTooLong {
max_bytes: usize,
},
#[error("more than {max} attachments")]
TooManyAttachments {
max: usize,
},
#[error("empty locale")]
EmptyLocale,
#[error("empty account id")]
EmptyAccount,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[error("provider {provider_key} failed: {code:?}")]
pub struct ProviderFailure {
pub provider_key: ProviderKey,
pub model_key: Option<ModelKey>,
pub code: ProviderFailureCode,
pub retryable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProviderFailureCode {
Timeout,
RateLimited,
Authentication,
CredentialExpired,
QuotaExhausted,
ContextOverflow,
Malformed,
Refusal,
CapabilityMismatch,
Cancelled,
ServerError,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ErasureError {
#[error("state of workflow {workflow} could not be deserialized")]
StateDeserialization {
workflow: WorkflowKey,
},
#[error("command of workflow {workflow} could not be deserialized")]
CommandDeserialization {
workflow: WorkflowKey,
},
#[error("event of workflow {workflow} could not be deserialized")]
EventDeserialization {
workflow: WorkflowKey,
},
#[error("value of workflow {workflow} could not be serialized")]
Serialization {
workflow: WorkflowKey,
},
#[error("workflow {workflow} declares an unusable operation: {reason}")]
InvalidOperation {
workflow: WorkflowKey,
reason: String,
},
#[error("unknown workflow {workflow}")]
UnknownWorkflow {
workflow: WorkflowKey,
},
#[error("duplicate workflow {workflow}")]
DuplicateWorkflow {
workflow: WorkflowKey,
},
#[error("workflow {workflow} call targets a different case than the act resolved to")]
CaseMismatch {
workflow: WorkflowKey,
},
#[error("workflow {workflow} version mismatch: registered {registered}, found {found}")]
VersionMismatch {
workflow: WorkflowKey,
registered: WorkflowVersion,
found: WorkflowVersion,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ErasedCallError {
#[error(transparent)]
Erasure(ErasureError),
#[error(transparent)]
Rejected(Box<DomainRejection>),
#[error(transparent)]
InvalidSpec(InteractionSpecError),
}
impl From<ErasureError> for ErasedCallError {
fn from(value: ErasureError) -> Self {
Self::Erasure(value)
}
}
impl From<InteractionSpecError> for ErasedCallError {
fn from(value: InteractionSpecError) -> Self {
Self::InvalidSpec(value)
}
}
impl From<DomainRejection> for ErasedCallError {
fn from(value: DomainRejection) -> Self {
Self::Rejected(Box::new(value))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorSeverity {
Info,
Warning,
Error,
Critical,
}
pub trait ErrorClassification {
fn retryable(&self) -> bool;
fn effect_may_have_happened(&self) -> bool;
fn user_message_key(&self) -> &'static str;
fn severity(&self) -> ErrorSeverity;
fn reconciliation_required(&self) -> bool;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
#[non_exhaustive]
pub enum OrchestratorError {
#[error(transparent)]
InvalidInput(InvalidInputError),
#[error(transparent)]
Unauthorized(AuthorizationError),
#[error(transparent)]
Provider(ProviderFailure),
#[error(transparent)]
Target(TargetError),
#[error(transparent)]
Reduction(ReductionError),
#[error(transparent)]
Interaction(InteractionError),
#[error(transparent)]
Policy(PolicyError),
#[error(transparent)]
RevisionConflict(RevisionConflict),
#[error(transparent)]
DomainRejected(DomainRejection),
#[error(transparent)]
Execution(ExecutionError),
#[error(transparent)]
ExternalOutcomeUnknown(UnknownOutcome),
#[error(transparent)]
Store(StoreError),
#[error(transparent)]
InvariantViolation(InvariantViolation),
#[error(transparent)]
Erasure(ErasureError),
#[error("internal failure {code}")]
Internal {
code: String,
},
}
pub mod internal_code {
pub const HASH: &str = "hash";
}
impl From<HashError> for OrchestratorError {
fn from(_: HashError) -> Self {
Self::Internal {
code: internal_code::HASH.to_owned(),
}
}
}
macro_rules! orchestrator_from {
($($variant:ident($ty:ty)),* $(,)?) => {
$(
impl From<$ty> for OrchestratorError {
fn from(value: $ty) -> Self {
Self::$variant(value)
}
}
)*
};
}
orchestrator_from! {
InvalidInput(InvalidInputError),
Unauthorized(AuthorizationError),
Provider(ProviderFailure),
Target(TargetError),
Reduction(ReductionError),
Interaction(InteractionError),
Policy(PolicyError),
RevisionConflict(RevisionConflict),
DomainRejected(DomainRejection),
Execution(ExecutionError),
ExternalOutcomeUnknown(UnknownOutcome),
Store(StoreError),
InvariantViolation(InvariantViolation),
Erasure(ErasureError),
}
impl ErrorClassification for StoreError {
fn retryable(&self) -> bool {
matches!(self, Self::Unavailable | Self::Timeout)
}
fn effect_may_have_happened(&self) -> bool {
matches!(self, Self::Timeout)
}
fn user_message_key(&self) -> &'static str {
match self {
Self::NotFound => "turnframe.error.not_found",
_ => "turnframe.error.temporary",
}
}
fn severity(&self) -> ErrorSeverity {
match self {
Self::NotFound | Self::Conflict => ErrorSeverity::Warning,
Self::Corrupt => ErrorSeverity::Critical,
_ => ErrorSeverity::Error,
}
}
fn reconciliation_required(&self) -> bool {
matches!(self, Self::Timeout | Self::Corrupt)
}
}
impl ErrorClassification for ExecutionError {
fn retryable(&self) -> bool {
match self {
Self::Store(store) => store.retryable(),
_ => false,
}
}
fn effect_may_have_happened(&self) -> bool {
match self {
Self::OutcomeUnknown(_) | Self::Timeout => true,
Self::Store(store) => store.effect_may_have_happened(),
_ => false,
}
}
fn user_message_key(&self) -> &'static str {
match self {
Self::RevisionConflict(_) => "turnframe.error.revision_conflict",
Self::Rejected(_) => "turnframe.error.domain_rejected",
Self::OutcomeUnknown(_) | Self::Timeout => "turnframe.error.verification_in_progress",
_ => "turnframe.error.temporary",
}
}
fn severity(&self) -> ErrorSeverity {
match self {
Self::RevisionConflict(_) | Self::Rejected(_) => ErrorSeverity::Warning,
Self::IdempotencyMismatch { .. } | Self::ScopeViolation | Self::Erasure(_) => {
ErrorSeverity::Critical
}
Self::Store(store) => store.severity(),
_ => ErrorSeverity::Error,
}
}
fn reconciliation_required(&self) -> bool {
match self {
Self::OutcomeUnknown(_) | Self::Timeout => true,
Self::Store(store) => store.reconciliation_required(),
_ => false,
}
}
}
impl ErrorClassification for ReductionError {
fn retryable(&self) -> bool {
false
}
fn effect_may_have_happened(&self) -> bool {
false
}
fn user_message_key(&self) -> &'static str {
match self {
Self::Limits(_)
| Self::UnknownOperation { .. }
| Self::InvalidArguments { .. }
| Self::CaseNotLoaded { .. }
| Self::Contradiction { .. }
| Self::CommandBudgetExceeded { .. } => "turnframe.error.not_understood",
Self::InconsistentPlan { .. } | Self::Hash | Self::DuplicateOperation { .. } => {
"turnframe.error.internal"
}
}
}
fn severity(&self) -> ErrorSeverity {
match self {
Self::Limits(_)
| Self::UnknownOperation { .. }
| Self::InvalidArguments { .. }
| Self::CaseNotLoaded { .. }
| Self::Contradiction { .. }
| Self::CommandBudgetExceeded { .. } => ErrorSeverity::Error,
Self::InconsistentPlan { .. } | Self::Hash | Self::DuplicateOperation { .. } => {
ErrorSeverity::Critical
}
}
}
fn reconciliation_required(&self) -> bool {
false
}
}
impl ErrorClassification for OrchestratorError {
fn retryable(&self) -> bool {
match self {
Self::Provider(failure) => failure.retryable,
Self::RevisionConflict(_) => true,
Self::Reduction(inner) => inner.retryable(),
Self::Execution(inner) => inner.retryable(),
Self::Store(inner) => inner.retryable(),
_ => false,
}
}
fn effect_may_have_happened(&self) -> bool {
match self {
Self::ExternalOutcomeUnknown(_) => true,
Self::Execution(inner) => inner.effect_may_have_happened(),
Self::Store(inner) => inner.effect_may_have_happened(),
_ => false,
}
}
fn user_message_key(&self) -> &'static str {
match self {
Self::InvalidInput(_) => "turnframe.error.invalid_input",
Self::Unauthorized(_) => "turnframe.error.unauthorized",
Self::Provider(_) => "turnframe.error.assistant_unavailable",
Self::Target(_) => "turnframe.error.target",
Self::Reduction(inner) => inner.user_message_key(),
Self::Interaction(_) => "turnframe.error.interaction",
Self::Policy(_) => "turnframe.error.policy",
Self::RevisionConflict(_) => "turnframe.error.revision_conflict",
Self::DomainRejected(_) => "turnframe.error.domain_rejected",
Self::Execution(inner) => inner.user_message_key(),
Self::ExternalOutcomeUnknown(_) => "turnframe.error.verification_in_progress",
Self::Store(inner) => inner.user_message_key(),
Self::InvariantViolation(_) | Self::Erasure(_) | Self::Internal { .. } => {
"turnframe.error.internal"
}
}
}
fn severity(&self) -> ErrorSeverity {
match self {
Self::Target(_) | Self::DomainRejected(_) => ErrorSeverity::Info,
Self::InvalidInput(_)
| Self::Unauthorized(_)
| Self::Interaction(_)
| Self::Policy(_)
| Self::RevisionConflict(_) => ErrorSeverity::Warning,
Self::Provider(_) | Self::ExternalOutcomeUnknown(_) => ErrorSeverity::Error,
Self::Reduction(inner) => inner.severity(),
Self::Execution(inner) => inner.severity(),
Self::Store(inner) => inner.severity(),
Self::InvariantViolation(_) | Self::Erasure(_) | Self::Internal { .. } => {
ErrorSeverity::Critical
}
}
}
fn reconciliation_required(&self) -> bool {
match self {
Self::ExternalOutcomeUnknown(_) => true,
Self::Execution(inner) => inner.reconciliation_required(),
Self::Store(inner) => inner.reconciliation_required(),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::UnknownOutcome;
use crate::ids::AttemptId;
fn every_orchestrator_error() -> Vec<OrchestratorError> {
vec![
OrchestratorError::InvalidInput(InvalidInputError::EmptyTurn),
OrchestratorError::Unauthorized(AuthorizationError::AccountMismatch),
OrchestratorError::Provider(ProviderFailure {
provider_key: crate::ids::ProviderKey::from("p"),
model_key: None,
code: ProviderFailureCode::Timeout,
retryable: true,
detail: None,
}),
OrchestratorError::Target(TargetError::NoActiveInteraction),
OrchestratorError::Reduction(ReductionError::Limits(PlanLimitError {
kind: crate::plan::limits::PlanLimitKind::Acts,
limit: 1,
actual: 2,
})),
OrchestratorError::Reduction(ReductionError::InconsistentPlan { detail: "d".into() }),
OrchestratorError::Interaction(InteractionError::NotPersisted),
OrchestratorError::Policy(PolicyError::Unavailable),
OrchestratorError::RevisionConflict(RevisionConflict {
expected: CaseRef::new("w", "c", CaseRevision(1)),
current_revision: CaseRevision(2),
}),
OrchestratorError::DomainRejected(DomainRejection::new("c", "k")),
OrchestratorError::Execution(ExecutionError::Timeout),
OrchestratorError::ExternalOutcomeUnknown(UnknownOutcome {
attempt_id: AttemptId::from("a1"),
remote_ref: None,
reason: "timeout".into(),
}),
OrchestratorError::Store(StoreError::Unavailable),
OrchestratorError::InvariantViolation(InvariantViolation {
case_ref: CaseRef::new("w", "c", CaseRevision(1)),
kind: InvariantViolationKind::TerminalPhaseWithoutOutcome,
}),
OrchestratorError::Erasure(ErasureError::UnknownWorkflow {
workflow: WorkflowKey::from("w"),
}),
OrchestratorError::Internal {
code: internal_code::HASH.to_owned(),
},
]
}
#[test]
fn every_variant_is_classified_and_safe_to_log() {
for error in every_orchestrator_error() {
let key = error.user_message_key();
assert!(key.starts_with("turnframe.error."), "{error:?} -> {key}");
if error.severity() == ErrorSeverity::Critical {
assert!(!error.retryable(), "{error:?}");
}
let rendered = error.to_string();
assert!(!rendered.is_empty());
let json = serde_json::to_value(&error).unwrap();
assert_eq!(
serde_json::from_value::<OrchestratorError>(json).unwrap(),
error
);
}
}
#[test]
fn a_structurally_broken_plan_is_a_defect_not_a_language_problem() {
let broken = OrchestratorError::Reduction(ReductionError::InconsistentPlan {
detail: "dangling command reference".into(),
});
assert_eq!(broken.severity(), ErrorSeverity::Critical);
assert!(!broken.retryable());
}
#[test]
fn hashing_failures_reach_the_orchestrator_error() {
let unserializable: std::collections::BTreeMap<(u8, u8), u8> =
[((1, 2), 3)].into_iter().collect();
let err: OrchestratorError = crate::hash::canonical_digest(&unserializable)
.unwrap_err()
.into();
assert_eq!(
err,
OrchestratorError::Internal {
code: internal_code::HASH.to_owned()
}
);
assert_eq!(err.severity(), ErrorSeverity::Critical);
assert!(!err.retryable());
assert_eq!(err.user_message_key(), "turnframe.error.internal");
}
#[test]
fn external_unknown_is_classified_for_reconciliation() {
let err = OrchestratorError::ExternalOutcomeUnknown(UnknownOutcome {
attempt_id: "a1".into(),
remote_ref: None,
reason: "timeout".into(),
});
assert!(!err.retryable());
assert!(err.effect_may_have_happened());
assert!(err.reconciliation_required());
assert_eq!(
err.user_message_key(),
"turnframe.error.verification_in_progress"
);
}
#[test]
fn display_carries_ids_only() {
let err = OrchestratorError::Reduction(ReductionError::CaseNotLoaded {
act: ActId::new(crate::understanding::UnitId(2), 1),
});
assert_eq!(
err.to_string(),
"act u2.a1 references a case that is not loaded"
);
}
#[test]
fn revision_conflict_is_retryable_without_effect() {
let err = OrchestratorError::RevisionConflict(RevisionConflict {
expected: CaseRef::new("trip", "i1", CaseRevision(3)),
current_revision: CaseRevision(4),
});
assert!(err.retryable());
assert!(!err.effect_may_have_happened());
assert_eq!(err.severity(), ErrorSeverity::Warning);
}
}