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