Typed Reducer Errors
Summary
Section titled “Summary”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.
Motivation
Section titled “Motivation”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 point —
Fx::error(e)as a peer ofFx::intent()andFx::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
Design
Section titled “Design”Reducer trait addition
Section titled “Reducer trait addition”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.
Per-modality error types
Section titled “Per-modality error types”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.
Feedback enum convention
Section titled “Feedback enum convention”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) }}Fx::error() — sugar on Effect
Section titled “Fx::error() — sugar on Effect”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).
Reducer handles errors
Section titled “Reducer handles errors”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.
Error reporting from spawned effects
Section titled “Error reporting from spawned effects”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-level errors
Section titled “Session-level errors”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 gains type Error
Section titled “ReduceIntent gains type Error”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 — leaf sessions
Section titled “SessionError — leaf sessions”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>.
ParentSessionError — parent sessions
Section titled “ParentSessionError — parent sessions”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.
Error handling in parent reduce
Section titled “Error handling in parent reduce”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(/* ... */), }), }}Concrete error type table
Section titled “Concrete error type table”| Reducer | type Error | Example |
|---|---|---|
Whiteboard | WhiteboardError | InvalidPlacement, CompileFailed |
Worksheet | WorksheetError | InvalidQuestion, CompileFailed |
LessonPlan | LessonPlanError | InvalidSlide, CompileFailed |
Prose | ProseError | InvalidBlock, ClipboardFailed |
Session<Whiteboard, SessionIntent<...>> | SessionError<WhiteboardError> | Modality(InvalidPlacement), Ai(...) |
Session<LessonPlan, ParentSessionIntent<...>> | ParentSessionError<LessonPlanError, SessionError<WhiteboardError>> | Session(Modality(...)), Child { id, error } |
Implementation Plan
Section titled “Implementation Plan”- Add
thiserrordependency to modality crates - Add
type Error: std::error::Error + Send + 'statictoReducertrait,From<Self::Error>bound onFeedback - Add
Fx::error()constructor toEffect,Tx::error()toTx - Define per-modality error enums with
#[derive(thiserror::Error)]:WhiteboardError,WorksheetError,LessonPlanError,ProseError - Add
Error(E)variant +Fromimpl to each feedback enum - Add
type ErrortoReduceIntenttrait - Define
SessionError<E>andParentSessionError<E, ChildE>inmodality_session - Update
SessionIntentimpl:type Error = SessionError<M::Error> - Update
ParentSessionIntentimpl:type Error = ParentSessionError<M::Error, CI::Error> - Update all
Reducerimpls (Whiteboard, Worksheet, LessonPlan, Assessment, Prose, Session) - Migrate existing
svc.agent.tool_error()calls to useFx::error()where appropriate cargo test --workspace— pure refactor, no behavioral changes
Alternatives Considered
Section titled “Alternatives Considered”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.
Unresolved Questions
Section titled “Unresolved Questions”None — all questions resolved.