Skip to content

Typed Reducer Errors

Add type Error to Reducer with a From<Self::Error> bound on Feedback. Errors become a first-class feedback variant per reducer, reported via Fx::error(e) — a returned effect value, not a channel call.

Builds on Runtime Struct which introduced the returned Effect model.

Currently, error reporting is split across two ad-hoc paths:

  • State mutation — the reducer sets an error field on state for UI display
  • svc.agent.tool_error(msg) — sends a string error to the AI agent bridge

There’s no guarantee both happen. A reducer author might update state but forget the agent call, or vice versa. Errors are also untyped — tool_error takes impl Into<String>, losing structure.

We want:

  • Typed errors per reducer — each modality defines its own error enum
  • Single entry pointFx::error(e) as a peer of Fx::intent() and Fx::spawn()
  • Reducer handles consequences — the error arrives as Feedback::Error(e), where the reducer decides: mutate state, notify agent, spawn recovery, or all three
  • No new infrastructure — errors flow as Effect::Send(Command::Feedback(err.into())) through the existing effect system
trait Reducer: Send + Sync + 'static {
type State;
type Intent: Send + 'static;
type Error: std::error::Error + Send + 'static;
type Feedback: From<Self::Error> + Send + 'static;
type Services: Send + Sync + 'static;
// ... runtime(), runtime_mut(), reduce(), dispatch() from the Runtime Struct RFC
}

The From<Self::Error> bound on Feedback structurally enforces that every reducer’s feedback enum includes an error variant.

The std::error::Error bound gives Display (for e.to_string() → agent error messages) and source() for error chaining. Each modality uses thiserror to derive it.

Each modality defines a typed error enum using thiserror:

#[derive(Debug, thiserror::Error)]
pub enum WhiteboardError {
#[error("invalid placement: {0}")]
InvalidPlacement(String),
#[error("compile failed: {0}")]
CompileFailed(String),
#[error("export failed: {0}")]
ExportFailed(String),
}

thiserror derives Display and std::error::Error from the #[error(...)] annotations — no manual impls needed.

Each feedback enum gains an Error variant with a From impl:

pub enum WhiteboardFeedback {
ImageLoaded(ImageData),
Error(WhiteboardError),
}
impl From<WhiteboardError> for WhiteboardFeedback {
fn from(e: WhiteboardError) -> Self {
Self::Error(e)
}
}

Effect gains an error() constructor. Sugar for Effect::Send(Command::Feedback(err.into())):

impl<Svc, I, F> Effect<Svc, I, F> {
/// Report a typed error. Sugar for feedback(err.into()).
pub fn error<E: Into<F>>(err: E) -> Self {
Self::Send(Command::Feedback(err.into()))
}
}

Tx also gains an error() method for sending errors from closures:

impl<I, F> Tx<I, F> {
pub fn error<E: Into<F>>(&self, err: E) {
self.feedback(err.into());
}
}

Used via the per-reducer type alias:

type Fx = Effect<WhiteboardServices, WhiteboardIntent, WhiteboardFeedback>;
// In reduce:
Command::Intent(BadIntent) => {
Fx::error(WhiteboardError::InvalidPlacement("out of bounds".into()))
}

The error is wrapped as Command::Feedback and reduced in the same dispatch cycle (via Runtime::execute processing Effect::Send).

The reducer pattern-matches Feedback::Error(e) and decides how to respond:

fn reduce(cmd: Command<I, F>, state: &mut State) -> Fx {
match cmd {
Command::Intent(i) => { /* ... */ }
Command::Feedback(WhiteboardFeedback::ImageLoaded(data)) => {
state.ephemeral.image = Some(data);
Fx::none()
}
Command::Feedback(WhiteboardFeedback::Error(e)) => {
// Mutate state for UI
state.ephemeral.last_error = Some(e.to_string());
// Notify AI agent via task (services only in effects)
Fx::task(|svc, _tx| {
svc.agent.tool_error(e.to_string());
})
// Or optionally spawn recovery: Fx::spawn(|svc, tx| { ... })
}
}
}

The reducer is in full control. Different errors can have different handling — some may only update state (Fx::none()), some may notify the agent (Fx::task()), some may trigger retries (Fx::spawn()), or any combination via .and() chaining.

Spawned closures report errors via Tx:

Fx::spawn(|svc, tx| Box::pin(async move {
match svc.backend.load("id").await {
Ok(data) => tx.feedback(Loaded(data)),
Err(e) => tx.error(WhiteboardError::LoadFailed(e)),
}
}))

The From impl converts the typed error into the feedback enum automatically. tx.error(e) is sugar for tx.feedback(e.into()).

Session has its own error sources beyond the modality reducer: AI failures, export failures, sync/persistence failures. These compose with the modality’s error type via a wrapper enum.

ReduceIntent defines its own error type so Session can forward it:

trait ReduceIntent<M: Modality>: Send + 'static {
type Error: std::error::Error + Send + 'static;
type Feedback: From<Self::Error> + Send + 'static;
fn reduce(self, state: &mut SessionState<M>) -> SessionFx<Self, Self::Feedback>
where Self: Sized;
fn handle_feedback(fb: Self::Feedback, state: &mut SessionState<M>) -> SessionFx<Self, Self::Feedback>
where Self: Sized;
}

