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