Skip to main content

type_bridge/
error.rs

1//! Error handling for the public TypeBridge client.
2
3use std::collections::BTreeMap;
4use std::error::Error as StdError;
5use std::fmt;
6
7use type_bridge_contract::sdk_diagnostic::{
8    SdkDiagnosticCategory, SdkDiagnosticDetailValue, SdkDiagnosticPathSegment,
9    SdkExecutionDiagnostic, SdkProjectionEvidenceSlotPresence, SdkQueryDiagnosticCategory,
10    SdkQueryDiagnosticPathKind,
11};
12use type_bridge_orm::match_request::MatchError;
13use type_bridge_orm::{
14    ProjectedCrudCompatibilityCause, ProjectedCrudCompatibilityFailure,
15    ProjectedCrudCompatibilityStage,
16};
17
18use crate::hooks::{CrudOperation, ModelKind};
19
20/// Stable public classification for TypeBridge client failures.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22#[non_exhaustive]
23pub enum ErrorCategory {
24    /// Connection establishment or connectivity failed.
25    Connection,
26    /// Generated or installed schema authority failed verification.
27    Schema,
28    /// Generated input or provider evidence failed model validation.
29    ModelValidation,
30    /// Generated package or provider evidence failed an integrity contract.
31    Integrity,
32    /// A typed query was invalid before provider execution.
33    QueryAuthoring,
34    /// The provider failed while executing an accepted query.
35    QueryExecution,
36    /// A transaction lifecycle operation failed.
37    Transaction,
38    /// A remote envelope, reply, transport, or integrity contract failed.
39    Remote,
40    /// The selected provider or remote executor lacks a required capability.
41    Capability,
42    /// A canonical client, provider, or remote resource ceiling was exceeded.
43    ResourceLimit,
44    /// Cooperative cancellation interrupted the operation.
45    Cancelled,
46    /// A requested entity or schema element was not found.
47    NotFound,
48    /// A generated-model lifecycle hook rejected or failed an operation.
49    Lifecycle,
50    /// An underlying database operation failed outside a narrower category.
51    Database,
52    /// A client invariant failed outside the stable categories above.
53    Other,
54}
55
56impl ErrorCategory {
57    /// Return the stable language-neutral category spelling.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::Connection => "connection",
62            Self::Schema => "schema",
63            Self::ModelValidation => "model_validation",
64            Self::Integrity => "integrity",
65            Self::QueryAuthoring => "query_authoring",
66            Self::QueryExecution => "query_execution",
67            Self::Transaction => "transaction",
68            Self::Remote => "remote",
69            Self::Capability => "capability",
70            Self::ResourceLimit => "resource_limit",
71            Self::Cancelled => "cancelled",
72            Self::NotFound => "not_found",
73            Self::Lifecycle => "lifecycle",
74            Self::Database => "database",
75            Self::Other => "other",
76        }
77    }
78}
79
80impl fmt::Display for ErrorCategory {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        formatter.write_str(self.as_str())
83    }
84}
85
86/// Stage at which generated-model evidence failed validation.
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum ModelValidationPhase {
89    /// Generated constructor input failed before provider execution.
90    Input,
91    /// Provider row evidence failed while hydrating a generated model.
92    Hydration,
93}
94
95/// One typed value from a structured engine or remote diagnostic.
96#[derive(Clone, Debug, Eq, PartialEq)]
97#[non_exhaustive]
98pub enum ErrorDetail {
99    /// Textual context.
100    Text(String),
101    /// A signed integer.
102    Long(i64),
103    /// A boolean fact.
104    Boolean(bool),
105    /// An ordered list of text values.
106    TextList(Vec<String>),
107    /// The exact canonical typed-query category.
108    QueryCategory(QueryDiagnosticCategory),
109    /// One bounded canonical query or contract identity.
110    QueryIdentity(String),
111    /// An ordered bounded list of canonical query or contract identities.
112    QueryIdentityList(Vec<String>),
113}
114
115/// Stable public typed-query diagnostic categories.
116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117#[non_exhaustive]
118pub enum QueryDiagnosticCategory {
119    /// The immutable query plan is invalid.
120    InvalidPlan,
121    /// A terminal cardinality contract was not satisfied.
122    Cardinality,
123    /// The provider lacks a required query capability.
124    UnsupportedCapability,
125    /// Request-relevant schema authority changed.
126    StaleSchema,
127    /// A query resource ceiling was crossed.
128    ResourceLimit,
129    /// Cooperative cancellation interrupted execution.
130    Cancelled,
131    /// The provider failed before complete evidence was available.
132    Provider,
133    /// Result evidence did not match the validated invocation.
134    ResultDecode,
135}
136
137/// Stable structural locations within a typed query request or result.
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139#[non_exhaustive]
140pub enum QueryDiagnosticPathKind {
141    /// The request envelope.
142    Request,
143    /// The graph plan.
144    Plan,
145    /// The selected terminal operation.
146    Operation,
147    /// The predicate tree.
148    Predicate,
149    /// The declared output shape.
150    Output,
151    /// Provider solution or hydration evidence.
152    ProviderEvidence,
153    /// The validated result envelope.
154    Result,
155}
156
157/// One typed segment from a structured engine or remote diagnostic path.
158#[derive(Clone, Debug, Eq, PartialEq)]
159#[non_exhaustive]
160pub enum ErrorPathSegment {
161    /// A named public operation argument.
162    Argument(String),
163    /// A named field in the rejected contract.
164    Field(String),
165    /// An indexed member in the rejected contract.
166    Index(u64),
167    /// A schema or query identifier.
168    Identifier(String),
169    /// A structural typed-query request or result location.
170    Query(QueryDiagnosticPathKind),
171    /// A plan-local typed-query binding ordinal.
172    QueryBinding(u16),
173    /// A descriptor-qualified binding-facing query field.
174    QueryField {
175        /// Kind-qualified registry descriptor identity.
176        owner: String,
177        /// Binding-facing field name.
178        name: String,
179    },
180    /// A descriptor-qualified query role.
181    QueryRole {
182        /// Kind-qualified registry descriptor identity.
183        owner: String,
184        /// Binding-facing role name.
185        name: String,
186    },
187    /// A plan-local typed-query role-edge ordinal.
188    QueryRoleEdge(u16),
189    /// A zero-based positional query output slot.
190    QueryOutputSlot(u64),
191    /// A declared generated query output member.
192    QueryOutputName(String),
193    /// An object field retained from an authenticated contract diagnostic.
194    ContractField(String),
195    /// An identity retained from an authenticated contract diagnostic.
196    ContractIdentity(String),
197}
198
199/// Complete typed metadata supplied by one structured engine or remote
200/// diagnostic.
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct ErrorDiagnostic {
203    path: Vec<ErrorPathSegment>,
204    details: BTreeMap<String, ErrorDetail>,
205}
206
207impl ErrorDiagnostic {
208    /// Return the typed diagnostic path.
209    #[must_use]
210    pub fn path(&self) -> &[ErrorPathSegment] {
211        &self.path
212    }
213
214    /// Return the deterministic typed detail map.
215    #[must_use]
216    pub fn details(&self) -> &BTreeMap<String, ErrorDetail> {
217        &self.details
218    }
219}
220
221/// Primary error type for the TypeBridge client SDK.
222#[derive(Debug, thiserror::Error)]
223#[non_exhaustive]
224pub enum Error {
225    /// Generated-model evidence did not match the installed schema projection.
226    #[error("Model validation failed during {phase:?}: {message}")]
227    ModelValidation {
228        /// Validation stage at which the evidence failed.
229        phase: ModelValidationPhase,
230        /// Stable language-neutral failure code.
231        code: String,
232        /// Canonical path to the rejected value or model evidence.
233        path: Vec<String>,
234        /// Human-readable failure summary.
235        message: String,
236        /// Optional underlying error that caused the validation failure.
237        #[source]
238        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
239    },
240
241    /// A structured engine or remote-contract failure mapped into stable
242    /// client-owned categories, codes, and paths.
243    #[error("{category} error [{code}]: {message}")]
244    Classified {
245        /// Stable public error category.
246        category: ErrorCategory,
247        /// Model-validation phase when applicable to this failure.
248        phase: Option<ModelValidationPhase>,
249        /// Stable language-neutral failure code.
250        code: String,
251        /// Canonical flattened path to the rejected contract member.
252        path: Vec<String>,
253        /// Typed engine or remote diagnostic metadata, when supplied.
254        diagnostic: Option<Box<ErrorDiagnostic>>,
255        /// Human-readable failure summary.
256        message: String,
257        /// Optional underlying error that caused the classified failure.
258        #[source]
259        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
260    },
261
262    /// Schema verification or installation failed.
263    #[error("Schema verification failed: {message}")]
264    SchemaVerification {
265        /// Human-readable schema verification failure summary.
266        message: String,
267        /// Optional underlying schema decoding or installation error.
268        #[source]
269        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
270    },
271
272    /// Connection to the database failed.
273    #[error("Connection error: {message}")]
274    Connection {
275        /// Human-readable connection failure summary.
276        message: String,
277        /// Optional underlying transport or provider error.
278        #[source]
279        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
280    },
281
282    /// Database query or operation failed.
283    #[error("Query execution error: {message}")]
284    QueryExecution {
285        /// Human-readable query execution failure summary.
286        message: String,
287        /// Optional underlying provider or remote execution error.
288        #[source]
289        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
290    },
291
292    /// Database transaction failed.
293    #[error("Transaction error: {message}")]
294    Transaction {
295        /// Human-readable transaction failure summary.
296        message: String,
297        /// Optional underlying provider transaction error.
298        #[source]
299        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
300    },
301
302    /// Requested schema element or database entity was not found.
303    #[error("Entity not found: {message}")]
304    NotFound {
305        /// Human-readable description of the missing resource.
306        message: String,
307        /// Optional underlying lookup error.
308        #[source]
309        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
310    },
311
312    /// Underlying database error.
313    #[error("Database error: {message}")]
314    Database {
315        /// Human-readable database failure summary.
316        message: String,
317        /// Optional underlying database driver error.
318        #[source]
319        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
320    },
321
322    /// Client request or execution error.
323    #[error("Client error: {message}")]
324    Other {
325        /// Human-readable client failure summary.
326        message: String,
327        /// Optional underlying error outside the narrower variants.
328        #[source]
329        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
330    },
331}
332
333impl Error {
334    pub(crate) fn from_contract_diagnostic(
335        error: type_bridge_contract::diagnostic::Diagnostic,
336    ) -> Self {
337        use type_bridge_contract::diagnostic::DiagnosticCategory;
338
339        let category = match error.category() {
340            DiagnosticCategory::InvalidContract => ErrorCategory::Other,
341            DiagnosticCategory::UnsupportedCapability => ErrorCategory::Capability,
342            DiagnosticCategory::ResourceLimit => ErrorCategory::ResourceLimit,
343            DiagnosticCategory::Cancelled => ErrorCategory::Cancelled,
344            DiagnosticCategory::Integrity => ErrorCategory::Integrity,
345        };
346        let code = error.code().as_str().to_owned();
347        let message = error.to_string();
348        Self::classified_with_diagnostic(
349            category,
350            None,
351            code,
352            Vec::new(),
353            None,
354            message,
355            Some(Box::new(error)),
356        )
357    }
358
359    #[allow(dead_code)]
360    pub(crate) fn model_validation(
361        phase: ModelValidationPhase,
362        code: impl Into<String>,
363        path: Vec<String>,
364        message: impl Into<String>,
365        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
366    ) -> Self {
367        Self::ModelValidation {
368            phase,
369            code: code.into(),
370            path,
371            message: message.into(),
372            source,
373        }
374    }
375
376    pub(crate) fn classified(
377        category: ErrorCategory,
378        phase: Option<ModelValidationPhase>,
379        code: impl Into<String>,
380        path: Vec<String>,
381        message: impl Into<String>,
382        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
383    ) -> Self {
384        Self::classified_with_diagnostic(category, phase, code, path, None, message, source)
385    }
386
387    pub(crate) fn projection_evidence_rejection(
388        presence: SdkProjectionEvidenceSlotPresence,
389    ) -> Self {
390        let error = SdkExecutionDiagnostic::classify_detached_semantic_schema_fingerprint_rejection(
391            presence,
392        );
393        let code = error.code().as_str().to_owned();
394        let message = error.message().as_str().to_owned();
395        let path = error.path().iter().map(flatten_sdk_path).collect();
396        let diagnostic = ErrorDiagnostic {
397            path: error
398                .path()
399                .iter()
400                .map(|segment| match segment {
401                    SdkDiagnosticPathSegment::Argument(value) => {
402                        ErrorPathSegment::Argument(value.as_str().to_owned())
403                    }
404                    other => typed_sdk_path(other),
405                })
406                .collect(),
407            details: error
408                .details()
409                .iter()
410                .map(|(name, value)| (name.as_str().to_owned(), flatten_sdk_detail(value)))
411                .collect(),
412        };
413        Self::classified_with_diagnostic(
414            ErrorCategory::Integrity,
415            None,
416            code,
417            path,
418            Some(diagnostic),
419            message,
420            Some(Box::new(error)),
421        )
422    }
423
424    fn classified_with_diagnostic(
425        category: ErrorCategory,
426        phase: Option<ModelValidationPhase>,
427        code: impl Into<String>,
428        path: Vec<String>,
429        diagnostic: Option<ErrorDiagnostic>,
430        message: impl Into<String>,
431        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
432    ) -> Self {
433        Self::Classified {
434            category,
435            phase,
436            code: code.into(),
437            path,
438            diagnostic: diagnostic.map(Box::new),
439            message: message.into(),
440            source,
441        }
442    }
443
444    /// Construct one application-owned remote transport failure.
445    ///
446    /// Transport implementations should use a stable lowercase snake-case
447    /// code so callers can handle the failure without parsing its message.
448    #[must_use]
449    pub fn remote(
450        code: impl Into<String>,
451        message: impl Into<String>,
452        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
453    ) -> Self {
454        Self::classified(
455            ErrorCategory::Remote,
456            None,
457            code,
458            Vec::new(),
459            message,
460            source,
461        )
462    }
463
464    pub(crate) fn from_match(error: MatchError, phase: ModelValidationPhase) -> Self {
465        Self::from_sdk_execution(type_bridge_orm::lower_match_error(&error), phase)
466    }
467
468    pub(crate) fn from_sdk_execution(
469        error: SdkExecutionDiagnostic,
470        model_phase: ModelValidationPhase,
471    ) -> Self {
472        let code = error.code().as_str().to_owned();
473        let message = error.message().as_str().to_owned();
474        let path = error.path().iter().map(flatten_sdk_path).collect();
475        let diagnostic_path = error.path().iter().map(typed_sdk_path).collect();
476        let details = error
477            .details()
478            .iter()
479            .map(|(name, value)| (name.as_str().to_owned(), flatten_sdk_detail(value)))
480            .collect();
481        let diagnostic = ErrorDiagnostic {
482            path: diagnostic_path,
483            details,
484        };
485        if let Some(query_category) = sdk_query_category(&error) {
486            let (category, phase) = match query_category {
487                SdkQueryDiagnosticCategory::InvalidPlan => (ErrorCategory::QueryAuthoring, None),
488                SdkQueryDiagnosticCategory::Cardinality
489                | SdkQueryDiagnosticCategory::ResultDecode => {
490                    (ErrorCategory::ModelValidation, Some(model_phase))
491                }
492                SdkQueryDiagnosticCategory::UnsupportedCapability => {
493                    (ErrorCategory::Capability, None)
494                }
495                SdkQueryDiagnosticCategory::StaleSchema => (ErrorCategory::Schema, None),
496                SdkQueryDiagnosticCategory::ResourceLimit => (ErrorCategory::ResourceLimit, None),
497                SdkQueryDiagnosticCategory::Cancelled => (ErrorCategory::Cancelled, None),
498                SdkQueryDiagnosticCategory::Provider => (ErrorCategory::QueryExecution, None),
499                _ => (ErrorCategory::Other, None),
500            };
501            return Self::classified_with_diagnostic(
502                category,
503                phase,
504                code,
505                path,
506                Some(diagnostic),
507                message,
508                Some(Box::new(error)),
509            );
510        }
511        match error.category() {
512            SdkDiagnosticCategory::InvalidInput | SdkDiagnosticCategory::Integrity => {
513                Self::ModelValidation {
514                    phase: model_phase,
515                    code,
516                    path,
517                    message,
518                    source: Some(Box::new(error)),
519                }
520            }
521            SdkDiagnosticCategory::Provider => Self::QueryExecution {
522                message,
523                source: Some(Box::new(error)),
524            },
525            SdkDiagnosticCategory::Transaction => Self::Transaction {
526                message,
527                source: Some(Box::new(error)),
528            },
529            SdkDiagnosticCategory::UnsupportedCapability => Self::classified_with_diagnostic(
530                ErrorCategory::Capability,
531                None,
532                code,
533                path,
534                Some(diagnostic),
535                message,
536                Some(Box::new(error)),
537            ),
538            SdkDiagnosticCategory::ResourceLimit => Self::classified_with_diagnostic(
539                ErrorCategory::ResourceLimit,
540                None,
541                code,
542                path,
543                Some(diagnostic),
544                message,
545                Some(Box::new(error)),
546            ),
547            SdkDiagnosticCategory::Cancelled => Self::classified_with_diagnostic(
548                ErrorCategory::Cancelled,
549                None,
550                code,
551                path,
552                Some(diagnostic),
553                message,
554                Some(Box::new(error)),
555            ),
556            SdkDiagnosticCategory::Internal => Self::classified_with_diagnostic(
557                ErrorCategory::Other,
558                None,
559                code,
560                path,
561                Some(diagnostic),
562                message,
563                Some(Box::new(error)),
564            ),
565            _ => Self::Other {
566                message,
567                source: Some(Box::new(error)),
568            },
569        }
570    }
571
572    #[cfg(feature = "typedb")]
573    pub(crate) fn from_direct_connection(error: SdkExecutionDiagnostic) -> Self {
574        let category = match error.category() {
575            SdkDiagnosticCategory::InvalidInput | SdkDiagnosticCategory::Provider => {
576                ErrorCategory::Connection
577            }
578            SdkDiagnosticCategory::Integrity => ErrorCategory::Integrity,
579            SdkDiagnosticCategory::UnsupportedCapability => ErrorCategory::Capability,
580            SdkDiagnosticCategory::ResourceLimit => ErrorCategory::ResourceLimit,
581            SdkDiagnosticCategory::Cancelled => ErrorCategory::Cancelled,
582            SdkDiagnosticCategory::Transaction | SdkDiagnosticCategory::Internal => {
583                ErrorCategory::Other
584            }
585            _ => ErrorCategory::Other,
586        };
587        let code = error.code().as_str().to_owned();
588        let message = error.message().as_str().to_owned();
589        let path = error.path().iter().map(flatten_sdk_path).collect();
590        let diagnostic = ErrorDiagnostic {
591            path: error.path().iter().map(typed_sdk_path).collect(),
592            details: error
593                .details()
594                .iter()
595                .map(|(name, value)| (name.as_str().to_owned(), flatten_sdk_detail(value)))
596                .collect(),
597        };
598        Self::classified_with_diagnostic(
599            category,
600            None,
601            code,
602            path,
603            Some(diagnostic),
604            message,
605            Some(Box::new(error)),
606        )
607    }
608
609    pub(crate) fn from_projected_batch(
610        error: SdkExecutionDiagnostic,
611        model_phase: ModelValidationPhase,
612    ) -> Self {
613        let (category, phase) = match error.category() {
614            SdkDiagnosticCategory::InvalidInput => {
615                (ErrorCategory::ModelValidation, Some(model_phase))
616            }
617            SdkDiagnosticCategory::Integrity => (ErrorCategory::Integrity, Some(model_phase)),
618            SdkDiagnosticCategory::Provider => (ErrorCategory::QueryExecution, None),
619            SdkDiagnosticCategory::Transaction => (ErrorCategory::Transaction, None),
620            SdkDiagnosticCategory::UnsupportedCapability => (ErrorCategory::Capability, None),
621            SdkDiagnosticCategory::ResourceLimit => (ErrorCategory::ResourceLimit, None),
622            SdkDiagnosticCategory::Cancelled => (ErrorCategory::Cancelled, None),
623            SdkDiagnosticCategory::Internal => (ErrorCategory::Other, None),
624            _ => (ErrorCategory::Other, None),
625        };
626        let code = error.code().as_str().to_owned();
627        let message = error.message().as_str().to_owned();
628        let path = error.path().iter().map(flatten_sdk_path).collect();
629        let diagnostic = ErrorDiagnostic {
630            path: error
631                .path()
632                .iter()
633                .map(typed_projected_batch_path)
634                .collect(),
635            details: error
636                .details()
637                .iter()
638                .map(|(name, value)| (name.as_str().to_owned(), flatten_sdk_detail(value)))
639                .collect(),
640        };
641        Self::classified_with_diagnostic(
642            category,
643            phase,
644            code,
645            path,
646            Some(diagnostic),
647            message,
648            Some(Box::new(error)),
649        )
650    }
651
652    pub(crate) fn with_projected_batch_row(self, ordinal: u64) -> Self {
653        let row_path = [
654            ErrorPathSegment::Argument("rows".to_owned()),
655            ErrorPathSegment::Index(ordinal),
656        ];
657        match self {
658            Self::ModelValidation {
659                phase,
660                code,
661                mut path,
662                message,
663                source,
664            } => {
665                let mut typed_path = Vec::with_capacity(path.len().saturating_add(2));
666                typed_path.extend(row_path);
667                for segment in &path {
668                    append_generated_path_segment(&mut typed_path, segment);
669                }
670                path.insert(0, format!("[{ordinal}]"));
671                path.insert(0, "rows".to_owned());
672                Self::classified_with_diagnostic(
673                    ErrorCategory::ModelValidation,
674                    Some(phase),
675                    code,
676                    path,
677                    Some(ErrorDiagnostic {
678                        path: typed_path,
679                        details: BTreeMap::new(),
680                    }),
681                    message,
682                    source,
683                )
684            }
685            Self::Classified {
686                category,
687                phase,
688                code,
689                mut path,
690                diagnostic,
691                message,
692                source,
693            } => {
694                path.insert(0, format!("[{ordinal}]"));
695                path.insert(0, "rows".to_owned());
696                let mut diagnostic = diagnostic.map_or_else(
697                    || ErrorDiagnostic {
698                        path: Vec::new(),
699                        details: BTreeMap::new(),
700                    },
701                    |diagnostic| *diagnostic,
702                );
703                diagnostic.path.splice(0..0, row_path);
704                Self::classified_with_diagnostic(
705                    category,
706                    phase,
707                    code,
708                    path,
709                    Some(diagnostic),
710                    message,
711                    source,
712                )
713            }
714            other => other,
715        }
716    }
717
718    pub(crate) fn from_projected_crud(
719        error: ProjectedCrudCompatibilityFailure,
720        kind: ModelKind,
721        operation: Option<CrudOperation>,
722    ) -> Self {
723        let (diagnostic, stage, cause) = error.into_parts();
724        let code = diagnostic.code().as_str();
725
726        if code == "mutation_rehydration_missing" {
727            let message = match (kind, operation) {
728                (ModelKind::Entity, Some(CrudOperation::Update)) => {
729                    "updated entity was not returned"
730                }
731                (ModelKind::Entity, _) => "written entity was not returned",
732                (ModelKind::Relation, _) => "written relation was not returned",
733            };
734            return Self::model_validation(
735                ModelValidationPhase::Hydration,
736                "missing_post_write_row",
737                vec!["iid".into()],
738                message,
739                None,
740            );
741        }
742        if code == "relation_hydration_ambiguous" {
743            return Self::model_validation(
744                ModelValidationPhase::Hydration,
745                "ambiguous_provider_row",
746                vec!["iid".into()],
747                "provider returned multiple coalesced rows for one exact IID",
748                None,
749            );
750        }
751        if code == "hydrated_type_mismatch" {
752            let message = match kind {
753                ModelKind::Entity => "provider entity row has the wrong exact concrete type",
754                ModelKind::Relation => "provider relation row has the wrong exact concrete type",
755            };
756            return Self::model_validation(
757                ModelValidationPhase::Hydration,
758                "wrong_concrete_type",
759                vec!["type".into()],
760                message,
761                None,
762            );
763        }
764        if code == "hydrated_iid_missing" {
765            let message = match kind {
766                ModelKind::Entity => "provider entity row omitted its IID",
767                ModelKind::Relation => "provider relation row omitted its IID",
768            };
769            return Self::model_validation(
770                ModelValidationPhase::Hydration,
771                "missing_iid",
772                vec!["iid".into()],
773                message,
774                None,
775            );
776        }
777        if code == "hydrated_iid_mismatch" {
778            let message = match kind {
779                ModelKind::Entity => "provider entity row contains a noncanonical IID",
780                ModelKind::Relation => "provider relation row contains a noncanonical IID",
781            };
782            return Self::model_validation(
783                ModelValidationPhase::Hydration,
784                "noncanonical_iid",
785                vec!["iid".into()],
786                message,
787                None,
788            );
789        }
790        if kind == ModelKind::Relation
791            && code == "noncanonical_iid"
792            && diagnostic
793                .path()
794                .iter()
795                .any(|segment| matches!(segment, SdkDiagnosticPathSegment::Role(_)))
796        {
797            return Self::model_validation(
798                ModelValidationPhase::Input,
799                "noncanonical_player_iid",
800                projected_role_path(&diagnostic, "iid"),
801                "relation reference IID must be canonical",
802                None,
803            );
804        }
805        if kind == ModelKind::Relation
806            && diagnostic
807                .path()
808                .iter()
809                .any(|segment| matches!(segment, SdkDiagnosticPathSegment::Role(_)))
810            && matches!(
811                cause.as_ref(),
812                Some(ProjectedCrudCompatibilityCause::Orm(
813                    type_bridge_orm::OrmError::Hydration { .. }
814                ))
815            )
816        {
817            return Self::model_validation(
818                ModelValidationPhase::Hydration,
819                "invalid_player_attributes",
820                projected_role_path(&diagnostic, "attributes"),
821                "provider role player attributes are outside the projected descriptor",
822                cause.and_then(projected_cause_source),
823            );
824        }
825        if kind == ModelKind::Relation
826            && code == "runtime_projection_mismatch"
827            && diagnostic
828                .path()
829                .iter()
830                .any(|segment| matches!(segment, SdkDiagnosticPathSegment::Role(_)))
831        {
832            return Self::model_validation(
833                ModelValidationPhase::Hydration,
834                "invalid_player_attributes",
835                projected_role_path(&diagnostic, "attributes"),
836                "provider role player attributes are outside the projected descriptor",
837                cause.and_then(projected_cause_source),
838            );
839        }
840        if kind == ModelKind::Relation {
841            let mapped = match code {
842                "hydrated_player_iid_missing" => Some((
843                    "missing_player_iid",
844                    "iid",
845                    "provider role player omitted its IID",
846                )),
847                "hydrated_player_iid_invalid" => Some((
848                    "noncanonical_player_iid",
849                    "iid",
850                    "provider role player contains a noncanonical IID",
851                )),
852                "hydrated_player_type_missing" => Some((
853                    "missing_player_type",
854                    "type",
855                    "provider role player omitted its concrete type",
856                )),
857                "hydrated_role_player_not_accepted" => Some((
858                    "player_not_allowed",
859                    "type",
860                    "provider role player type is outside the role",
861                )),
862                "projected_player_type_ambiguous" => Some((
863                    "invalid_installed_projection",
864                    "type",
865                    "projected player authority is ambiguous",
866                )),
867                _ => None,
868            };
869            if let Some((legacy_code, suffix, message)) = mapped {
870                return Self::model_validation(
871                    ModelValidationPhase::Hydration,
872                    legacy_code,
873                    projected_role_path(&diagnostic, suffix),
874                    message,
875                    None,
876                );
877            }
878        }
879
880        if let Some(cause) = cause {
881            return match cause {
882                ProjectedCrudCompatibilityCause::Orm(error) => {
883                    if stage == ProjectedCrudCompatibilityStage::Hydration {
884                        Self::from_orm_hydration(error)
885                    } else {
886                        Self::from_orm(error)
887                    }
888                }
889                ProjectedCrudCompatibilityCause::Commit(error) => {
890                    Self::from_orm(error.into_orm_error())
891                }
892            };
893        }
894        Self::from_sdk_execution(
895            diagnostic,
896            if stage == ProjectedCrudCompatibilityStage::Hydration {
897                ModelValidationPhase::Hydration
898            } else {
899                ModelValidationPhase::Input
900            },
901        )
902    }
903
904    pub(crate) fn from_hook(error: crate::hooks::HookError) -> Self {
905        let code = match error {
906            crate::hooks::HookError::Rejected { .. } => "lifecycle_hook_rejected",
907            crate::hooks::HookError::Internal { .. } => "lifecycle_hook_failed",
908        };
909        Self::classified(
910            ErrorCategory::Lifecycle,
911            None,
912            code,
913            Vec::new(),
914            error.to_string(),
915            Some(Box::new(error)),
916        )
917    }
918
919    #[allow(dead_code)]
920    pub(crate) fn from_orm(err: type_bridge_orm::OrmError) -> Self {
921        match err {
922            type_bridge_orm::OrmError::Match(error) => {
923                Self::from_match(error, ModelValidationPhase::Input)
924            }
925            error @ type_bridge_orm::OrmError::Connection(_) => Self::Connection {
926                message: error.to_string(),
927                source: Some(Box::new(error)),
928            },
929            error @ type_bridge_orm::OrmError::QueryExecution(_) => Self::QueryExecution {
930                message: error.to_string(),
931                source: Some(Box::new(error)),
932            },
933            error @ type_bridge_orm::OrmError::Transaction(_) => Self::Transaction {
934                message: error.to_string(),
935                source: Some(Box::new(error)),
936            },
937            error @ type_bridge_orm::OrmError::NotFound(_) => Self::NotFound {
938                message: error.to_string(),
939                source: Some(Box::new(error)),
940            },
941            error @ type_bridge_orm::OrmError::Hydration { .. } => Self::ModelValidation {
942                phase: ModelValidationPhase::Hydration,
943                code: "invalid_provider_evidence".into(),
944                path: vec![],
945                message: error.to_string(),
946                source: Some(Box::new(error)),
947            },
948            error => Self::Database {
949                message: error.to_string(),
950                source: Some(Box::new(error)),
951            },
952        }
953    }
954
955    pub(crate) fn from_orm_hydration(err: type_bridge_orm::OrmError) -> Self {
956        match err {
957            type_bridge_orm::OrmError::Match(error) => {
958                Self::from_match(error, ModelValidationPhase::Hydration)
959            }
960            error => Self::from_orm(error),
961        }
962    }
963
964    /// Return the stable public failure category.
965    #[must_use]
966    pub const fn category(&self) -> ErrorCategory {
967        match self {
968            Self::ModelValidation { .. } => ErrorCategory::ModelValidation,
969            Self::Classified { category, .. } => *category,
970            Self::SchemaVerification { .. } => ErrorCategory::Schema,
971            Self::Connection { .. } => ErrorCategory::Connection,
972            Self::QueryExecution { .. } => ErrorCategory::QueryExecution,
973            Self::Transaction { .. } => ErrorCategory::Transaction,
974            Self::NotFound { .. } => ErrorCategory::NotFound,
975            Self::Database { .. } => ErrorCategory::Database,
976            Self::Other { .. } => ErrorCategory::Other,
977        }
978    }
979
980    /// Return the exact common SDK diagnostic category, when this error
981    /// directly retains one.
982    ///
983    /// This is an additive, lossless view for generated manager and projected
984    /// batch diagnostics. [`Self::category`] remains the released Rust
985    /// compatibility classification and may intentionally map SDK
986    /// `invalid_input` to [`ErrorCategory::ModelValidation`].
987    #[must_use]
988    pub fn sdk_category(&self) -> Option<&'static str> {
989        StdError::source(self)
990            .and_then(|source| source.downcast_ref::<SdkExecutionDiagnostic>())
991            .map(|diagnostic| diagnostic.category().as_str())
992    }
993
994    /// Return the error message string.
995    #[must_use]
996    pub fn message(&self) -> &str {
997        match self {
998            Self::ModelValidation { message, .. }
999            | Self::Classified { message, .. }
1000            | Self::SchemaVerification { message, .. }
1001            | Self::Connection { message, .. }
1002            | Self::QueryExecution { message, .. }
1003            | Self::Transaction { message, .. }
1004            | Self::NotFound { message, .. }
1005            | Self::Database { message, .. }
1006            | Self::Other { message, .. } => message,
1007        }
1008    }
1009
1010    /// Return the stable machine-readable failure code, when available.
1011    #[must_use]
1012    pub fn code(&self) -> Option<&str> {
1013        match self {
1014            Self::ModelValidation { code, .. } | Self::Classified { code, .. } => Some(code),
1015            _ => None,
1016        }
1017    }
1018
1019    /// Return the owned structured diagnostic path, when available.
1020    #[must_use]
1021    pub fn path(&self) -> Option<&[String]> {
1022        match self {
1023            Self::ModelValidation { path, .. } | Self::Classified { path, .. } => Some(path),
1024            _ => None,
1025        }
1026    }
1027
1028    /// Return the typed diagnostic path, when the source supplied one.
1029    ///
1030    /// [`Self::path`] remains available as the compatibility-oriented textual
1031    /// projection of the same path.
1032    #[must_use]
1033    pub fn diagnostic_path(&self) -> Option<&[ErrorPathSegment]> {
1034        match self {
1035            Self::Classified { diagnostic, .. } => diagnostic.as_deref().map(ErrorDiagnostic::path),
1036            _ => None,
1037        }
1038    }
1039
1040    /// Return deterministic typed diagnostic details, when available.
1041    #[must_use]
1042    pub fn details(&self) -> Option<&BTreeMap<String, ErrorDetail>> {
1043        match self {
1044            Self::Classified { diagnostic, .. } => {
1045                diagnostic.as_deref().map(ErrorDiagnostic::details)
1046            }
1047            _ => None,
1048        }
1049    }
1050
1051    /// Return the model-validation phase, when applicable.
1052    #[must_use]
1053    pub const fn model_validation_phase(&self) -> Option<ModelValidationPhase> {
1054        match self {
1055            Self::ModelValidation { phase, .. } => Some(*phase),
1056            Self::Classified { phase, .. } => *phase,
1057            _ => None,
1058        }
1059    }
1060}
1061
1062fn flatten_sdk_path(segment: &SdkDiagnosticPathSegment) -> String {
1063    match segment {
1064        SdkDiagnosticPathSegment::Argument(value) => value.as_str().to_owned(),
1065        SdkDiagnosticPathSegment::Index(value) => format!("[{value}]"),
1066        SdkDiagnosticPathSegment::Type(_) => "type".into(),
1067        SdkDiagnosticPathSegment::Field(value) => value.attribute().label().as_str().to_owned(),
1068        SdkDiagnosticPathSegment::Role(value) => value.label().as_str().to_owned(),
1069        SdkDiagnosticPathSegment::Query(value) => value.as_str().to_owned(),
1070        SdkDiagnosticPathSegment::QueryBinding(value) => format!("binding[{value}]"),
1071        SdkDiagnosticPathSegment::QueryField { owner, name } => {
1072            format!("{}.{}", owner.as_str(), name.as_str())
1073        }
1074        SdkDiagnosticPathSegment::QueryRole { owner, name } => {
1075            format!("{}.{}", owner.as_str(), name.as_str())
1076        }
1077        SdkDiagnosticPathSegment::QueryRoleEdge(value) => format!("role_edge[{value}]"),
1078        SdkDiagnosticPathSegment::QueryOutputSlot(value) => format!("output[{value}]"),
1079        SdkDiagnosticPathSegment::QueryOutputName(value)
1080        | SdkDiagnosticPathSegment::ContractField(value)
1081        | SdkDiagnosticPathSegment::ContractIdentity(value) => value.as_str().to_owned(),
1082        _ => "diagnostic".into(),
1083    }
1084}
1085
1086fn projected_role_path(diagnostic: &SdkExecutionDiagnostic, suffix: &'static str) -> Vec<String> {
1087    let role = diagnostic.path().iter().find_map(|segment| {
1088        let SdkDiagnosticPathSegment::Role(role) = segment else {
1089            return None;
1090        };
1091        Some(role.label().as_str())
1092    });
1093    let index = diagnostic.path().iter().find_map(|segment| {
1094        let SdkDiagnosticPathSegment::Index(index) = segment else {
1095            return None;
1096        };
1097        Some(*index)
1098    });
1099    let head = match (role, index) {
1100        (Some(role), Some(index)) => format!("{role}[{index}]"),
1101        (Some(role), None) => role.to_owned(),
1102        _ => "roles".to_owned(),
1103    };
1104    vec![head, suffix.to_owned()]
1105}
1106
1107fn projected_cause_source(
1108    cause: ProjectedCrudCompatibilityCause,
1109) -> Option<Box<dyn StdError + Send + Sync + 'static>> {
1110    match cause {
1111        ProjectedCrudCompatibilityCause::Orm(error) => Some(Box::new(error)),
1112        ProjectedCrudCompatibilityCause::Commit(error) => Some(Box::new(error)),
1113    }
1114}
1115
1116fn typed_sdk_path(segment: &SdkDiagnosticPathSegment) -> ErrorPathSegment {
1117    match segment {
1118        SdkDiagnosticPathSegment::Argument(value) => {
1119            ErrorPathSegment::Field(value.as_str().to_owned())
1120        }
1121        SdkDiagnosticPathSegment::Index(value) => ErrorPathSegment::Index(*value),
1122        SdkDiagnosticPathSegment::Type(value) => ErrorPathSegment::Identifier(format!(
1123            "{}:{}",
1124            match value.kind() {
1125                type_bridge_contract::id::TypeKind::Entity => "entity",
1126                type_bridge_contract::id::TypeKind::Relation => "relation",
1127                type_bridge_contract::id::TypeKind::Attribute => "attribute",
1128                type_bridge_contract::id::TypeKind::Struct => "struct",
1129            },
1130            value.label().as_str()
1131        )),
1132        SdkDiagnosticPathSegment::Field(value) => ErrorPathSegment::Identifier(format!(
1133            "{}:{}",
1134            value.owner().label().as_str(),
1135            value.attribute().label().as_str()
1136        )),
1137        SdkDiagnosticPathSegment::Role(value) => ErrorPathSegment::Identifier(format!(
1138            "{}:{}",
1139            value.declaring_relation().as_str(),
1140            value.label().as_str()
1141        )),
1142        SdkDiagnosticPathSegment::Query(value) => {
1143            ErrorPathSegment::Query(acceptance_path_kind(*value))
1144        }
1145        SdkDiagnosticPathSegment::QueryBinding(value) => ErrorPathSegment::QueryBinding(*value),
1146        SdkDiagnosticPathSegment::QueryField { owner, name } => ErrorPathSegment::QueryField {
1147            owner: owner.as_str().to_owned(),
1148            name: name.as_str().to_owned(),
1149        },
1150        SdkDiagnosticPathSegment::QueryRole { owner, name } => ErrorPathSegment::QueryRole {
1151            owner: owner.as_str().to_owned(),
1152            name: name.as_str().to_owned(),
1153        },
1154        SdkDiagnosticPathSegment::QueryRoleEdge(value) => ErrorPathSegment::QueryRoleEdge(*value),
1155        SdkDiagnosticPathSegment::QueryOutputSlot(value) => {
1156            ErrorPathSegment::QueryOutputSlot(*value)
1157        }
1158        SdkDiagnosticPathSegment::QueryOutputName(value) => {
1159            ErrorPathSegment::QueryOutputName(value.as_str().to_owned())
1160        }
1161        SdkDiagnosticPathSegment::ContractField(value) => {
1162            ErrorPathSegment::ContractField(value.as_str().to_owned())
1163        }
1164        SdkDiagnosticPathSegment::ContractIdentity(value) => {
1165            ErrorPathSegment::ContractIdentity(value.as_str().to_owned())
1166        }
1167        _ => ErrorPathSegment::Identifier("diagnostic".into()),
1168    }
1169}
1170
1171fn typed_projected_batch_path(segment: &SdkDiagnosticPathSegment) -> ErrorPathSegment {
1172    match segment {
1173        SdkDiagnosticPathSegment::Argument(value) => {
1174            ErrorPathSegment::Argument(value.as_str().to_owned())
1175        }
1176        other => typed_sdk_path(other),
1177    }
1178}
1179
1180fn append_generated_path_segment(path: &mut Vec<ErrorPathSegment>, segment: &str) {
1181    let Some(first_index) = segment.find('[') else {
1182        path.push(ErrorPathSegment::Field(segment.to_owned()));
1183        return;
1184    };
1185    let mut parsed = Vec::new();
1186    if first_index > 0 {
1187        parsed.push(ErrorPathSegment::Field(segment[..first_index].to_owned()));
1188    }
1189    let mut remainder = &segment[first_index..];
1190    while remainder.starts_with('[') {
1191        let Some(end) = remainder.find(']') else {
1192            path.push(ErrorPathSegment::Field(segment.to_owned()));
1193            return;
1194        };
1195        let Ok(index) = remainder[1..end].parse::<u64>() else {
1196            path.push(ErrorPathSegment::Field(segment.to_owned()));
1197            return;
1198        };
1199        parsed.push(ErrorPathSegment::Index(index));
1200        remainder = &remainder[end + 1..];
1201    }
1202    if !remainder.is_empty() || parsed.is_empty() {
1203        path.push(ErrorPathSegment::Field(segment.to_owned()));
1204    } else {
1205        path.extend(parsed);
1206    }
1207}
1208
1209fn flatten_sdk_detail(value: &SdkDiagnosticDetailValue) -> ErrorDetail {
1210    match value {
1211        SdkDiagnosticDetailValue::Boolean(value) => ErrorDetail::Boolean(*value),
1212        SdkDiagnosticDetailValue::Count(value) | SdkDiagnosticDetailValue::ByteCount(value) => {
1213            i64::try_from(*value)
1214                .map_or_else(|_| ErrorDetail::Text(value.to_string()), ErrorDetail::Long)
1215        }
1216        SdkDiagnosticDetailValue::Capability(value) => ErrorDetail::Text(value.as_str().to_owned()),
1217        SdkDiagnosticDetailValue::ValueType(value) => ErrorDetail::Text(value.as_str().to_owned()),
1218        SdkDiagnosticDetailValue::Type(value) => {
1219            ErrorDetail::Text(value.label().as_str().to_owned())
1220        }
1221        SdkDiagnosticDetailValue::Field(value) => {
1222            ErrorDetail::Text(value.attribute().label().as_str().to_owned())
1223        }
1224        SdkDiagnosticDetailValue::Role(value) => {
1225            ErrorDetail::Text(value.label().as_str().to_owned())
1226        }
1227        SdkDiagnosticDetailValue::Fingerprint(value) => ErrorDetail::Text(value.digest().to_hex()),
1228        SdkDiagnosticDetailValue::ProviderOperation(value) => {
1229            ErrorDetail::Text(value.as_str().to_owned())
1230        }
1231        SdkDiagnosticDetailValue::CommitOutcome(value) => {
1232            ErrorDetail::Text(value.as_str().to_owned())
1233        }
1234        SdkDiagnosticDetailValue::Signed(value) => ErrorDetail::Long(*value),
1235        SdkDiagnosticDetailValue::QueryCategory(value) => {
1236            ErrorDetail::QueryCategory(query_category(*value))
1237        }
1238        SdkDiagnosticDetailValue::QueryIdentity(value) => {
1239            ErrorDetail::QueryIdentity(value.as_str().to_owned())
1240        }
1241        SdkDiagnosticDetailValue::QueryIdentityList(values) => ErrorDetail::QueryIdentityList(
1242            values
1243                .iter()
1244                .map(|value| value.as_str().to_owned())
1245                .collect(),
1246        ),
1247        _ => ErrorDetail::Text("diagnostic".into()),
1248    }
1249}
1250
1251fn sdk_query_category(error: &SdkExecutionDiagnostic) -> Option<SdkQueryDiagnosticCategory> {
1252    error.details().iter().find_map(|(name, value)| {
1253        (name.as_str() == "query_category").then(|| {
1254            let SdkDiagnosticDetailValue::QueryCategory(category) = value else {
1255                return None;
1256            };
1257            Some(*category)
1258        })?
1259    })
1260}
1261
1262const fn query_category(value: SdkQueryDiagnosticCategory) -> QueryDiagnosticCategory {
1263    match value {
1264        SdkQueryDiagnosticCategory::InvalidPlan => QueryDiagnosticCategory::InvalidPlan,
1265        SdkQueryDiagnosticCategory::Cardinality => QueryDiagnosticCategory::Cardinality,
1266        SdkQueryDiagnosticCategory::UnsupportedCapability => {
1267            QueryDiagnosticCategory::UnsupportedCapability
1268        }
1269        SdkQueryDiagnosticCategory::StaleSchema => QueryDiagnosticCategory::StaleSchema,
1270        SdkQueryDiagnosticCategory::ResourceLimit => QueryDiagnosticCategory::ResourceLimit,
1271        SdkQueryDiagnosticCategory::Cancelled => QueryDiagnosticCategory::Cancelled,
1272        SdkQueryDiagnosticCategory::Provider => QueryDiagnosticCategory::Provider,
1273        SdkQueryDiagnosticCategory::ResultDecode => QueryDiagnosticCategory::ResultDecode,
1274        _ => QueryDiagnosticCategory::ResultDecode,
1275    }
1276}
1277
1278const fn acceptance_path_kind(value: SdkQueryDiagnosticPathKind) -> QueryDiagnosticPathKind {
1279    match value {
1280        SdkQueryDiagnosticPathKind::Request => QueryDiagnosticPathKind::Request,
1281        SdkQueryDiagnosticPathKind::Plan => QueryDiagnosticPathKind::Plan,
1282        SdkQueryDiagnosticPathKind::Operation => QueryDiagnosticPathKind::Operation,
1283        SdkQueryDiagnosticPathKind::Predicate => QueryDiagnosticPathKind::Predicate,
1284        SdkQueryDiagnosticPathKind::Output => QueryDiagnosticPathKind::Output,
1285        SdkQueryDiagnosticPathKind::ProviderEvidence => QueryDiagnosticPathKind::ProviderEvidence,
1286        SdkQueryDiagnosticPathKind::Result => QueryDiagnosticPathKind::Result,
1287        _ => QueryDiagnosticPathKind::Result,
1288    }
1289}
1290
1291/// Convenience Result type for the TypeBridge client.
1292pub type Result<T, E = Error> = std::result::Result<T, E>;
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::{Error, ErrorCategory};
1297
1298    #[test]
1299    fn public_error_categories_and_remote_constructor_are_stable() {
1300        let categories = [
1301            (ErrorCategory::Connection, "connection"),
1302            (ErrorCategory::Schema, "schema"),
1303            (ErrorCategory::ModelValidation, "model_validation"),
1304            (ErrorCategory::Integrity, "integrity"),
1305            (ErrorCategory::QueryAuthoring, "query_authoring"),
1306            (ErrorCategory::QueryExecution, "query_execution"),
1307            (ErrorCategory::Transaction, "transaction"),
1308            (ErrorCategory::Remote, "remote"),
1309            (ErrorCategory::Capability, "capability"),
1310            (ErrorCategory::ResourceLimit, "resource_limit"),
1311            (ErrorCategory::NotFound, "not_found"),
1312            (ErrorCategory::Lifecycle, "lifecycle"),
1313            (ErrorCategory::Database, "database"),
1314            (ErrorCategory::Other, "other"),
1315        ];
1316        for (category, spelling) in categories {
1317            assert_eq!(category.as_str(), spelling);
1318            assert_eq!(category.to_string(), spelling);
1319        }
1320
1321        let error = Error::remote("remote_transport", "connection reset", None);
1322        assert_eq!(error.category(), ErrorCategory::Remote);
1323        assert_eq!(error.code(), Some("remote_transport"));
1324        assert_eq!(error.path(), Some(&[][..]));
1325        assert_eq!(error.message(), "connection reset");
1326    }
1327}