Skip to main content

ff_script/
error.rs

1//! FlowFabric `ScriptError` — Lua error codes + transport errors.
2//!
3//! Lives in `ff-script` (not `ff-core`) because the `Valkey` variant wraps
4//! `ferriskey::Error`, and `ff-core` stays pure of the transport client.
5//! `ff-core` continues to own the `ErrorClass` enum.
6
7use ff_core::error::ErrorClass;
8
9use crate::retry::is_retryable_kind;
10
11/// All error codes returned by FlowFabric Valkey Functions.
12/// Matches RFC-010 §10.7 exactly.
13///
14/// Does not derive `Serialize`/`Deserialize`/`PartialEq`/`Eq`/`Hash` because the
15/// `Valkey` variant wraps `ferriskey::Error`, which implements none of those.
16/// Call sites compare via `matches!`/`.class()` rather than `==`, so this is
17/// not a regression.
18#[derive(Debug, thiserror::Error)]
19pub enum ScriptError {
20    // ── Lease/Ownership errors ──
21    /// Stop. Lease superseded by reclaim.
22    #[error("stale_lease: lease superseded by reclaim")]
23    StaleLease,
24
25    /// Stop. Lease TTL elapsed.
26    #[error("lease_expired: lease TTL elapsed")]
27    LeaseExpired,
28
29    /// Stop. Operator revoked.
30    #[error("lease_revoked: operator revoked lease")]
31    LeaseRevoked,
32
33    /// Stop. Check enriched return: epoch match + success = your completion won.
34    #[error("execution_not_active: execution is not in active state")]
35    ExecutionNotActive,
36
37    /// Revoke target has no active lease (already revoked/expired/unowned).
38    #[error("no_active_lease: target has no active lease")]
39    NoActiveLease,
40
41    /// Bug. Active attempt already exists.
42    #[error("active_attempt_exists: invariant violation")]
43    ActiveAttemptExists,
44
45    // ── Claim dispatch errors ──
46    /// Re-dispatch to claim_resumed_execution.
47    #[error("use_claim_resumed_execution: attempt_interrupted, use resume claim path")]
48    UseClaimResumedExecution,
49
50    /// Re-dispatch to claim_execution.
51    #[error("not_a_resumed_execution: use normal claim path")]
52    NotAResumedExecution,
53
54    /// State changed since grant. Request new grant.
55    #[error("execution_not_leaseable: state changed since grant")]
56    ExecutionNotLeaseable,
57
58    /// Another worker holds lease. Request different execution.
59    #[error("lease_conflict: another worker holds lease")]
60    LeaseConflict,
61
62    /// Grant missing/mismatched. Request new grant.
63    #[error("invalid_claim_grant: grant missing or mismatched")]
64    InvalidClaimGrant,
65
66    /// Grant TTL elapsed. Request new grant.
67    #[error("claim_grant_expired: grant TTL elapsed")]
68    ClaimGrantExpired,
69
70    /// Backoff 100ms-1s, retry.
71    #[error("no_eligible_execution: no execution available")]
72    NoEligibleExecution,
73
74    // ── Budget/Quota enforcement ──
75    /// Immediate stop. Call fail_execution(budget_exceeded).
76    #[error("budget_exceeded: hard budget limit reached")]
77    BudgetExceeded,
78
79    /// Log warning. Continue.
80    #[error("budget_soft_exceeded: soft budget limit reached")]
81    BudgetSoftExceeded,
82
83    // ── Suspension/Signal errors ──
84    /// Already resumed/cancelled. No-op.
85    #[error("execution_not_suspended: already resumed or cancelled")]
86    ExecutionNotSuspended,
87
88    /// Open suspension exists. No-op.
89    #[error("already_suspended: suspension already active")]
90    AlreadySuspended,
91
92    /// Signal too late. Return to caller.
93    #[error("waitpoint_closed: waitpoint already closed")]
94    WaitpointClosed,
95
96    /// Waitpoint may not exist yet. Retry with backoff.
97    #[error("waitpoint_not_found: waitpoint does not exist yet")]
98    WaitpointNotFound,
99
100    /// Execution not suspended, no pending waitpoint.
101    #[error("target_not_signalable: no valid signal target")]
102    TargetNotSignalable,
103
104    /// Route to buffer_signal_for_pending_waitpoint.
105    #[error("waitpoint_pending_use_buffer_script: route to buffer script")]
106    WaitpointPendingUseBufferScript,
107
108    /// Dedup. Return existing signal_id.
109    #[error("duplicate_signal: signal already delivered")]
110    DuplicateSignal,
111
112    /// Payload > 64KB.
113    #[error("payload_too_large: signal payload exceeds 64KB")]
114    PayloadTooLarge,
115
116    /// Max signals reached.
117    #[error("signal_limit_exceeded: max signals per execution reached")]
118    SignalLimitExceeded,
119
120    /// MAC failed. Token invalid or expired.
121    #[error("invalid_waitpoint_key: MAC verification failed")]
122    InvalidWaitpointKey,
123
124    /// Invalid lease for suspend.
125    #[error("invalid_lease_for_suspend: lease/attempt binding mismatch")]
126    InvalidLeaseForSuspend,
127
128    /// Conditions not satisfied.
129    #[error("resume_condition_not_met: resume conditions not satisfied")]
130    ResumeConditionNotMet,
131
132    /// Waitpoint not in pending state.
133    #[error("waitpoint_not_pending: waitpoint is not in pending state")]
134    WaitpointNotPending,
135
136    /// Pending waitpoint expired before suspension committed.
137    #[error("pending_waitpoint_expired: pending waitpoint aged out")]
138    PendingWaitpointExpired,
139
140    /// Waitpoint/execution binding mismatch.
141    #[error("invalid_waitpoint_for_execution: waitpoint does not belong to execution")]
142    InvalidWaitpointForExecution,
143
144    /// Waitpoint already exists (pending or active).
145    #[error("waitpoint_already_exists: waitpoint already exists")]
146    WaitpointAlreadyExists,
147
148    /// Waitpoint not in an open state.
149    #[error("waitpoint_not_open: waitpoint is not pending or active")]
150    WaitpointNotOpen,
151
152    // ── Replay errors ──
153    /// Cannot replay non-terminal.
154    #[error("execution_not_terminal: cannot replay non-terminal execution")]
155    ExecutionNotTerminal,
156
157    /// Replay limit reached.
158    #[error("max_replays_exhausted: replay limit reached")]
159    MaxReplaysExhausted,
160
161    // ── Stream errors ──
162    /// Attempt terminal. No appends.
163    #[error("stream_closed: attempt terminal, no appends allowed")]
164    StreamClosed,
165
166    /// Lease mismatch on stream append.
167    #[error("stale_owner_cannot_append: lease mismatch on append")]
168    StaleOwnerCannotAppend,
169
170    /// Frame > 64KB.
171    #[error("retention_limit_exceeded: frame exceeds size limit")]
172    RetentionLimitExceeded,
173
174    // ── Scheduling errors ──
175    /// State changed. Scheduler skips.
176    #[error("execution_not_eligible: state changed")]
177    ExecutionNotEligible,
178
179    /// Another scheduler got it. Skip.
180    #[error("execution_not_in_eligible_set: removed by another scheduler")]
181    ExecutionNotInEligibleSet,
182
183    /// Grant already issued. Skip.
184    #[error("grant_already_exists: grant already active")]
185    GrantAlreadyExists,
186
187    /// Already reclaimed/cancelled. Skip.
188    #[error("execution_not_reclaimable: already reclaimed or cancelled")]
189    ExecutionNotReclaimable,
190
191    // ── Flow/Dependency errors ──
192    /// Edge doesn't exist.
193    #[error("invalid_dependency: dependency edge not found")]
194    InvalidDependency,
195
196    /// Re-read adjacency, retry.
197    #[error("stale_graph_revision: graph has been updated")]
198    StaleGraphRevision,
199
200    /// Already in another flow.
201    #[error("execution_already_in_flow: execution belongs to another flow")]
202    ExecutionAlreadyInFlow,
203
204    /// Edge would create cycle.
205    #[error("cycle_detected: dependency edge would create cycle")]
206    CycleDetected,
207
208    /// Flow does not exist.
209    #[error("flow_not_found: flow does not exist")]
210    FlowNotFound,
211
212    /// Execution is not a member of the specified flow.
213    #[error("execution_not_in_flow: execution not in flow")]
214    ExecutionNotInFlow,
215
216    /// Dependency edge already exists.
217    #[error("dependency_already_exists: edge already exists")]
218    DependencyAlreadyExists,
219
220    /// Self-referencing edge (upstream == downstream).
221    #[error("self_referencing_edge: upstream and downstream are the same")]
222    SelfReferencingEdge,
223
224    /// Flow is already in a terminal state (cancelled/completed/failed).
225    #[error("flow_already_terminal: flow is already terminal")]
226    FlowAlreadyTerminal,
227
228    /// Dependencies not yet satisfied (for promote_blocked_to_eligible).
229    #[error("deps_not_satisfied: dependencies still unresolved")]
230    DepsNotSatisfied,
231
232    /// Not blocked by dependencies (for promote/unblock).
233    #[error("not_blocked_by_deps: execution not blocked by dependencies")]
234    NotBlockedByDeps,
235
236    /// Execution not runnable (for block/unblock/promote).
237    #[error("not_runnable: execution is not in runnable state")]
238    NotRunnable,
239
240    /// Execution is terminal (for block/promote).
241    #[error("terminal: execution is already terminal")]
242    Terminal,
243
244    /// Invalid blocking reason for block_execution_for_admission.
245    #[error("invalid_blocking_reason: unrecognized blocking reason")]
246    InvalidBlockingReason,
247
248    // ── Usage reporting ──
249    /// Usage seq already processed. No-op.
250    #[error("ok_already_applied: usage seq already processed")]
251    OkAlreadyApplied,
252
253    // ── Attempt errors (RFC-002) ──
254    /// Attempt index doesn't exist.
255    #[error("attempt_not_found: attempt index does not exist")]
256    AttemptNotFound,
257
258    /// Attempt not created. Internal sequencing error.
259    #[error("attempt_not_in_created_state: internal sequencing error")]
260    AttemptNotInCreatedState,
261
262    /// Attempt not running.
263    #[error("attempt_not_started: attempt not in started state")]
264    AttemptNotStarted,
265
266    /// Already ended. No-op.
267    #[error("attempt_already_terminal: attempt already ended")]
268    AttemptAlreadyTerminal,
269
270    /// Execution doesn't exist.
271    #[error("execution_not_found: execution does not exist")]
272    ExecutionNotFound,
273
274    /// Wrong state for new attempt.
275    #[error("execution_not_eligible_for_attempt: wrong state for new attempt")]
276    ExecutionNotEligibleForAttempt,
277
278    /// Not terminal or limit reached.
279    #[error("replay_not_allowed: execution not terminal or limit reached")]
280    ReplayNotAllowed,
281
282    /// Retry limit reached.
283    #[error("max_retries_exhausted: retry limit reached")]
284    MaxRetriesExhausted,
285
286    // ── Stream errors (RFC-006) ──
287    /// No frames appended yet. Normal for new attempts.
288    #[error("stream_not_found: no frames appended yet")]
289    StreamNotFound,
290
291    /// Already closed. No-op.
292    #[error("stream_already_closed: stream already closed")]
293    StreamAlreadyClosed,
294
295    /// Unrecognized frame type.
296    #[error("invalid_frame_type: unrecognized frame type")]
297    InvalidFrameType,
298
299    /// Invalid Stream ID.
300    #[error("invalid_offset: invalid stream ID offset")]
301    InvalidOffset,
302
303    /// Auth failed.
304    #[error("unauthorized: authentication/authorization failed")]
305    Unauthorized,
306
307    // ── Budget/Quota errors (RFC-008) ──
308    /// Budget doesn't exist.
309    #[error("budget_not_found: budget does not exist")]
310    BudgetNotFound,
311
312    /// Malformed scope.
313    #[error("invalid_budget_scope: malformed budget scope")]
314    InvalidBudgetScope,
315
316    /// Budget already attached or conflicts.
317    #[error("budget_attach_conflict: budget attachment conflict")]
318    BudgetAttachConflict,
319
320    /// No operator privileges.
321    #[error("budget_override_not_allowed: insufficient privileges")]
322    BudgetOverrideNotAllowed,
323
324    /// Quota doesn't exist.
325    #[error("quota_policy_not_found: quota policy does not exist")]
326    QuotaPolicyNotFound,
327
328    /// Window full. Backoff retry_after_ms.
329    #[error("rate_limit_exceeded: rate limit window full")]
330    RateLimitExceeded,
331
332    /// Concurrency cap hit.
333    #[error("concurrency_limit_exceeded: concurrency cap reached")]
334    ConcurrencyLimitExceeded,
335
336    /// Quota already attached.
337    #[error("quota_attach_conflict: quota policy already attached")]
338    QuotaAttachConflict,
339
340    /// Malformed quota definition.
341    #[error("invalid_quota_spec: malformed quota policy definition")]
342    InvalidQuotaSpec,
343
344    /// Caller supplied a non-numeric value where a number is required.
345    #[error("invalid_input: {0}")]
346    InvalidInput(String),
347
348    /// Worker caps do not satisfy execution's required_capabilities.
349    /// Payload is the sorted-CSV of missing tokens. RETRYABLE: execution
350    /// stays in the eligible ZSET for a worker with matching caps.
351    #[error("capability_mismatch: missing {0}")]
352    CapabilityMismatch(String),
353
354    /// Caller supplied a malformed or oversized capability list (defense
355    /// against 1MB-repeated-token payloads). TERMINAL from this call's
356    /// perspective: the caller must fix its config before retrying.
357    #[error("invalid_capabilities: {0}")]
358    InvalidCapabilities(String),
359
360    /// `ff_create_execution` received a `policy_json` that is not valid JSON
361    /// or whose `routing_requirements` is structurally wrong (not an object,
362    /// required_capabilities not an array). TERMINAL: the submitter must
363    /// send a well-formed policy. Kept distinct from `invalid_capabilities`
364    /// so tooling can distinguish "payload never parsed" from "payload
365    /// parsed but contents rejected".
366    #[error("invalid_policy_json: {0}")]
367    InvalidPolicyJson(String),
368
369    /// Pending waitpoint record is missing its HMAC token field. Returned by
370    /// `ff_suspend_execution` when activating a pending waitpoint whose
371    /// `waitpoint_token` field is absent or empty (pre-HMAC-upgrade record
372    /// or a corrupted write). Surfacing this at activation time instead of
373    /// letting every subsequent signal delivery silently reject with
374    /// `missing_token` makes the degraded state visible at the right step.
375    /// TERMINAL: the pending waitpoint is unrecoverable without a fresh one.
376    #[error("waitpoint_not_token_bound")]
377    WaitpointNotTokenBound,
378
379    // ── Transport-level errors (not from Lua) ──
380    /// Valkey connection or protocol error. Preserves `ferriskey::ErrorKind` so
381    /// callers can distinguish transient/permanent/NOSCRIPT/MOVED/etc.
382    #[error("valkey: {0}")]
383    Valkey(#[from] ferriskey::Error),
384
385    /// Failed to parse FCALL return value.
386    #[error("parse error: {0}")]
387    Parse(String),
388}
389
390impl ScriptError {
391    /// Returns the underlying ferriskey ErrorKind if this is a transport error.
392    pub fn valkey_kind(&self) -> Option<ferriskey::ErrorKind> {
393        match self {
394            Self::Valkey(e) => Some(e.kind()),
395            _ => None,
396        }
397    }
398
399    /// Classify this error for SDK action dispatch.
400    pub fn class(&self) -> ErrorClass {
401        match self {
402            // Terminal
403            Self::StaleLease
404            | Self::LeaseExpired
405            | Self::LeaseRevoked
406            | Self::ExecutionNotActive
407            | Self::TargetNotSignalable
408            | Self::PayloadTooLarge
409            | Self::SignalLimitExceeded
410            | Self::InvalidWaitpointKey
411            | Self::ExecutionNotTerminal
412            | Self::MaxReplaysExhausted
413            | Self::StreamClosed
414            | Self::StaleOwnerCannotAppend
415            | Self::RetentionLimitExceeded
416            | Self::InvalidLeaseForSuspend
417            | Self::ResumeConditionNotMet
418            | Self::InvalidDependency
419            | Self::ExecutionAlreadyInFlow
420            | Self::CycleDetected
421            | Self::FlowNotFound
422            | Self::ExecutionNotInFlow
423            | Self::DependencyAlreadyExists
424            | Self::SelfReferencingEdge
425            | Self::FlowAlreadyTerminal
426            | Self::InvalidWaitpointForExecution
427            | Self::InvalidBlockingReason
428            | Self::NotRunnable
429            | Self::Terminal
430            | Self::AttemptNotFound
431            | Self::AttemptNotStarted
432            | Self::ExecutionNotFound
433            | Self::ExecutionNotEligibleForAttempt
434            | Self::ReplayNotAllowed
435            | Self::MaxRetriesExhausted
436            | Self::Unauthorized
437            | Self::BudgetNotFound
438            | Self::InvalidBudgetScope
439            | Self::BudgetAttachConflict
440            | Self::BudgetOverrideNotAllowed
441            | Self::QuotaPolicyNotFound
442            | Self::QuotaAttachConflict
443            | Self::InvalidQuotaSpec
444            | Self::InvalidInput(_)
445            | Self::InvalidCapabilities(_)
446            | Self::InvalidPolicyJson(_)
447            | Self::WaitpointNotTokenBound
448            | Self::Parse(_) => ErrorClass::Terminal,
449
450            // Transport errors classify by their ferriskey ErrorKind —
451            // IoError / FatalSend / TryAgain / BusyLoading / ClusterDown are
452            // genuinely retryable even though all other Valkey errors are
453            // terminal from the caller's perspective.
454            Self::Valkey(e) => {
455                if is_retryable_kind(e.kind()) {
456                    ErrorClass::Retryable
457                } else {
458                    ErrorClass::Terminal
459                }
460            }
461
462            // Retryable
463            Self::UseClaimResumedExecution
464            | Self::NotAResumedExecution
465            | Self::ExecutionNotLeaseable
466            | Self::LeaseConflict
467            | Self::InvalidClaimGrant
468            | Self::ClaimGrantExpired
469            | Self::NoEligibleExecution
470            | Self::WaitpointNotFound
471            | Self::WaitpointPendingUseBufferScript
472            | Self::StaleGraphRevision
473            | Self::RateLimitExceeded
474            | Self::ConcurrencyLimitExceeded
475            | Self::CapabilityMismatch(_)
476            | Self::InvalidOffset => ErrorClass::Retryable,
477
478            // Cooperative
479            Self::BudgetExceeded => ErrorClass::Cooperative,
480
481            // Informational
482            Self::ExecutionNotSuspended
483            | Self::AlreadySuspended
484            | Self::WaitpointClosed
485            | Self::DuplicateSignal
486            | Self::ExecutionNotEligible
487            | Self::ExecutionNotInEligibleSet
488            | Self::GrantAlreadyExists
489            | Self::ExecutionNotReclaimable
490            | Self::NoActiveLease
491            | Self::OkAlreadyApplied
492            | Self::AttemptAlreadyTerminal
493            | Self::StreamAlreadyClosed
494            | Self::BudgetSoftExceeded
495            | Self::WaitpointAlreadyExists
496            | Self::WaitpointNotOpen
497            | Self::WaitpointNotPending
498            | Self::PendingWaitpointExpired
499            | Self::NotBlockedByDeps
500            | Self::DepsNotSatisfied => ErrorClass::Informational,
501
502            // Bug
503            Self::ActiveAttemptExists | Self::AttemptNotInCreatedState => ErrorClass::Bug,
504
505            // Expected
506            Self::StreamNotFound => ErrorClass::Expected,
507
508            // Soft error
509            Self::InvalidFrameType => ErrorClass::SoftError,
510        }
511    }
512
513    /// Parse an error code string (from Lua return) into a ScriptError.
514    pub fn from_code(code: &str) -> Option<Self> {
515        Some(match code {
516            "stale_lease" => Self::StaleLease,
517            "lease_expired" => Self::LeaseExpired,
518            "lease_revoked" => Self::LeaseRevoked,
519            "execution_not_active" => Self::ExecutionNotActive,
520            "no_active_lease" => Self::NoActiveLease,
521            "active_attempt_exists" => Self::ActiveAttemptExists,
522            "use_claim_resumed_execution" => Self::UseClaimResumedExecution,
523            "not_a_resumed_execution" => Self::NotAResumedExecution,
524            "execution_not_leaseable" => Self::ExecutionNotLeaseable,
525            "lease_conflict" => Self::LeaseConflict,
526            "invalid_claim_grant" => Self::InvalidClaimGrant,
527            "claim_grant_expired" => Self::ClaimGrantExpired,
528            "no_eligible_execution" => Self::NoEligibleExecution,
529            "budget_exceeded" => Self::BudgetExceeded,
530            "budget_soft_exceeded" => Self::BudgetSoftExceeded,
531            "execution_not_suspended" => Self::ExecutionNotSuspended,
532            "already_suspended" => Self::AlreadySuspended,
533            "waitpoint_closed" => Self::WaitpointClosed,
534            "waitpoint_not_found" => Self::WaitpointNotFound,
535            "target_not_signalable" => Self::TargetNotSignalable,
536            "waitpoint_pending_use_buffer_script" => Self::WaitpointPendingUseBufferScript,
537            "duplicate_signal" => Self::DuplicateSignal,
538            "payload_too_large" => Self::PayloadTooLarge,
539            "signal_limit_exceeded" => Self::SignalLimitExceeded,
540            "invalid_waitpoint_key" => Self::InvalidWaitpointKey,
541            "invalid_lease_for_suspend" => Self::InvalidLeaseForSuspend,
542            "resume_condition_not_met" => Self::ResumeConditionNotMet,
543            "waitpoint_not_pending" => Self::WaitpointNotPending,
544            "pending_waitpoint_expired" => Self::PendingWaitpointExpired,
545            "invalid_waitpoint_for_execution" => Self::InvalidWaitpointForExecution,
546            "waitpoint_already_exists" => Self::WaitpointAlreadyExists,
547            "waitpoint_not_open" => Self::WaitpointNotOpen,
548            "execution_not_terminal" => Self::ExecutionNotTerminal,
549            "max_replays_exhausted" => Self::MaxReplaysExhausted,
550            "stream_closed" => Self::StreamClosed,
551            "stale_owner_cannot_append" => Self::StaleOwnerCannotAppend,
552            "retention_limit_exceeded" => Self::RetentionLimitExceeded,
553            "execution_not_eligible" => Self::ExecutionNotEligible,
554            "execution_not_in_eligible_set" => Self::ExecutionNotInEligibleSet,
555            "grant_already_exists" => Self::GrantAlreadyExists,
556            "execution_not_reclaimable" => Self::ExecutionNotReclaimable,
557            "invalid_dependency" => Self::InvalidDependency,
558            "stale_graph_revision" => Self::StaleGraphRevision,
559            "execution_already_in_flow" => Self::ExecutionAlreadyInFlow,
560            "cycle_detected" => Self::CycleDetected,
561            "flow_not_found" => Self::FlowNotFound,
562            "execution_not_in_flow" => Self::ExecutionNotInFlow,
563            "dependency_already_exists" => Self::DependencyAlreadyExists,
564            "self_referencing_edge" => Self::SelfReferencingEdge,
565            "flow_already_terminal" => Self::FlowAlreadyTerminal,
566            "deps_not_satisfied" => Self::DepsNotSatisfied,
567            "not_blocked_by_deps" => Self::NotBlockedByDeps,
568            "not_runnable" => Self::NotRunnable,
569            "terminal" => Self::Terminal,
570            "invalid_blocking_reason" => Self::InvalidBlockingReason,
571            "ok_already_applied" => Self::OkAlreadyApplied,
572            "attempt_not_found" => Self::AttemptNotFound,
573            "attempt_not_in_created_state" => Self::AttemptNotInCreatedState,
574            "attempt_not_started" => Self::AttemptNotStarted,
575            "attempt_already_terminal" => Self::AttemptAlreadyTerminal,
576            "execution_not_found" => Self::ExecutionNotFound,
577            "execution_not_eligible_for_attempt" => Self::ExecutionNotEligibleForAttempt,
578            "replay_not_allowed" => Self::ReplayNotAllowed,
579            "max_retries_exhausted" => Self::MaxRetriesExhausted,
580            "stream_not_found" => Self::StreamNotFound,
581            "stream_already_closed" => Self::StreamAlreadyClosed,
582            "invalid_frame_type" => Self::InvalidFrameType,
583            "invalid_offset" => Self::InvalidOffset,
584            "unauthorized" => Self::Unauthorized,
585            "budget_not_found" => Self::BudgetNotFound,
586            "invalid_budget_scope" => Self::InvalidBudgetScope,
587            "budget_attach_conflict" => Self::BudgetAttachConflict,
588            "budget_override_not_allowed" => Self::BudgetOverrideNotAllowed,
589            "quota_policy_not_found" => Self::QuotaPolicyNotFound,
590            "rate_limit_exceeded" => Self::RateLimitExceeded,
591            "concurrency_limit_exceeded" => Self::ConcurrencyLimitExceeded,
592            "quota_attach_conflict" => Self::QuotaAttachConflict,
593            "invalid_quota_spec" => Self::InvalidQuotaSpec,
594            "invalid_input" => Self::InvalidInput(String::new()),
595            "capability_mismatch" => Self::CapabilityMismatch(String::new()),
596            "invalid_capabilities" => Self::InvalidCapabilities(String::new()),
597            "invalid_policy_json" => Self::InvalidPolicyJson(String::new()),
598            "waitpoint_not_token_bound" => Self::WaitpointNotTokenBound,
599            _ => return None,
600        })
601    }
602
603    /// Like `from_code`, but preserves the Lua-side detail payload for
604    /// variants that carry a String. Lua returns `{0, code, detail}` for
605    /// capability_mismatch (missing CSV), invalid_capabilities (bounds
606    /// reason), invalid_input (field name). The plain `from_code` discards
607    /// the detail; callers that log or surface the detail should use this
608    /// variant. Returns `None` only when the code is unknown — the detail
609    /// is always folded in when applicable.
610    pub fn from_code_with_detail(code: &str, detail: &str) -> Option<Self> {
611        let base = Self::from_code(code)?;
612        Some(match base {
613            Self::CapabilityMismatch(_) => Self::CapabilityMismatch(detail.to_owned()),
614            Self::InvalidCapabilities(_) => Self::InvalidCapabilities(detail.to_owned()),
615            Self::InvalidPolicyJson(_) => Self::InvalidPolicyJson(detail.to_owned()),
616            Self::InvalidInput(_) => Self::InvalidInput(detail.to_owned()),
617            other => other,
618        })
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn error_classification_terminal() {
628        assert_eq!(ScriptError::StaleLease.class(), ErrorClass::Terminal);
629        assert_eq!(ScriptError::LeaseExpired.class(), ErrorClass::Terminal);
630        assert_eq!(ScriptError::ExecutionNotFound.class(), ErrorClass::Terminal);
631    }
632
633    #[test]
634    fn error_classification_retryable() {
635        assert_eq!(
636            ScriptError::UseClaimResumedExecution.class(),
637            ErrorClass::Retryable
638        );
639        assert_eq!(
640            ScriptError::NoEligibleExecution.class(),
641            ErrorClass::Retryable
642        );
643        assert_eq!(
644            ScriptError::WaitpointNotFound.class(),
645            ErrorClass::Retryable
646        );
647        assert_eq!(
648            ScriptError::RateLimitExceeded.class(),
649            ErrorClass::Retryable
650        );
651    }
652
653    #[test]
654    fn error_classification_cooperative() {
655        assert_eq!(ScriptError::BudgetExceeded.class(), ErrorClass::Cooperative);
656    }
657
658    #[test]
659    fn error_classification_valkey_transient_is_retryable() {
660        use ferriskey::ErrorKind;
661        let transient = ScriptError::Valkey(ferriskey::Error::from((
662            ErrorKind::IoError,
663            "connection dropped",
664        )));
665        assert_eq!(transient.class(), ErrorClass::Retryable);
666    }
667
668    #[test]
669    fn error_classification_valkey_permanent_is_terminal() {
670        use ferriskey::ErrorKind;
671        let permanent = ScriptError::Valkey(ferriskey::Error::from((
672            ErrorKind::AuthenticationFailed,
673            "bad creds",
674        )));
675        assert_eq!(permanent.class(), ErrorClass::Terminal);
676
677        // FatalReceiveError: request may have been applied, conservatively
678        // terminal.
679        let fatal_recv = ScriptError::Valkey(ferriskey::Error::from((
680            ErrorKind::FatalReceiveError,
681            "response lost",
682        )));
683        assert_eq!(fatal_recv.class(), ErrorClass::Terminal);
684    }
685
686    #[test]
687    fn error_classification_informational() {
688        assert_eq!(
689            ScriptError::ExecutionNotSuspended.class(),
690            ErrorClass::Informational
691        );
692        assert_eq!(
693            ScriptError::DuplicateSignal.class(),
694            ErrorClass::Informational
695        );
696        assert_eq!(
697            ScriptError::OkAlreadyApplied.class(),
698            ErrorClass::Informational
699        );
700    }
701
702    #[test]
703    fn error_classification_bug() {
704        assert_eq!(ScriptError::ActiveAttemptExists.class(), ErrorClass::Bug);
705        assert_eq!(
706            ScriptError::AttemptNotInCreatedState.class(),
707            ErrorClass::Bug
708        );
709    }
710
711    #[test]
712    fn error_classification_expected() {
713        assert_eq!(ScriptError::StreamNotFound.class(), ErrorClass::Expected);
714    }
715
716    #[test]
717    fn error_classification_budget_soft_exceeded() {
718        // RFC-010 §10.7: budget_soft_exceeded is INFORMATIONAL
719        assert_eq!(
720            ScriptError::BudgetSoftExceeded.class(),
721            ErrorClass::Informational
722        );
723    }
724
725    #[test]
726    fn error_classification_soft_error() {
727        assert_eq!(ScriptError::InvalidFrameType.class(), ErrorClass::SoftError);
728    }
729
730    #[test]
731    fn from_code_roundtrip() {
732        let codes = [
733            "stale_lease", "lease_expired", "lease_revoked",
734            "execution_not_active", "no_active_lease", "active_attempt_exists",
735            "use_claim_resumed_execution", "not_a_resumed_execution",
736            "execution_not_leaseable", "lease_conflict",
737            "invalid_claim_grant", "claim_grant_expired",
738            "budget_exceeded", "budget_soft_exceeded",
739            "execution_not_suspended", "already_suspended",
740            "waitpoint_closed", "waitpoint_not_found",
741            "target_not_signalable", "waitpoint_pending_use_buffer_script",
742            "invalid_lease_for_suspend", "resume_condition_not_met",
743            "signal_limit_exceeded",
744            "execution_not_terminal", "max_replays_exhausted",
745            "stream_closed", "stale_owner_cannot_append", "retention_limit_exceeded",
746            "execution_not_eligible", "execution_not_in_eligible_set",
747            "grant_already_exists", "execution_not_reclaimable",
748            "invalid_dependency", "stale_graph_revision",
749            "execution_already_in_flow", "cycle_detected",
750            "execution_not_found", "max_retries_exhausted",
751            "flow_not_found", "execution_not_in_flow",
752            "dependency_already_exists", "self_referencing_edge",
753            "flow_already_terminal",
754            "deps_not_satisfied", "not_blocked_by_deps",
755            "not_runnable", "terminal", "invalid_blocking_reason",
756            "waitpoint_not_pending", "pending_waitpoint_expired",
757            "invalid_waitpoint_for_execution", "waitpoint_already_exists",
758            "waitpoint_not_open",
759        ];
760        for code in codes {
761            let err = ScriptError::from_code(code);
762            assert!(err.is_some(), "failed to parse code: {code}");
763        }
764    }
765
766    #[test]
767    fn from_code_unknown_returns_none() {
768        assert!(ScriptError::from_code("nonexistent_error").is_none());
769    }
770}