ADR-047: Typed Outbox StageError in a Leaf Crate (messaging ↔ API error boundary)
On this page
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 ApiErrorin craig-mq ⇒craig-mqmust depend oncraig-common, dragging axum + reqwest + tower-http + object_store into the low-level messaging layer (ApiErroris an axumIntoResponsetype). -
From<StageError> for ApiErrorin craig-common ⇒ the base crate that nearly everything depends on must depend oncraig-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-mqdepends on it;stage_eventreturnsResult<(), StageError>via?(theProtocollie is gone). -
craig-commondepends on it and providesFrom<StageError> for ApiError(aDbfailure keeps itssqlx::Errorverbatim; aSerializefailure — 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-errorandcraig-common → craig-mq-errorare 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 `IntoResponseis untouched. This is an internal error-typing change, not a wire change. -
Cascade. The per-service
events.rspublish_*staging wrappers widensqlx::Error → StageError(their DB?still compile viaStageError: From<sqlx::Error>); the ~72 handlers that call them and returnApiErrorare untouched (the newFrompowers their?). Two service-local error enums that also cross this boundary gained aStageErrorarm:craig-rules’ `EngineError(aStagingvariant) and the craig-exchange send-worker + craig-security detection helpers widened toStageError. -
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::Protocolmapping — violates the typed-error convention (stringly workaround) and mislabels the failure class. -
craig-mq → craig-commonorcraig-common → craig-mq— each is a layering inversion (axum into messaging, or amqp into the base crate). -
.map_errat all ~72 call sites — repetitive per-site conversion code for what a singleFromimpl expresses once.