ADR-047: Typed Outbox StageError in a Leaf Crate (messaging ↔ API error boundary)

On this page

Status

Accepted (2026-07-06). Issue #832 (P3-low backlog, typed-error refactor).

Context

craig_mq::stage_event stages an event envelope onto a caller’s outbox transaction. It serializes a typed EventEnvelope to JSON and INSERT`s the row. To fit a `→ Result<(), sqlx::Error> signature it mapped a serialization failure onto sqlx::Error::Protocol(format!("envelope serialization: {e}")) — labeling an application-layer serialize error as a wire-protocol error, with an overstated "cannot fail unless non-UTF8" comment.

The coding conventions forbid this: "Errors are typed, never strings … no ErrorKind::Other as a string workaround." But the naive fix — a typed StageError with a clean From<StageError> for ApiError so the ~72 call sites propagate with ? — has no valid orphan-rule home:

  • From<StageError> for ApiError in craig-mqcraig-mq must depend on craig-common, dragging axum + reqwest + tower-http + object_store into the low-level messaging layer (ApiError is an axum IntoResponse type).

  • From<StageError> for ApiError in craig-common ⇒ the base crate that nearly everything depends on must depend on craig-mq, dragging amqp/lapin into that base graph.

The sqlx::Error::Protocol mapping existed precisely because sqlx::Error was the only error type both craig-mq and its service consumers already shared (services' ApiError already has From<sqlx::Error>).

Decision

Introduce a leaf crate craig-mq-error holding the typed error:

pub enum StageError {
    Serialize(#[from] serde_json::Error),  // envelope → JSON (unreachable in practice)
    Db(#[from] sqlx::Error),               // the outbox-row INSERT
}
  • Its only dependencies are serde_json + sqlx + thiserror — it never reaches back to any heavier crate, so both producer and consumer can depend on it with no layering pull-in.

  • craig-mq depends on it; stage_event returns Result<(), StageError> via ? (the Protocol lie is gone).

  • craig-common depends on it and provides From<StageError> for ApiError (a Db failure keeps its sqlx::Error verbatim; a Serialize failure — a server-side bug, since well-formed envelopes always serialize — becomes a redacted 500).

This mirrors CRAIG’s own established pattern: craig-validation is a leaf crate holding FieldError "so contracts crates can pull them without dragging axum/reqwest/tower-http" (craig-common Cargo.toml). The messaging error boundary is the same shape.

Consequences

  • No layering violation, no cycle. craig-mq → craig-mq-error and craig-common → craig-mq-error are both acyclic; the leaf reaches nothing.

  • HTTP behavior unchanged. A staging failure still surfaces as a 500 and a DB failure keeps its existing mapping — ApiError’s `IntoResponse is untouched. This is an internal error-typing change, not a wire change.

  • Cascade. The per-service events.rs publish_* staging wrappers widen sqlx::Error → StageError (their DB ? still compile via StageError: From<sqlx::Error>); the ~72 handlers that call them and return ApiError are untouched (the new From powers their ?). Two service-local error enums that also cross this boundary gained a StageError arm: craig-rules’ `EngineError (a Staging variant) and the craig-exchange send-worker + craig-security detection helpers widened to StageError.

  • Precedent. Cross-layer typed errors that cannot live in either layer get a leaf crate (this ADR + craig-validation). Issue #828 (crypto typed errors) applies the same rule where it crosses a layer.

Alternatives rejected

  • Keep the sqlx::Error::Protocol mapping — violates the typed-error convention (stringly workaround) and mislabels the failure class.

  • craig-mq → craig-common or craig-common → craig-mq — each is a layering inversion (axum into messaging, or amqp into the base crate).

  • .map_err at all ~72 call sites — repetitive per-site conversion code for what a single From impl expresses once.

Edit this page · latest