Session forwards the error type from its ReduceIntent:

impl<M, I> Reducer for Session<M, I>
where
M: Modality,
I: ReduceIntent<M>,
{
type Error = I::Error;
type Feedback = I::Feedback;
// ...
}

SessionError<E> wraps the modality’s error type plus session-specific errors:

#[derive(Debug, thiserror::Error)]
pub enum SessionError<E: std::error::Error> {
#[error("modality error: {0}")]
Modality(E),
#[error("ai error: {0}")]
Ai(AiError),
#[error("export error: {0}")]
Export(ExportError),
#[error("sync error: {0}")]
Sync(SyncError),
}

The leaf ReduceIntent impl pins it:

impl<M> ReduceIntent<M> for SessionIntent<M::Intent>
where
M: Modality,
{
type Error = SessionError<<M as Reducer>::Error>;
type Feedback = SessionFeedback<M>;
// ...
}

For a whiteboard session, this resolves to SessionError<WhiteboardError>.

Parent sessions route intents to child sessions. Child errors are a routing concern — they surface automatically at the session level, not inside the modality’s error enum:

#[derive(Debug, thiserror::Error)]
pub enum ParentSessionError<E: std::error::Error, ChildE: std::error::Error> {
#[error(transparent)]
Session(SessionError<E>),
#[error("child {id}: {error}")]
Child { id: String, error: ChildE },
}

The parent ReduceIntent impl uses the child’s error type:

impl<M, CI> ReduceIntent<M> for ParentSessionIntent<M::Intent, CI>
where
M: Modality + HasChildren,
CI: ReduceIntent<M::ChildModality>,
{
type Error = ParentSessionError<<M as Reducer>::Error, CI::Error>;
type Feedback = ParentSessionFeedback<M, CI>;
// ...
}

For a lesson plan session with whiteboard children, this resolves to: ParentSessionError<LessonPlanError, SessionError<WhiteboardError>> — fully typed from parent to child.

When a child session dispatches and produces an error, the parent routes it:

ParentSessionIntent::Child { id, intent } => {
match state.modality.children_mut().get_mut(&id) {
Some(Slot::Hot(child_session)) => {
child_session.dispatch(Command::Intent(intent));
// Child errors flow through child's feedback → parent observes via snapshot
Fx::none()
}
_ => Fx::error(ParentSessionError::Child {
id,
error: SessionError::Modality(/* ... */),
}),
}
}
Reducertype ErrorExample
WhiteboardWhiteboardErrorInvalidPlacement, CompileFailed
WorksheetWorksheetErrorInvalidQuestion, CompileFailed
LessonPlanLessonPlanErrorInvalidSlide, CompileFailed
ProseProseErrorInvalidBlock, ClipboardFailed
Session<Whiteboard, SessionIntent<...>>SessionError<WhiteboardError>Modality(InvalidPlacement), Ai(...)
Session<LessonPlan, ParentSessionIntent<...>>ParentSessionError<LessonPlanError, SessionError<WhiteboardError>>Session(Modality(...)), Child { id, error }
  1. Add thiserror dependency to modality crates
  2. Add type Error: std::error::Error + Send + 'static to Reducer trait, From<Self::Error> bound on Feedback
  3. Add Fx::error() constructor to Effect, Tx::error() to Tx
  4. Define per-modality error enums with #[derive(thiserror::Error)]: WhiteboardError, WorksheetError, LessonPlanError, ProseError
  5. Add Error(E) variant + From impl to each feedback enum
  6. Add type Error to ReduceIntent trait
  7. Define SessionError<E> and ParentSessionError<E, ChildE> in modality_session
  8. Update SessionIntent impl: type Error = SessionError<M::Error>
  9. Update ParentSessionIntent impl: type Error = ParentSessionError<M::Error, CI::Error>
  10. Update all Reducer impls (Whiteboard, Worksheet, LessonPlan, Assessment, Prose, Session)
  11. Migrate existing svc.agent.tool_error() calls to use Fx::error() where appropriate
  12. cargo test --workspace — pure refactor, no behavioral changes

Error as a third Command variant (Command<I, F, E>) — Rejected because it adds a generic to Command everywhere and errors are conceptually internal results, not external intents. Errors are feedback.

Separate error effect variant (Effect::Error(E)) — Rejected because Fx::error(e) already desugars cleanly to Effect::Send(Command::Feedback(e.into())). No need for a distinct variant when the existing effect system handles it.

Separate reduce_error() handler on Reducer — Rejected because it splits reduce logic across two methods and prevents errors from being pattern-matched alongside other feedback in a single match.

Erased errors (Box<dyn Error>) — Rejected because it loses type information. Per-reducer typed errors enable exhaustive matching and richer handling.

Session delegates error type directly (Session::Error = I::Error with no wrapper) — Rejected because Session has its own error sources (AI, export, sync) that need typed representation. A composable SessionError<E> / ParentSessionError<E, ChildE> wrapper gives full coverage while preserving the modality’s typed errors via the Modality(E) variant.

None — all questions resolved.