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