Skip to main content

runledger_postgres/
error.rs

1use std::{fmt, sync::Arc};
2
3use sqlx::error::ErrorKind;
4
5mod classify;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum QueryErrorCategory {
9    Conflict,
10    Validation,
11    Forbidden,
12    Internal,
13}
14
15/// Stable semantic kinds for database errors that drive runtime policy.
16///
17/// Human- and machine-readable error codes remain available through
18/// [`QueryError::code`]. This enum is deliberately smaller: it covers errors
19/// whose handling must stay compile-checked across Runledger crates instead of
20/// depending on duplicated string literals.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum QueryErrorKind {
23    JobLeaseOwnerMismatch,
24    JobInvalidCompletionProgress,
25    JobInvalidContinuationDelay,
26    JobInvalidRetryTiming,
27    JobUnstartedClaimReleaseNotApplicable,
28    JobWorkflowHandlerContinuationNotEnabled,
29    JobWorkflowRequeueNotSupported,
30    PostgresLockNotAvailable,
31    WorkflowReleaseConflict,
32}
33
34impl QueryErrorKind {
35    const fn spec(self) -> QueryErrorSpec {
36        match self {
37            Self::JobLeaseOwnerMismatch => QueryErrorSpec::forbidden(
38                "job.lease_owner_mismatch",
39                "Job lease is not currently held by this worker.",
40            ),
41            Self::JobInvalidCompletionProgress => QueryErrorSpec::validation(
42                "job.invalid_completion_progress",
43                "Job completion progress is invalid.",
44            ),
45            Self::JobInvalidContinuationDelay => QueryErrorSpec::validation(
46                "job.invalid_continuation_delay",
47                "Job continuation delay is too large.",
48            ),
49            Self::JobInvalidRetryTiming => QueryErrorSpec::validation(
50                "job.invalid_retry_timing",
51                "Job retry timing is invalid.",
52            ),
53            Self::JobUnstartedClaimReleaseNotApplicable => QueryErrorSpec::validation(
54                "job.unstarted_claim_release_not_applicable",
55                "Job claim cannot be released as unstarted.",
56            ),
57            Self::JobWorkflowHandlerContinuationNotEnabled => QueryErrorSpec::validation(
58                "job.workflow_handler_continuation_not_enabled",
59                "Workflow step handler continuation is not enabled.",
60            ),
61            Self::JobWorkflowRequeueNotSupported => QueryErrorSpec::validation(
62                "job.workflow_requeue_not_supported",
63                "Workflow-managed jobs cannot be requeued directly.",
64            ),
65            Self::PostgresLockNotAvailable => QueryErrorSpec::internal(),
66            Self::WorkflowReleaseConflict => QueryErrorSpec::conflict(
67                "workflow.release_conflict",
68                "Workflow step release conflicted with another workflow mutation.",
69            ),
70        }
71    }
72
73    fn from_sqlstate(sqlstate: Option<&str>) -> Option<Self> {
74        match sqlstate {
75            Some("55P03") => Some(Self::PostgresLockNotAvailable),
76            _ => None,
77        }
78    }
79}
80
81/// Query-error fields that are safe to emit at application logging boundaries.
82///
83/// This deliberately excludes the internal message and SQLx source because
84/// either may contain payload values, idempotency keys, or database policy
85/// details. Keeping the safe projection as a distinct type makes it harder for
86/// callers to accidentally widen structured logs with raw diagnostics.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub(crate) struct SanitizedQueryErrorDiagnostics<'a> {
89    code: &'a str,
90    sqlstate: Option<&'a str>,
91    constraint: Option<&'a str>,
92}
93
94impl<'a> SanitizedQueryErrorDiagnostics<'a> {
95    #[must_use]
96    pub(crate) const fn from_code(code: &'a str) -> Self {
97        Self {
98            code,
99            sqlstate: None,
100            constraint: None,
101        }
102    }
103
104    #[must_use]
105    pub(crate) const fn code(self) -> &'a str {
106        self.code
107    }
108
109    #[must_use]
110    pub(crate) const fn sqlstate(self) -> Option<&'a str> {
111        self.sqlstate
112    }
113
114    #[must_use]
115    pub(crate) const fn constraint(self) -> Option<&'a str> {
116        self.constraint
117    }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct FrameworkConstraintSpec {
122    category: QueryErrorCategory,
123    code: &'static str,
124    client_message: &'static str,
125}
126
127impl FrameworkConstraintSpec {
128    #[must_use]
129    pub const fn new(
130        category: QueryErrorCategory,
131        code: &'static str,
132        client_message: &'static str,
133    ) -> Self {
134        Self {
135            category,
136            code,
137            client_message,
138        }
139    }
140
141    #[must_use]
142    pub const fn category(&self) -> QueryErrorCategory {
143        self.category
144    }
145
146    #[must_use]
147    pub const fn code(&self) -> &'static str {
148        self.code
149    }
150
151    #[must_use]
152    pub const fn client_message(&self) -> &'static str {
153        self.client_message
154    }
155}
156
157#[derive(Clone)]
158enum QueryErrorClassification {
159    Fixed(QueryErrorKind),
160    Classified(QueryErrorSpec),
161}
162
163impl QueryErrorClassification {
164    const fn spec(&self) -> QueryErrorSpec {
165        match self {
166            Self::Fixed(kind) => kind.spec(),
167            Self::Classified(spec) => *spec,
168        }
169    }
170
171    const fn kind(&self) -> Option<QueryErrorKind> {
172        match self {
173            Self::Fixed(kind) => Some(*kind),
174            Self::Classified(_) => None,
175        }
176    }
177}
178
179#[derive(Clone)]
180pub struct QueryError {
181    classification: QueryErrorClassification,
182    sqlstate: Option<String>,
183    constraint: Option<String>,
184    message: String,
185    source: Option<Arc<sqlx::Error>>,
186}
187
188impl QueryError {
189    #[must_use]
190    pub fn from_classified(
191        category: QueryErrorCategory,
192        code: &'static str,
193        client_message: &'static str,
194        internal_message: impl Into<String>,
195    ) -> Self {
196        Self {
197            classification: QueryErrorClassification::Classified(QueryErrorSpec {
198                category,
199                code,
200                client_message,
201            }),
202            sqlstate: None,
203            constraint: None,
204            message: internal_message.into(),
205            source: None,
206        }
207    }
208
209    #[must_use]
210    pub(crate) fn from_kind(kind: QueryErrorKind, internal_message: impl Into<String>) -> Self {
211        Self {
212            classification: QueryErrorClassification::Fixed(kind),
213            sqlstate: None,
214            constraint: None,
215            message: internal_message.into(),
216            source: None,
217        }
218    }
219
220    #[must_use]
221    pub(crate) fn from_sqlx_with_kind(
222        kind: QueryErrorKind,
223        internal_message: impl Into<String>,
224        source: sqlx::Error,
225    ) -> Self {
226        let (sqlstate, constraint) = source
227            .as_database_error()
228            .map(|database_error| {
229                (
230                    database_error.code().map(|code| code.into_owned()),
231                    database_error.constraint().map(ToOwned::to_owned),
232                )
233            })
234            .unwrap_or((None, None));
235
236        Self {
237            classification: QueryErrorClassification::Fixed(kind),
238            sqlstate,
239            constraint,
240            message: internal_message.into(),
241            source: Some(Arc::new(source)),
242        }
243    }
244
245    #[must_use]
246    pub fn from_sqlx_with_constraint_classifier<F>(
247        error: sqlx::Error,
248        context: Option<&str>,
249        classify_constraint: F,
250    ) -> Self
251    where
252        F: Fn(&str) -> Option<FrameworkConstraintSpec>,
253    {
254        let (sqlstate, constraint, spec, raw_message) = if let Some(db) = error.as_database_error()
255        {
256            let sqlstate = db.code().map(|code| code.into_owned());
257            let constraint = db.constraint().map(ToOwned::to_owned);
258            let spec = classify_query_error_with_constraint_classifier(
259                &db.kind(),
260                sqlstate.as_deref(),
261                constraint.as_deref(),
262                classify_constraint,
263            );
264            (sqlstate, constraint, spec, db.message().to_owned())
265        } else {
266            (
267                None,
268                None,
269                QueryErrorSpec::internal().into(),
270                error.to_string(),
271            )
272        };
273
274        let message = match context {
275            Some(ctx) => format!("{ctx}: {raw_message}"),
276            None => raw_message,
277        };
278
279        let classification = match QueryErrorKind::from_sqlstate(sqlstate.as_deref()) {
280            Some(kind) => QueryErrorClassification::Fixed(kind),
281            None => QueryErrorClassification::Classified(QueryErrorSpec {
282                category: spec.category(),
283                code: spec.code(),
284                client_message: spec.client_message(),
285            }),
286        };
287
288        Self {
289            classification,
290            sqlstate,
291            constraint,
292            message,
293            source: Some(Arc::new(error)),
294        }
295    }
296
297    pub(crate) fn from_sqlx(error: sqlx::Error, context: Option<&str>) -> Self {
298        Self::from_sqlx_with_constraint_classifier(error, context, |_| None)
299    }
300
301    #[must_use]
302    pub const fn category(&self) -> QueryErrorCategory {
303        self.classification.spec().category
304    }
305
306    /// Returns a stable semantic kind when this error participates in
307    /// cross-crate runtime policy.
308    #[must_use]
309    pub const fn kind(&self) -> Option<QueryErrorKind> {
310        self.classification.kind()
311    }
312
313    #[must_use]
314    pub const fn code(&self) -> &'static str {
315        self.classification.spec().code
316    }
317
318    #[must_use]
319    pub const fn client_message(&self) -> &'static str {
320        self.classification.spec().client_message
321    }
322
323    #[must_use]
324    pub fn sqlstate(&self) -> Option<&str> {
325        self.sqlstate.as_deref()
326    }
327
328    #[must_use]
329    pub fn constraint(&self) -> Option<&str> {
330        self.constraint.as_deref()
331    }
332
333    #[must_use]
334    pub(crate) fn sanitized_diagnostics(&self) -> SanitizedQueryErrorDiagnostics<'_> {
335        SanitizedQueryErrorDiagnostics {
336            code: self.code(),
337            sqlstate: self.sqlstate(),
338            constraint: self.constraint(),
339        }
340    }
341
342    #[must_use]
343    pub fn internal_message(&self) -> &str {
344        &self.message
345    }
346
347    /// Returns the underlying SQLx error for trusted diagnostics.
348    ///
349    /// Public [`Display`](fmt::Display) and [`Debug`](fmt::Debug) output for
350    /// [`QueryError`] is sanitized, but the returned source may contain raw
351    /// database details. Do not log or expose it on untrusted boundaries without
352    /// redaction.
353    #[must_use]
354    pub fn source_arc(&self) -> Option<Arc<sqlx::Error>> {
355        self.source.clone()
356    }
357
358    #[must_use]
359    pub fn reclassified_with_constraint_classifier<F>(mut self, classify_constraint: F) -> Self
360    where
361        F: Fn(&str) -> Option<FrameworkConstraintSpec>,
362    {
363        let Some(spec) = self.constraint.as_deref().and_then(classify_constraint) else {
364            return self;
365        };
366
367        self.classification = QueryErrorClassification::Classified(QueryErrorSpec {
368            category: spec.category(),
369            code: spec.code(),
370            client_message: spec.client_message(),
371        });
372        self
373    }
374}
375
376impl fmt::Debug for QueryError {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        f.debug_struct("QueryError")
379            .field("category", &self.category())
380            .field("kind", &self.kind())
381            .field("code", &self.code())
382            .field("client_message", &self.client_message())
383            .field("sqlstate", &self.sqlstate)
384            .field("constraint", &self.constraint)
385            .field("has_source", &self.source.is_some())
386            .finish()
387    }
388}
389
390impl fmt::Display for QueryError {
391    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392        write!(f, "{}", self.client_message())
393    }
394}
395
396impl std::error::Error for QueryError {
397    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
398        self.source
399            .as_deref()
400            .map(|source| source as &(dyn std::error::Error + 'static))
401    }
402}
403
404#[derive(Debug, Clone, Copy)]
405struct QueryErrorSpec {
406    category: QueryErrorCategory,
407    code: &'static str,
408    client_message: &'static str,
409}
410
411impl QueryErrorSpec {
412    const fn conflict(code: &'static str, client_message: &'static str) -> Self {
413        Self {
414            category: QueryErrorCategory::Conflict,
415            code,
416            client_message,
417        }
418    }
419
420    const fn validation(code: &'static str, client_message: &'static str) -> Self {
421        Self {
422            category: QueryErrorCategory::Validation,
423            code,
424            client_message,
425        }
426    }
427
428    const fn forbidden(code: &'static str, client_message: &'static str) -> Self {
429        Self {
430            category: QueryErrorCategory::Forbidden,
431            code,
432            client_message,
433        }
434    }
435
436    const fn internal() -> Self {
437        Self {
438            category: QueryErrorCategory::Internal,
439            code: "db.query_failed",
440            client_message: "Database operation failed.",
441        }
442    }
443}
444
445impl From<QueryErrorSpec> for FrameworkConstraintSpec {
446    fn from(spec: QueryErrorSpec) -> Self {
447        Self::new(spec.category, spec.code, spec.client_message)
448    }
449}
450
451#[must_use]
452pub fn classify_query_error(
453    kind: &ErrorKind,
454    sqlstate: Option<&str>,
455    constraint: Option<&str>,
456) -> FrameworkConstraintSpec {
457    classify_query_error_with_constraint_classifier(kind, sqlstate, constraint, |_| None)
458}
459
460#[must_use]
461pub fn classify_query_error_with_constraint_classifier<F>(
462    kind: &ErrorKind,
463    sqlstate: Option<&str>,
464    constraint: Option<&str>,
465    classify_constraint: F,
466) -> FrameworkConstraintSpec
467where
468    F: Fn(&str) -> Option<FrameworkConstraintSpec>,
469{
470    if let Some(spec) = constraint.and_then(classify_constraint) {
471        return spec;
472    }
473
474    classify_database_error(kind, sqlstate, constraint).into()
475}
476
477fn classify_database_error(
478    kind: &ErrorKind,
479    sqlstate: Option<&str>,
480    constraint: Option<&str>,
481) -> QueryErrorSpec {
482    if let Some(spec) = constraint.and_then(classify_constraint) {
483        return spec;
484    }
485
486    match (kind, sqlstate) {
487        (ErrorKind::UniqueViolation, _) | (_, Some("23505")) => {
488            QueryErrorSpec::conflict("db.unique_violation", "Resource already exists.")
489        }
490        (ErrorKind::ForeignKeyViolation, _) | (_, Some("23503")) => QueryErrorSpec::validation(
491            "db.related_resource_missing",
492            "Related resource does not exist.",
493        ),
494        (_, Some("23001")) => QueryErrorSpec::validation(
495            "db.related_resource_still_referenced",
496            "Related resource is still referenced and cannot be deleted.",
497        ),
498        (ErrorKind::CheckViolation, _) | (_, Some("23514")) => QueryErrorSpec::validation(
499            "db.business_rule_violation",
500            "Request violates a business rule.",
501        ),
502        (ErrorKind::NotNullViolation, _) | (_, Some("23502")) => {
503            QueryErrorSpec::validation("db.required_field_missing", "Required data is missing.")
504        }
505        (_, Some("42501")) => {
506            QueryErrorSpec::forbidden("db.permission_denied", "Operation is not allowed.")
507        }
508        _ => QueryErrorSpec::internal(),
509    }
510}
511
512fn classify_constraint(constraint: &str) -> Option<QueryErrorSpec> {
513    classify::classify_constraint(constraint)
514}
515
516#[must_use]
517pub fn classify_framework_constraint(constraint: &str) -> Option<FrameworkConstraintSpec> {
518    classify_constraint(constraint).map(FrameworkConstraintSpec::from)
519}
520
521#[must_use]
522pub fn has_framework_constraint_classifier(constraint: &str) -> bool {
523    classify_framework_constraint(constraint).is_some()
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn classifies_job_idempotency_constraint() {
532        let spec = classify_database_error(
533            &ErrorKind::UniqueViolation,
534            Some("23505"),
535            Some("uq_job_queue_type_idempotency_org"),
536        );
537        assert_eq!(spec.category, QueryErrorCategory::Conflict);
538        assert_eq!(spec.code, "job.already_enqueued");
539    }
540
541    #[test]
542    fn classifies_global_job_idempotency_constraint() {
543        let spec = classify_database_error(
544            &ErrorKind::UniqueViolation,
545            Some("23505"),
546            Some("uq_job_queue_type_idempotency_global"),
547        );
548        assert_eq!(spec.category, QueryErrorCategory::Conflict);
549        assert_eq!(spec.code, "job.already_enqueued");
550    }
551
552    #[test]
553    fn classifies_workflow_idempotency_constraint() {
554        let spec = classify_database_error(
555            &ErrorKind::UniqueViolation,
556            Some("23505"),
557            Some("uq_workflow_runs_type_idempotency_org"),
558        );
559        assert_eq!(spec.category, QueryErrorCategory::Conflict);
560        assert_eq!(spec.code, "workflow.already_enqueued");
561    }
562
563    #[test]
564    fn classifies_global_workflow_idempotency_constraint() {
565        let spec = classify_database_error(
566            &ErrorKind::UniqueViolation,
567            Some("23505"),
568            Some("uq_workflow_runs_type_idempotency_global"),
569        );
570        assert_eq!(spec.category, QueryErrorCategory::Conflict);
571        assert_eq!(spec.code, "workflow.already_enqueued");
572    }
573
574    #[test]
575    fn classifies_job_definition_fk_constraint() {
576        let spec = classify_database_error(
577            &ErrorKind::ForeignKeyViolation,
578            Some("23503"),
579            Some("fk_job_queue_job_type"),
580        );
581        assert_eq!(spec.category, QueryErrorCategory::Validation);
582        assert_eq!(spec.code, "job.definition_not_found");
583    }
584
585    #[test]
586    fn classifies_job_runtime_config_definition_fk_constraint() {
587        let spec = classify_database_error(
588            &ErrorKind::ForeignKeyViolation,
589            Some("23503"),
590            Some("fk_job_runtime_configs_job_type"),
591        );
592        assert_eq!(spec.category, QueryErrorCategory::Validation);
593        assert_eq!(spec.code, "job.definition_not_found");
594    }
595
596    #[test]
597    fn classifies_job_organization_fk_constraint() {
598        let spec = classify_database_error(
599            &ErrorKind::ForeignKeyViolation,
600            Some("23503"),
601            Some("fk_job_queue_organization"),
602        );
603        assert_eq!(spec.category, QueryErrorCategory::Validation);
604        assert_eq!(spec.code, "job.organization_not_found");
605    }
606
607    #[test]
608    fn classifies_workflow_linkage_symmetry_constraint() {
609        let spec = classify_database_error(
610            &ErrorKind::CheckViolation,
611            Some("23514"),
612            Some("os_workflow_job_linkage_symmetry"),
613        );
614        assert_eq!(spec.category, QueryErrorCategory::Validation);
615        assert_eq!(spec.code, "workflow.linkage_symmetry_violation");
616    }
617
618    #[test]
619    fn classifies_workflow_linkage_symmetry_trigger_table_constraint() {
620        let spec = classify_database_error(
621            &ErrorKind::CheckViolation,
622            Some("23514"),
623            Some("os_workflow_job_linkage_symmetry_trigger_table"),
624        );
625        assert_eq!(spec.category, QueryErrorCategory::Validation);
626        assert_eq!(spec.code, "workflow.linkage_symmetry_trigger_table_invalid");
627    }
628
629    #[test]
630    fn classifies_workflow_linkage_cutover_audit_constraints() {
631        for constraint in [
632            "os_workflow_job_linkage_expand_audit",
633            "os_workflow_job_linkage_expand_rollback_audit",
634            "os_workflow_job_linkage_contract_audit",
635        ] {
636            let spec = classify_database_error(
637                &ErrorKind::CheckViolation,
638                Some("23514"),
639                Some(constraint),
640            );
641            assert_eq!(spec.code, "workflow.linkage_cutover_audit_failed");
642        }
643    }
644
645    #[test]
646    fn classifies_workflow_linkage_compatibility_trigger_table_constraint() {
647        let spec = classify_database_error(
648            &ErrorKind::CheckViolation,
649            Some("23514"),
650            Some("os_workflow_job_linkage_compatibility_trigger_table"),
651        );
652        assert_eq!(
653            spec.code,
654            "workflow.linkage_compatibility_trigger_table_invalid"
655        );
656    }
657
658    #[test]
659    fn classifies_external_gate_downgrade_blocked_constraint() {
660        let spec = classify_database_error(
661            &ErrorKind::CheckViolation,
662            Some("23514"),
663            Some("os_workflow_external_gate_downgrade_waiting_runs_exist"),
664        );
665        assert_eq!(spec.category, QueryErrorCategory::Validation);
666        assert_eq!(spec.code, "workflow.external_gate_downgrade_blocked");
667    }
668
669    #[test]
670    fn custom_constraint_classifier_takes_precedence() {
671        let spec = classify_query_error_with_constraint_classifier(
672            &ErrorKind::UniqueViolation,
673            Some("23505"),
674            Some("os_custom_override"),
675            |constraint| {
676                (constraint == "os_custom_override").then_some(FrameworkConstraintSpec::new(
677                    QueryErrorCategory::Forbidden,
678                    "custom.override",
679                    "Custom override wins.",
680                ))
681            },
682        );
683        assert_eq!(spec.category(), QueryErrorCategory::Forbidden);
684        assert_eq!(spec.code(), "custom.override");
685        assert_eq!(spec.client_message(), "Custom override wins.");
686    }
687
688    #[test]
689    fn query_error_debug_omits_internal_message() {
690        let error = QueryError::from_classified(
691            QueryErrorCategory::Conflict,
692            "job.idempotency_conflict",
693            "Job enqueue retry conflicts with the existing idempotency key.",
694            "internal context includes secret-idempotency-key",
695        );
696
697        let debug = format!("{error:?}");
698        assert_eq!(
699            debug,
700            "QueryError { category: Conflict, kind: None, code: \"job.idempotency_conflict\", client_message: \"Job enqueue retry conflicts with the existing idempotency key.\", sqlstate: None, constraint: None, has_source: false }"
701        );
702        assert!(!debug.contains("secret-idempotency-key"));
703
704        let display = error.to_string();
705        assert_eq!(
706            display,
707            "Job enqueue retry conflicts with the existing idempotency key."
708        );
709        assert!(!display.contains("secret-idempotency-key"));
710    }
711
712    #[test]
713    fn query_error_from_sqlx_uses_sanitized_display_and_debug() {
714        let error = QueryError::from_sqlx(
715            sqlx::Error::Protocol("internal secret-idempotency-key detail".into()),
716            Some("sensitive context"),
717        );
718
719        let display = error.to_string();
720        assert_eq!(display, "Database operation failed.");
721        assert!(!display.contains("secret-idempotency-key"));
722
723        let debug = format!("{error:?}");
724        assert!(debug.contains("db.query_failed"));
725        assert!(!debug.contains("secret-idempotency-key"));
726        assert!(error.internal_message().contains("secret-idempotency-key"));
727        assert!(std::error::Error::source(&error).is_some());
728        assert!(error.source_arc().is_some());
729    }
730
731    #[test]
732    fn sanitized_diagnostics_omit_internal_message_and_source() {
733        let error = QueryError::from_sqlx(
734            sqlx::Error::Protocol("database detail includes secret-idempotency-key".into()),
735            Some("sensitive context includes secret-idempotency-key"),
736        );
737
738        let diagnostics = error.sanitized_diagnostics();
739
740        assert_eq!(diagnostics.code(), "db.query_failed");
741        assert_eq!(diagnostics.sqlstate(), None);
742        assert_eq!(diagnostics.constraint(), None);
743        let debug = format!("{diagnostics:?}");
744        assert!(debug.contains("db.query_failed"));
745        assert!(!debug.contains("secret-idempotency-key"));
746    }
747
748    #[test]
749    fn typed_query_error_from_classified_sqlx_preserves_source_without_leaking_display() {
750        let error = QueryError::from_sqlx_with_kind(
751            QueryErrorKind::WorkflowReleaseConflict,
752            "internal context includes secret-lock-key",
753            sqlx::Error::Protocol("database detail includes secret-lock-key".into()),
754        );
755
756        assert_eq!(error.category(), QueryErrorCategory::Conflict);
757        assert_eq!(error.kind(), Some(QueryErrorKind::WorkflowReleaseConflict));
758        assert_eq!(error.code(), "workflow.release_conflict");
759        assert_eq!(
760            error.client_message(),
761            "Workflow step release conflicted with another workflow mutation."
762        );
763        assert!(error.internal_message().contains("secret-lock-key"));
764        assert!(error.source_arc().is_some());
765        assert!(std::error::Error::source(&error).is_some());
766
767        let display = error.to_string();
768        assert_eq!(
769            display,
770            "Workflow step release conflicted with another workflow mutation."
771        );
772        assert!(!display.contains("secret-lock-key"));
773
774        let debug = format!("{error:?}");
775        assert_eq!(
776            debug,
777            "QueryError { category: Conflict, kind: Some(WorkflowReleaseConflict), code: \"workflow.release_conflict\", client_message: \"Workflow step release conflicted with another workflow mutation.\", sqlstate: None, constraint: None, has_source: true }"
778        );
779        assert!(!debug.contains("secret-lock-key"));
780    }
781
782    #[test]
783    fn fixed_query_error_kind_metadata_is_exhaustive_and_stable() {
784        let cases = [
785            (
786                QueryErrorKind::JobLeaseOwnerMismatch,
787                QueryErrorCategory::Forbidden,
788                "job.lease_owner_mismatch",
789                "Job lease is not currently held by this worker.",
790            ),
791            (
792                QueryErrorKind::JobInvalidCompletionProgress,
793                QueryErrorCategory::Validation,
794                "job.invalid_completion_progress",
795                "Job completion progress is invalid.",
796            ),
797            (
798                QueryErrorKind::JobInvalidContinuationDelay,
799                QueryErrorCategory::Validation,
800                "job.invalid_continuation_delay",
801                "Job continuation delay is too large.",
802            ),
803            (
804                QueryErrorKind::JobInvalidRetryTiming,
805                QueryErrorCategory::Validation,
806                "job.invalid_retry_timing",
807                "Job retry timing is invalid.",
808            ),
809            (
810                QueryErrorKind::JobUnstartedClaimReleaseNotApplicable,
811                QueryErrorCategory::Validation,
812                "job.unstarted_claim_release_not_applicable",
813                "Job claim cannot be released as unstarted.",
814            ),
815            (
816                QueryErrorKind::JobWorkflowHandlerContinuationNotEnabled,
817                QueryErrorCategory::Validation,
818                "job.workflow_handler_continuation_not_enabled",
819                "Workflow step handler continuation is not enabled.",
820            ),
821            (
822                QueryErrorKind::JobWorkflowRequeueNotSupported,
823                QueryErrorCategory::Validation,
824                "job.workflow_requeue_not_supported",
825                "Workflow-managed jobs cannot be requeued directly.",
826            ),
827            (
828                QueryErrorKind::PostgresLockNotAvailable,
829                QueryErrorCategory::Internal,
830                "db.query_failed",
831                "Database operation failed.",
832            ),
833            (
834                QueryErrorKind::WorkflowReleaseConflict,
835                QueryErrorCategory::Conflict,
836                "workflow.release_conflict",
837                "Workflow step release conflicted with another workflow mutation.",
838            ),
839        ];
840
841        for (kind, expected_category, expected_code, expected_client_message) in cases {
842            let error = QueryError::from_kind(kind, "internal detail");
843
844            assert_eq!(error.kind(), Some(kind));
845            assert_eq!(error.category(), expected_category);
846            assert_eq!(error.code(), expected_code);
847            assert_eq!(error.client_message(), expected_client_message);
848        }
849    }
850
851    #[test]
852    fn maps_only_postgres_lock_not_available_to_a_runtime_policy_kind() {
853        assert_eq!(
854            QueryErrorKind::from_sqlstate(Some("55P03")),
855            Some(QueryErrorKind::PostgresLockNotAvailable)
856        );
857        assert_eq!(QueryErrorKind::from_sqlstate(Some("57014")), None);
858        assert_eq!(QueryErrorKind::from_sqlstate(None), None);
859    }
860
861    #[test]
862    fn classifies_permission_denied() {
863        let spec = classify_database_error(&ErrorKind::Other, Some("42501"), None);
864        assert_eq!(spec.category, QueryErrorCategory::Forbidden);
865        assert_eq!(spec.code, "db.permission_denied");
866    }
867
868    #[test]
869    fn falls_back_to_internal_for_unmapped_errors() {
870        let spec = classify_database_error(&ErrorKind::Other, Some("99999"), Some("not_mapped"));
871        assert_eq!(spec.category, QueryErrorCategory::Internal);
872        assert_eq!(spec.code, "db.query_failed");
873    }
874}