diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
 # Changelog
 
+## 0.3.0.0 -- 2026-11-13
+
+- **Breaking.** A callback now takes one argument describing the step it runs
+  in, instead of a state and a `Maybe FsmEvent`, and could previously see only
+  one end of the step: an entry callback where the chart was going, an exit
+  callback where it had been. A step is a generated `FsmTransition` record of
+  the state it leaves (`transitionFrom`), the event that caused it
+  (`transitionEvent`) and the state it settles in (`transitionTo`). An exit
+  callback takes an `FsmTransition`. An entry callback takes an
+  `FsmEntry = FsmStart FsmState | FsmStep FsmTransition`, since it also runs
+  while `initiateStateMachine` enters the initial state, which leaves nothing
+  and has no event. Neither carries a `Maybe` whose `Nothing` could not
+  happen. A step caused by a raised event leaves the state the previous step
+  settled in.
+- Migrating: an exit callback `cb st ev` becomes `cb step` with
+  `transitionFrom step` and `transitionEvent step`. An entry callback matches
+  `FsmStart st` for what used to be `cb st Nothing`, and `FsmStep step` with
+  `transitionTo step` and `transitionEvent step` for `cb st (Just ev)`.
+- `FsmTransition`, its three fields, `FsmEntry`, `FsmStart` and `FsmStep` are
+  generated into the chart's module next to the other generated names, so a
+  module defining any of them no longer compiles, and a state id or event
+  named `FsmTransition`, `FsmStart` or `FsmStep` is rejected as a clash.
+
 ## 0.2.0.0 -- 2026-09-17
 
 - **Breaking.** The `event` attribute now names one event and then the Haskell
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -49,12 +49,12 @@
 
 -- The callbacks named in <script>. They must share one monad, and the type
 -- checker enforces it.
-fetchStatus, sendReport :: FsmState -> Maybe FsmEvent -> StateT Monitor IO (Maybe FsmEvent)
-fetchStatus _ _ = gets (\m -> Just (if healthy m then Ok else Failed))
-sendReport _ _ = modify' (\m -> m {reports = reports m + 1}) >> pure (Just Done)
+fetchStatus, sendReport :: FsmEntry -> StateT Monitor IO (Maybe FsmEvent)
+fetchStatus _ = gets (\m -> Just (if healthy m then Ok else Failed))
+sendReport _ = modify' (\m -> m {reports = reports m + 1}) >> pure (Just Done)
 
-recordRun :: FsmState -> Maybe FsmEvent -> StateT Monitor IO ()
-recordRun _ _ = modify' (\m -> m {runs = runs m + 1})
+recordRun :: FsmTransition -> StateT Monitor IO ()
+recordRun _ = modify' (\m -> m {runs = runs m + 1})
 ```
 
 The quasiquote generates:
@@ -63,6 +63,12 @@
 data FsmState = Idle | Polling Polling
 data Polling  = Fetching | Reporting
 data FsmEvent = Check | Ok | Done | Failed
+data FsmTransition = FsmTransition
+  { transitionFrom  :: FsmState  -- the state the step leaves
+  , transitionEvent :: FsmEvent  -- the event that caused it
+  , transitionTo    :: FsmState  -- the state it settles in
+  }
+data FsmEntry = FsmStart FsmState | FsmStep FsmTransition
 
 initiateStateMachine    -- enter the initial state, running its entry callbacks
 notifyStateMachine      -- deliver one event
@@ -94,16 +100,26 @@
 
 ```haskell
 -- onentry: may decide where to go next by raising an event
-name :: FsmState -> Maybe FsmEvent -> m (Maybe FsmEvent)
+name :: FsmEntry -> m (Maybe FsmEvent)
 -- onexit: cleanup only, enforced by the generated code
-name :: FsmState -> Maybe FsmEvent -> m ()
+name :: FsmTransition -> m ()
 ```
 
-Entry callbacks receive the state being entered, exit callbacks the state being
-left. The event is the one being processed, or `Nothing` during
-`initiateStateMachine`. It is the event the caller passed in, so whatever
-payload it carries arrives with it.
+A step is an `FsmTransition`: the state it leaves (`transitionFrom`), the
+event that caused it (`transitionEvent`) and the state it settles in
+(`transitionTo`). An exit callback receives the step it runs in, so it knows
+where the chart is going as well as where it has been. An entry callback
+receives `FsmStep` with that same record, or `FsmStart` with the initial state
+while `initiateStateMachine` enters it, since starting leaves no state and has
+no event. Exit callbacks never run then, so they need no such case. The event
+is the one the caller passed in, so whatever payload it carries arrives with
+it.
 
+A step caused by a raised event leaves the state the previous step settled
+in, so a callback sees the step it is part of rather than the whole call to
+`notifyStateMachine`. Both states are whole configurations rather than the
+one being entered or exited, which a callback can find with `case`.
+
 Returning `Just event` raises it, which is SCXML's `<raise>`. Raised events are
 queued and processed before `notifyStateMachine` returns. This is how branching
 is expressed: a decision becomes a state whose entry callback raises one of the
@@ -132,14 +148,15 @@
 ```
 
 That declares `Order Item Int` in `FsmEvent`, and the payload reaches the
-callbacks of the states the transition enters:
+callbacks the transition runs:
 
 ```haskell
-check :: FsmState -> Maybe FsmEvent -> m (Maybe FsmEvent)
-check _ (Just (Order (Item what) n))
-  | n > 0     = pure (Just Ok)
-  | otherwise = pure (Just (Reject (Reason ("nothing ordered of " <> what))))
-check _ _     = pure (Just (Reject (Reason "no order")))
+check :: FsmEntry -> m (Maybe FsmEvent)
+check entry = case entry of
+  FsmStep FsmTransition {transitionEvent = Order (Item what) n}
+    | n > 0     -> pure (Just Ok)
+    | otherwise -> pure (Just (Reject (Reason ("nothing ordered of " <> what))))
+  _ -> pure (Just (Reject (Reason "no order")))
 ```
 
 Selection is still by name alone: a payload never decides which transition
diff --git a/scxml-statecharts.cabal b/scxml-statecharts.cabal
--- a/scxml-statecharts.cabal
+++ b/scxml-statecharts.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               scxml-statecharts
-version:            0.2.0.0
+version:            0.3.0.0
 synopsis:           Typed statecharts from SCXML, via Template Haskell
 description:
     Define a statechart (<https://statecharts.dev/>) in SCXML inside a Haskell
@@ -34,8 +34,7 @@
 
     An event can carry data, by naming the Haskell types its constructor holds
     after it: @event="Order Item Int"@ declares @Order Item Int@, and the
-    callbacks of the states that transition enters receive the value with its
-    payload. Transitions are still selected by event name alone. See the
+    callbacks that transition runs receive the value with its payload. Transitions are still selected by event name alone. See the
     README for the full mapping and the list of unsupported SCXML features.
 
 homepage:           https://github.com/AxelUlmestig/scxml-statecharts
diff --git a/src/Scxml/Statechart/Interpret.hs b/src/Scxml/Statechart/Interpret.hs
--- a/src/Scxml/Statechart/Interpret.hs
+++ b/src/Scxml/Statechart/Interpret.hs
@@ -13,7 +13,6 @@
 -- legal one.
 module Scxml.Statechart.Interpret
   ( Configuration
-  , Phase (..)
   , Callbacks (..)
   , doneEventName
   , start
@@ -33,10 +32,6 @@
 
 type Configuration = Set StateId
 
--- | Where a callback is attached. Exit callbacks may not raise events.
-data Phase = OnEntry | OnExit
-  deriving (Eq, Ord, Show)
-
 -- | How the evaluator reaches the callbacks, and everything it needs to know
 -- about the event type to do so. "Scxml.Statechart.Run" wraps the typed
 -- 'Scxml.Statechart.Run.Hooks' into one of these.
@@ -45,9 +40,14 @@
 -- but it carries the event itself from the caller through to the callbacks,
 -- so an event may hold a payload the chart knows nothing about.
 data Callbacks m ev = Callbacks
-  { runCallback :: Phase -> Text -> Configuration -> Maybe ev -> m (Maybe ev)
-    -- ^ run the named callback, given the configuration it observes and the
-    -- event being processed ('Nothing' during 'start'); returns an event to raise
+  { runEntry :: Text -> Maybe (Configuration, ev) -> Configuration -> m (Maybe ev)
+    -- ^ run the named entry callback, given the configuration the step leaves
+    -- with the event that caused it ('Nothing' during 'start'), and the
+    -- configuration the step settles in; returns an event to raise
+  , runExit :: Text -> Configuration -> ev -> Configuration -> m ()
+    -- ^ run the named exit callback, given the configuration the step leaves,
+    -- the event that caused it and the configuration it settles in. Exit
+    -- callbacks only run inside a step, so all three are always there.
   , eventNameOf :: ev -> Text
     -- ^ the name a transition matches on
   , doneEvent :: StateId -> ev
@@ -74,38 +74,40 @@
 -- | Entering a final child is what completes its parent, so the parent's done
 -- event is raised just after that child's own entry callbacks.
 completing :: StateId -> Node -> Entered -> Entered
-completing parent target e
-  | nodeKind target /= Final = e
-  | otherwise = e {enEntered = attach (enEntered e), enFinal = True}
+completing parent target e@Entered {enEntered}
+  | not (isFinal target) = e
+  | otherwise = e {enEntered = attach enEntered, enFinal = True}
   where
     attach ((h, ds) : rest) = (h, ds ++ [parent]) : rest
     attach [] = []
 
 -- | Enter a state and everything default entry into it implies.
 enter :: Node -> Entered
-enter n = case nodeKind n of
+enter n@Node {nodeId, nodeKind} = case nodeKind of
   Atomic -> leaf False
   Final -> leaf True
   Compound (c :| _) ->
-    let below = completing (nodeId n) c (enter c)
+    let Entered {enConfig, enEntered, enFinal} = completing nodeId c (enter c)
      in Entered
-          { enConfig = Set.insert (nodeId n) (enConfig below)
-          , enEntered = (n, []) : enEntered below
-          , enFinal = enFinal below
+          { enConfig = Set.insert nodeId enConfig
+          , enEntered = (n, []) : enEntered
+          , enFinal
           }
   Parallel rs ->
-    let belows = fmap enter rs
+    let belows = NE.toList (fmap enter rs)
         allFinal = all enFinal belows
-        dones = [nodeId n | allFinal]
      in Entered
-          { enConfig = Set.insert (nodeId n) (Set.unions (fmap enConfig (NE.toList belows)))
-          , enEntered = (n, dones) : concatMap enEntered (NE.toList belows)
+          { enConfig = Set.insert nodeId (Set.unions (map enConfig belows))
+          , enEntered = (n, [nodeId | allFinal]) : concatMap enEntered belows
           , enFinal = allFinal
           }
   where
     leaf isFin =
-      Entered {enConfig = Set.singleton (nodeId n), enEntered = [(n, [])], enFinal = isFin}
+      Entered {enConfig = Set.singleton nodeId, enEntered = [(n, [])], enFinal = isFin}
 
+isFinal :: Node -> Bool
+isFinal Node {nodeKind} = nodeKind == Final
+
 -- | The active states of a subtree, innermost first, which is the order exit
 -- callbacks run in.
 exiting :: Configuration -> Node -> [Node]
@@ -114,19 +116,19 @@
 -- | The children of a state that are currently active: one for a compound
 -- state, all of them for a parallel state, none for a leaf.
 activeChildren :: Configuration -> Node -> [Node]
-activeChildren cfg n = case nodeKind n of
+activeChildren cfg Node {nodeKind} = case nodeKind of
   Compound kids -> maybe [] pure (activeChild cfg kids)
   Parallel rs -> NE.toList rs
   _ -> []
 
 activeChild :: Configuration -> NonEmpty Node -> Maybe Node
-activeChild cfg kids = listToMaybe (NE.filter (\c -> Set.member (nodeId c) cfg) kids)
+activeChild cfg kids = listToMaybe (NE.filter (\Node {nodeId} -> Set.member nodeId cfg) kids)
 
 -- | A named child. The parser has already checked that every transition
 -- target is one of its source's siblings, so this cannot fail for a chart the
 -- quasiquoter built.
 childNamed :: StateId -> NonEmpty Node -> Node
-childNamed tgt kids = case NE.filter ((== tgt) . nodeId) kids of
+childNamed tgt kids = case NE.filter (\Node {nodeId} -> nodeId == tgt) kids of
   t : _ -> t
   [] -> error ("Statechart: unknown transition target " ++ T.unpack tgt)
 
@@ -149,20 +151,20 @@
 -- acts on the event if nothing below it did, so the innermost transition wins
 -- and a transition on an enclosing state behaves as a default.
 offer :: Configuration -> Text -> Node -> Reply
-offer cfg ev n = case nodeKind n of
+offer cfg ev n@Node {nodeId, nodeKind, nodeTransitions} = case nodeKind of
   Atomic -> own
   Final -> own
   Compound kids -> case activeChild cfg kids of
     Nothing -> own
     Just active ->
-      let below = offer cfg ev active
-       in if rpConsumed below then absorb kids active below else own
+      let below@Reply {rpConsumed} = offer cfg ev active
+       in if rpConsumed then absorb kids active below else own
   Parallel rs ->
-    let belows = fmap (offer cfg ev) rs
+    let belows = NE.toList (fmap (offer cfg ev) rs)
      in if any rpConsumed belows then absorbRegions belows else own
   where
     -- This state's own transition, for its parent to perform.
-    own = case Map.lookup ev (nodeTransitions n) of
+    own = case Map.lookup ev nodeTransitions of
       Just tgt -> stay {rpMove = Just tgt, rpConsumed = True}
       Nothing -> stay
     stay =
@@ -177,24 +179,25 @@
 
     -- A compound state whose active child either moved to a sibling or
     -- settled internally. Either way this state stays put.
-    absorb kids active below = case rpMove below of
+    absorb kids active below@Reply {rpMove} = case rpMove of
       Nothing ->
-        stay
-          { rpConfig = Set.insert (nodeId n) (rpConfig below)
-          , rpExited = rpExited below
-          , rpEntered = rpEntered below
-          , rpConsumed = True
-          , rpFinal = nodeKind active == Final
-          }
+        let Reply {rpConfig, rpExited, rpEntered} = below
+         in stay
+              { rpConfig = Set.insert nodeId rpConfig
+              , rpExited
+              , rpEntered
+              , rpConsumed = True
+              , rpFinal = isFinal active
+              }
       Just tgt ->
         let target = childNamed tgt kids
-            entered = completing (nodeId n) target (enter target)
+            Entered {enConfig, enEntered, enFinal} = completing nodeId target (enter target)
          in stay
-              { rpConfig = Set.insert (nodeId n) (enConfig entered)
+              { rpConfig = Set.insert nodeId enConfig
               , rpExited = exiting cfg active
-              , rpEntered = enEntered entered
+              , rpEntered = enEntered
               , rpConsumed = True
-              , rpFinal = enFinal entered
+              , rpFinal = enFinal
               }
 
     -- A parallel state: regions cannot have transitions, so none of them can
@@ -203,11 +206,11 @@
       let allFinal = all rpFinal belows
           justCompleted = allFinal && not (inFinalState cfg n)
        in stay
-            { rpConfig = Set.insert (nodeId n) (Set.unions (fmap rpConfig (NE.toList belows)))
-            , rpExited = concatMap rpExited (NE.toList belows)
+            { rpConfig = Set.insert nodeId (Set.unions (map rpConfig belows))
+            , rpExited = concatMap rpExited belows
             , rpEntered =
-                concatMap rpEntered (NE.toList belows)
-                  ++ [(n, [nodeId n]) | justCompleted]
+                concatMap rpEntered belows
+                  ++ [(n, [nodeId]) | justCompleted]
             , rpConsumed = True
             , rpFinal = allFinal
             }
@@ -221,21 +224,20 @@
 -- when its active child is final, and a parallel state is when every region
 -- is. Only a parallel state's own completion consults this.
 inFinalState :: Configuration -> Node -> Bool
-inFinalState cfg n = case nodeKind n of
+inFinalState cfg Node {nodeKind} = case nodeKind of
   Final -> True
   Atomic -> False
-  Compound kids -> maybe False ((== Final) . nodeKind) (activeChild cfg kids)
+  Compound kids -> maybe False isFinal (activeChild cfg kids)
   Parallel rs -> all (inFinalState cfg) (NE.toList rs)
 
 -- Running -------------------------------------------------------------------
 
 -- | Enter the chart's initial state, then process whatever that raises.
 start :: Monad m => Chart -> Callbacks m ev -> m Configuration
-start ch cbs = do
-  let entered = enter (NE.head (chartRoot ch))
-      cfg = enConfig entered
-  raised <- runEntries cbs cfg Nothing (enEntered entered)
-  runToCompletion ch cbs cfg raised
+start ch@Chart {chartRoot} cbs = do
+  let Entered {enConfig, enEntered} = enter (NE.head chartRoot)
+  raised <- runEntries cbs Nothing enConfig enEntered
+  runToCompletion ch cbs enConfig raised
 
 -- | Process one external event. 'Nothing' if no transition was enabled for it.
 macrostep :: Monad m => Chart -> Callbacks m ev -> Configuration -> ev -> m (Maybe Configuration)
@@ -262,33 +264,38 @@
 -- | One event, one pass. The chart root behaves as a compound state: exactly
 -- one of its children is active, and it has no transitions of its own.
 microstep :: Monad m => Chart -> Callbacks m ev -> Configuration -> ev -> m (Maybe (Configuration, [ev]))
-microstep ch cbs cfg ev =
-  case activeChild cfg (chartRoot ch) of
+microstep Chart {chartRoot} cbs@Callbacks {eventNameOf} cfg ev =
+  case activeChild cfg chartRoot of
     Nothing -> pure Nothing
     Just active ->
-      let below = offer cfg (eventNameOf cbs ev) active
-       in if not (rpConsumed below)
+      let below@Reply {rpConsumed, rpMove} = offer cfg (eventNameOf ev) active
+       in if not rpConsumed
             then pure Nothing
             else do
-              let (cfg', exited, entered) = case rpMove below of
-                    Nothing -> (rpConfig below, rpExited below, rpEntered below)
+              let (cfg', exited, entered) = case rpMove of
+                    Nothing ->
+                      let Reply {rpConfig, rpExited, rpEntered} = below
+                       in (rpConfig, rpExited, rpEntered)
                     Just tgt ->
-                      let e = enter (childNamed tgt (chartRoot ch))
-                       in (enConfig e, exiting cfg active, enEntered e)
-              mapM_ (runExits cbs cfg ev) exited
-              raised <- runEntries cbs cfg' (Just ev) entered
+                      let Entered {enConfig, enEntered} = enter (childNamed tgt chartRoot)
+                       in (enConfig, exiting cfg active, enEntered)
+              mapM_ (runExits cbs cfg ev cfg') exited
+              raised <- runEntries cbs (Just (cfg, ev)) cfg' entered
               pure (Just (cfg', raised))
 
--- | Exit callbacks see the state being left, so they get the old configuration.
-runExits :: Monad m => Callbacks m ev -> Configuration -> ev -> Node -> m ()
-runExits cbs cfg ev n =
-  mapM_ (\a -> runCallback cbs OnExit a cfg (Just ev)) (nodeOnExit n)
+-- | Every callback of a step sees the whole step: the configuration it leaves,
+-- the event that caused it, and the configuration it settles in. Exit
+-- callbacks see the new configuration too, though they run before it has
+-- been entered.
+runExits :: Monad m => Callbacks m ev -> Configuration -> ev -> Configuration -> Node -> m ()
+runExits Callbacks {runExit} cfg ev cfg' Node {nodeOnExit} =
+  mapM_ (\a -> runExit a cfg ev cfg') nodeOnExit
 
--- | Entry callbacks see the configuration the step settles in, so they all get
--- the new one even though they run outermost first.
-runEntries :: Monad m => Callbacks m ev -> Configuration -> Maybe ev -> [(Node, [StateId])] -> m [ev]
-runEntries cbs cfg ev = fmap concat . mapM one
+-- | Entry callbacks all get the configuration the step settles in, even
+-- though they run outermost first.
+runEntries :: Monad m => Callbacks m ev -> Maybe (Configuration, ev) -> Configuration -> [(Node, [StateId])] -> m [ev]
+runEntries Callbacks {runEntry, doneEvent} cause cfg' = fmap concat . mapM one
   where
-    one (n, dones) = do
-      raised <- mapM (\a -> runCallback cbs OnEntry a cfg ev) (nodeOnEntry n)
-      pure ([r | Just r <- raised] ++ map (doneEvent cbs) dones)
+    one (Node {nodeOnEntry}, dones) = do
+      raised <- mapM (\a -> runEntry a cause cfg') nodeOnEntry
+      pure ([r | Just r <- raised] ++ map doneEvent dones)
diff --git a/src/Scxml/Statechart/Run.hs b/src/Scxml/Statechart/Run.hs
--- a/src/Scxml/Statechart/Run.hs
+++ b/src/Scxml/Statechart/Run.hs
@@ -3,7 +3,6 @@
 -- callbacks named in the SCXML.
 module Scxml.Statechart.Run
   ( Hooks (..)
-  , Phase (..)
   , entryAction
   , exitAction
   , start
@@ -17,15 +16,17 @@
 
 import Scxml.Statechart.Def
 import qualified Scxml.Statechart.Interpret as I
-import Scxml.Statechart.Interpret (Phase (..))
 import Scxml.Statechart.Model (StateId)
 
 -- | How to run the callbacks named in @<script>@ elements.
-newtype Hooks m s ev = Hooks
-  { runAction :: Phase -> Text -> s -> Maybe ev -> m (Maybe ev)
-    -- ^ run the named callback with the state it observes (the state being
-    -- left for 'OnExit', the state being entered otherwise) and the event
-    -- being processed ('Nothing' during 'start'); returns an event to raise
+data Hooks m s ev = Hooks
+  { runEntry :: Text -> Maybe (s, ev) -> s -> m (Maybe ev)
+    -- ^ run the named entry callback with the state the step leaves and the
+    -- event that caused it ('Nothing' during 'start'), and the state the
+    -- step settles in. Returns an event to raise.
+  , runExit :: Text -> s -> ev -> s -> m ()
+    -- ^ run the named exit callback with the state the step leaves, the
+    -- event that caused it and the state it settles in
   }
 
 -- | Marks an entry callback, which returns the event it raises, if any.
@@ -43,26 +44,32 @@
 -- | The event reaching a callback is the one the caller passed in, not a value
 -- rebuilt from its name, so whatever payload it carries survives the trip.
 toInterp :: Def s ev -> Hooks m s ev -> I.Callbacks m ev
-toInterp def h =
+toInterp def@Def {defEventName, defDoneEvent} Hooks {runEntry, runExit} =
   I.Callbacks
-    { I.runCallback = \phase name cfg ev -> runAction h phase name (unsafeFromConfig def cfg) ev
-    , I.eventNameOf = defEventName def
-    , I.doneEvent = defDoneEvent def
+    { I.runEntry = \name cause cfg' ->
+        runEntry name
+          (fmap (\(cfg, ev) -> (state cfg, ev)) cause)
+          (state cfg')
+    , I.runExit = \name cfg ev cfg' -> runExit name (state cfg) ev (state cfg')
+    , I.eventNameOf = defEventName
+    , I.doneEvent = defDoneEvent
     }
+  where
+    state = unsafeFromConfig def
 
 unsafeFromConfig :: Def s ev -> Set.Set StateId -> s
-unsafeFromConfig def cfg =
+unsafeFromConfig Def {defFromConfig} cfg =
   fromMaybe
     (error ("Statechart: interpreter produced an invalid configuration: " ++ show (map T.unpack (Set.toList cfg))))
-    (defFromConfig def cfg)
+    (defFromConfig cfg)
 
 -- | Enter the initial state, running entry callbacks and any events they raise.
 start :: Monad m => Def s ev -> Hooks m s ev -> m s
-start def h = unsafeFromConfig def <$> I.start (defChart def) (toInterp def h)
+start def@Def {defChart} h = unsafeFromConfig def <$> I.start defChart (toInterp def h)
 
 -- | Deliver one event, staying in the current state when no transition is
 -- enabled for it, which is SCXML's behaviour for an unmatched event.
 stepOrStay :: Monad m => Def s ev -> Hooks m s ev -> s -> ev -> m s
-stepOrStay def h s e =
+stepOrStay def@Def {defChart, defToConfig} h s e =
   maybe s (unsafeFromConfig def)
-    <$> I.macrostep (defChart def) (toInterp def h) (defToConfig def s) e
+    <$> I.macrostep defChart (toInterp def h) (defToConfig s) e
diff --git a/src/Scxml/Statechart/TH.hs b/src/Scxml/Statechart/TH.hs
--- a/src/Scxml/Statechart/TH.hs
+++ b/src/Scxml/Statechart/TH.hs
@@ -14,6 +14,12 @@
 -- * @data FsmEvent@: one constructor per event name, verbatim, carrying the
 --   payload types written after the name in the @event@ attribute, plus a
 --   nullary @DoneX@ for SCXML's automatic @done.state.X@ completion events.
+-- * @data FsmTransition@: one step of the chart, a record of
+--   @transitionFrom :: FsmState@, @transitionEvent :: FsmEvent@ and
+--   @transitionTo :: FsmState@. What an exit callback receives.
+-- * @data FsmEntry = FsmStart FsmState | FsmStep FsmTransition@: what an
+--   entry callback receives. @FsmStart@ carries the initial state while
+--   @initiateStateMachine@ enters it, which leaves no state and has no event.
 -- * @fsmChart :: Def FsmState FsmEvent@, for "Scxml.Statechart.Run".
 -- * @serializeStateMachine :: FsmState -> [Text]@ and
 --   @deserializeStateMachine :: [Text] -> Maybe FsmState@, which store a state
@@ -28,14 +34,15 @@
 -- initiateStateMachine :: m FsmState
 -- notifyStateMachine   :: FsmState -> FsmEvent -> m FsmState
 --
--- entryCallback :: FsmState -> Maybe FsmEvent -> m (Maybe FsmEvent)
--- exitCallback  :: FsmState -> Maybe FsmEvent -> m ()
+-- entryCallback :: FsmEntry -> m (Maybe FsmEvent)
+-- exitCallback  :: FsmTransition -> m ()
 -- @
 --
--- Every generated type derives @Show@, @Read@, @Eq@ and @Ord@, and the event
--- type also derives @Enum@ and @Bounded@ as long as no event carries a
+-- Every generated state type derives @Show@, @Read@, @Eq@ and @Ord@, and the
+-- event type also derives @Enum@ and @Bounded@ as long as no event carries a
 -- payload: @Ord@ would demand an instance of every payload type, and the
--- other two need every constructor nullary. For storing a state outside
+-- other two need every constructor nullary. @FsmTransition@ and @FsmEntry@
+-- derive @Show@, @Read@ and @Eq@, which every event type has. For storing a state outside
 -- Haskell, prefer the generated @serializeStateMachine@ over @Show@.
 module Scxml.Statechart.TH (scxml) where
 
@@ -79,8 +86,9 @@
 -- > </scxml>
 -- > |]
 -- >
--- > report :: FsmState -> Maybe FsmEvent -> IO (Maybe FsmEvent)
--- > report st _ = print st >> pure Nothing
+-- > report :: FsmEntry -> IO (Maybe FsmEvent)
+-- > report (FsmStart st) = print st >> pure Nothing
+-- > report (FsmStep step) = print (transitionTo step) >> pure Nothing
 --
 -- State ids and event names are used verbatim, so that declares @FsmState@
 -- with constructors @Off@ and @On@, @FsmEvent@ with @Flip@, and:
@@ -89,7 +97,11 @@
 -- > notifyStateMachine   :: FsmState -> FsmEvent -> IO FsmState
 --
 -- Callbacks are ordinary functions in the same module, written after the
--- quasiquote, and are found by the name in @\<script\>@:
+-- quasiquote, and are found by the name in @\<script\>@. An exit callback
+-- receives an @FsmTransition@: the state left, the event that caused the
+-- step, and the state the step settles in. An entry callback receives an
+-- @FsmEntry@, which is @FsmStep@ with that same record, or @FsmStart@ with
+-- the initial state while @initiateStateMachine@ enters it:
 --
 -- > ghci> off <- initiateStateMachine
 -- > Off
@@ -100,8 +112,9 @@
 -- enough to print; when a chart has to carry something along, put the
 -- callbacks in a @StateT@ over it and the generated functions follow suit:
 --
--- > record :: FsmState -> Maybe FsmEvent -> StateT [FsmState] IO (Maybe FsmEvent)
--- > record st _ = modify (st :) >> pure Nothing
+-- > record :: FsmEntry -> StateT [FsmState] IO (Maybe FsmEvent)
+-- > record entry = modify (entered :) >> pure Nothing
+-- >   where entered = case entry of FsmStart st -> st; FsmStep step -> transitionTo step
 -- >
 -- > -- initiateStateMachine :: StateT [FsmState] IO FsmState
 -- > -- notifyStateMachine   :: FsmState -> FsmEvent -> StateT [FsmState] IO FsmState
@@ -111,8 +124,8 @@
 --
 -- An event may carry data, by writing the Haskell types its constructor holds
 -- after its name. @\<transition event="Order Item Int" target="Checking"/\>@
--- declares @Order Item Int@, and the callbacks of the states that transition
--- enters receive the value the caller passed in, payload and all. A payload
+-- declares @Order Item Int@, and the callbacks that transition runs receive
+-- the value the caller passed in, payload and all, as @transitionEvent@. A payload
 -- type is one type constructor, optionally qualified (@Int@,
 -- @Order.LineItem@); like a callback name it is resolved after the
 -- quasiquote, so it may be defined below it. Transitions are still selected
@@ -148,6 +161,13 @@
   let states = chartStates ch
   let stateT = mkName "FsmState"
       eventT = mkName "FsmEvent"
+      transitionT = mkName "FsmTransition"
+      entryT = mkName "FsmEntry"
+      startCon = mkName "FsmStart"
+      stepCon = mkName "FsmStep"
+      fromField = mkName "transitionFrom"
+      eventField = mkName "transitionEvent"
+      toField = mkName "transitionTo"
       defName = mkName "fsmChart"
       startName = mkName "initiateStateMachine"
       stepName = mkName "notifyStateMachine"
@@ -167,14 +187,13 @@
   -- states that can complete but that no transition mentions.
   let referenced = chartEvents ch
       completing = [n | n <- states, completes n]
-      doneEvents = [Event (I.doneEventName (nodeId n)) [] | n <- completing]
+      doneEvents = [Event (I.doneEventName nodeId) [] | Node {nodeId} <- completing]
       events = referenced ++ filter ((`notElem` map eventName referenced) . eventName) doneEvents
       -- (constructor, payload type names, what it came from, wire name)
-      eventInfo e =
-        let name = eventName e
-         in case T.stripPrefix (T.pack "done.state.") name of
-              Just sid -> (mkName ("Done" ++ T.unpack sid), [], "completion event " ++ show (T.unpack name), name)
-              Nothing -> (mkName (T.unpack name), eventFields e, "event " ++ show (T.unpack name), name)
+      eventInfo Event {eventName = name, eventFields} =
+        case T.stripPrefix (T.pack "done.state.") name of
+          Just sid -> (mkName ("Done" ++ T.unpack sid), [], "completion event " ++ show (T.unpack name), name)
+          Nothing -> (mkName (T.unpack name), eventFields, "event " ++ show (T.unpack name), name)
       eventInfos = map eventInfo events
       eventCons = [(c, origin) | (c, _, origin, _) <- eventInfos]
       -- Enum and Bounded need every constructor nullary, and Ord would make
@@ -191,8 +210,8 @@
     from <- newName ("fromCfg_" ++ nameBase ty)
     let children = maybe (NE.toList (chartRoot ch)) nodeChildren g
     pure (g, Group ty children to from)
-  let groupMap = Map.fromList [(nodeId n, grp) | (Just n, grp) <- groups]
-  rootGroup <- case groups of
+  let groupMap = Map.fromList [(nodeId, grp) | (Just Node {nodeId}, grp) <- groups]
+  Group {gTo = rootTo, gFrom = rootFrom} <- case groups of
     (_, g) : _ -> pure g
     [] -> fail "scxml: internal error, no root group"
   let groupOf sid = case Map.lookup sid groupMap of
@@ -202,13 +221,17 @@
   -- Every generated constructor and type, with its origin, so clashes give a
   -- readable error instead of "Multiple declarations".
   let stateCons =
-        [ (nameFor (nodeId n), "state " ++ show (T.unpack (nodeId n)))
-        | n <- concatMap (gChildren . snd) groups
+        [ (nameFor nodeId, "state " ++ show (T.unpack nodeId))
+        | (_, Group {gChildren}) <- groups
+        , Node {nodeId} <- gChildren
         ]
       typeNames =
-        [(stateT, "the state type"), (eventT, "the event type")]
-          ++ [(gType grp, "compound state " ++ show (T.unpack (nodeId n))) | (Just n, grp) <- groups]
-  checkClashes "constructor" (stateCons ++ eventCons)
+        [ (stateT, "the state type"), (eventT, "the event type")
+        , (transitionT, "the transition type"), (entryT, "the entry type") ]
+          ++ [(gType, "compound state " ++ show (T.unpack nodeId)) | (Just Node {nodeId}, Group {gType}) <- groups]
+  checkClashes "constructor"
+    ( [(transitionT, "the transition type"), (startCon, "the entry type"), (stepCon, "the entry type")]
+        ++ stateCons ++ eventCons )
   checkClashes "type" typeNames
 
   stateDecs <- concat <$> mapM (groupDecs nameFor groupOf . snd) groups
@@ -218,6 +241,25 @@
       | (c, fs, _, _) <- eventInfos
       ]
       [derivClause Nothing (map conT eventDerivs)]
+  -- What callbacks receive. A record rather than positional arguments, so a
+  -- callback names what it reads and ignores the rest. An exit callback only
+  -- runs inside a step, so it gets the record itself; an entry callback may
+  -- also run while the chart starts, which leaves nothing and has no event.
+  transitionDec <- do
+    let field n t = varBangType n (bangType (bang noSourceUnpackedness noSourceStrictness) t)
+    dataD (cxt []) transitionT [] Nothing
+      [ recC transitionT
+          [ field fromField (conT stateT)
+          , field eventField (conT eventT)
+          , field toField (conT stateT)
+          ]
+      ]
+      [derivClause Nothing (map conT [''Show, ''Read, ''Eq])]
+  entryDec <- do
+    let plain t = bangType (bang noSourceUnpackedness noSourceStrictness) (conT t)
+    dataD (cxt []) entryT [] Nothing
+      [normalC startCon [plain stateT], normalC stepCon [plain transitionT]]
+      [derivClause Nothing (map conT [''Show, ''Read, ''Eq])]
 
   let eventNameE
         | null eventInfos = [| \_ -> error "eventName: chart has no events" |]
@@ -247,34 +289,39 @@
              { defChart = $(lift ch)
              , defEventName = $eventNameE
              , defDoneEvent = $doneEventE
-             , defToConfig = $(varE (gTo rootGroup))
-             , defFromConfig = $(varE (gFrom rootGroup))
+             , defToConfig = $(varE rootTo)
+             , defFromConfig = $(varE rootFrom)
              } |])
       []
 
   -- Hooks dispatching to the callbacks named in the SCXML. Entry callbacks
-  -- return m (Maybe FsmEvent), exit callbacks m (). Inlined into each
-  -- generated function rather than shared, so a polymorphic monad does not
-  -- hit the monomorphism restriction.
+  -- take an FsmEntry and return m (Maybe FsmEvent), exit callbacks take an
+  -- FsmTransition and return m (). Inlined into each generated function
+  -- rather than shared, so a polymorphic monad does not hit the monomorphism
+  -- restriction.
   -- Underscore-prefixed so that a chart with no callbacks of some phase does
   -- not emit an unused-match warning in the user's module.
   let hooksE = do
-        phase <- newName "_phase"
         name <- newName "_name"
-        st <- newName "_st"
+        cause <- newName "_cause"
+        from <- newName "_from"
         ev <- newName "_ev"
-        let call fn = [| $(varE (mkName (T.unpack fn))) $(varE st) $(varE ev) |]
-            entryChain =
-              foldr (\fn rest -> [| if $(varE name) == $(lift fn) then Run.entryAction $(call fn) else $rest |])
-                    [| pure Nothing |] entryActions
-            exitChain =
-              foldr (\fn rest -> [| if $(varE name) == $(lift fn) then Run.exitAction $(call fn) else $rest |])
-                    [| pure () |] exitActions
-        body <- caseE (varE phase)
-          [ match (conP 'Run.OnExit []) (normalB [| $exitChain >> pure Nothing |]) []
-          , match (conP 'Run.OnEntry []) (normalB entryChain) []
-          ]
-        [| Run.Hooks $(lamE [varP phase, varP name, varP st, varP ev] (pure body)) |]
+        to <- newName "_to"
+        let dispatch marker fallback arg fns =
+              foldr (\fn rest -> [| if $(varE name) == $(lift fn) then $marker ($(varE (mkName (T.unpack fn))) $arg) else $rest |])
+                    fallback fns
+            step f e t = [| $(conE transitionT) $f $e $t |]
+            entry =
+              [| case $(varE cause) of
+                   Nothing -> $(conE startCon) $(varE to)
+                   Just (f, e) -> $(conE stepCon) $(step [| f |] [| e |] (varE to)) |]
+            runEntryE =
+              lamE [varP name, varP cause, varP to]
+                (dispatch [| Run.entryAction |] [| pure Nothing |] entry entryActions)
+            runExitE =
+              lamE [varP name, varP from, varP ev, varP to]
+                (dispatch [| Run.exitAction |] [| pure () |] (step (varE from) (varE ev) (varE to)) exitActions)
+        [| Run.Hooks {Run.runEntry = $runEntryE, Run.runExit = $runExitE} |]
   startDec <- valD (varP startName) (normalB [| Run.start $(varE defName) $hooksE |]) []
   stepDec <- do
     st <- newName "st"
@@ -284,8 +331,8 @@
   -- Storing a state outside Haskell, as the set of active state ids. Generated
   -- rather than exported so that a chart needs no imports beyond the
   -- quasiquoter itself.
-  let toCfg = varE (gTo rootGroup)
-      fromCfg = varE (gFrom rootGroup)
+  let toCfg = varE rootTo
+      fromCfg = varE rootFrom
   toIdsSig <- sigD toIdsName [t| $(conT stateT) -> [Text] |]
   toIdsDec <- do
     x <- newName "st"
@@ -304,14 +351,14 @@
                    _ -> Nothing |]
     funD fromIdsName [clause [varP ids] (normalB body) []]
 
-  pure (stateDecs ++ [eventDec, defSig, defDec, startDec, stepDec, toIdsSig, toIdsDec, fromIdsSig, fromIdsDec])
+  pure (stateDecs ++ [eventDec, transitionDec, entryDec, defSig, defDec, startDec, stepDec, toIdsSig, toIdsDec, fromIdsSig, fromIdsDec])
 
 -- | Data type plus configuration conversions for one compound-like node.
 groupDecs :: (StateId -> Name) -> (StateId -> Q Group) -> Group -> Q [Dec]
-groupDecs nameFor groupOf grp = do
-  shapes <- mapM childShape (gChildren grp)
+groupDecs nameFor groupOf Group {gType, gChildren, gTo, gFrom} = do
+  shapes <- mapM childShape gChildren
   let dataDec =
-        dataD (cxt []) (gType grp) [] Nothing
+        dataD (cxt []) gType [] Nothing
           [normalC con [bangType (bang noSourceUnpackedness noSourceStrictness) (conT f) | f <- fields] | (con, fields, _, _) <- shapes]
           [derivClause Nothing (map conT [''Show, ''Read, ''Eq, ''Ord])]
       toDec = do
@@ -319,57 +366,58 @@
         alts <- forM shapes $ \(con, fields, toE, _) -> do
           vars <- mapM (const (newName "r")) fields
           match (conP con (map varP vars)) (normalB (toE vars)) []
-        funD (gTo grp) [clause [varP x] (normalB (caseE (varE x) (map pure alts))) []]
+        funD gTo [clause [varP x] (normalB (caseE (varE x) (map pure alts))) []]
       fromDec = do
         cfg <- newName "cfg"
         let body =
               foldr
-                (\(n, (_, _, _, rebuild)) rest -> [| if Set.member $(lift (nodeId n)) $(varE cfg) then $(rebuild cfg) else $rest |])
+                (\(Node {nodeId}, (_, _, _, rebuild)) rest -> [| if Set.member $(lift nodeId) $(varE cfg) then $(rebuild cfg) else $rest |])
                 [| Nothing |]
-                (zip (gChildren grp) shapes)
-        funD (gFrom grp) [clause [varP cfg] (normalB body) []]
+                (zip gChildren shapes)
+        funD gFrom [clause [varP cfg] (normalB body) []]
   sequence
     [ dataDec
-    , sigD (gTo grp) [t| $(conT (gType grp)) -> Set.Set Text |]
+    , sigD gTo [t| $(conT gType) -> Set.Set Text |]
     , toDec
-    , sigD (gFrom grp) [t| Set.Set Text -> Maybe $(conT (gType grp)) |]
+    , sigD gFrom [t| Set.Set Text -> Maybe $(conT gType) |]
     , fromDec
     ]
   where
     -- For one child: (constructor, field types, config-of-fields, rebuild-from-config)
     childShape :: Node -> Q (Name, [Name], [Name] -> Q Exp, Name -> Q Exp)
-    childShape node = do
-      let sid = nodeId node
-          con = nameFor sid
-      case nodeKind node of
+    -- The groups below are the children's, so their fields are renamed rather
+    -- than punned, which would shadow this group's own.
+    childShape Node {nodeId = sid, nodeKind} = do
+      let con = nameFor sid
+      case nodeKind of
         Compound _ -> do
-          sub <- groupOf sid
+          Group {gType = subType, gTo = subTo, gFrom = subFrom} <- groupOf sid
           pure
             ( con
-            , [gType sub]
+            , [subType]
             , \vs -> case vs of
-                [v] -> [| Set.insert $(lift sid) ($(varE (gTo sub)) $(varE v)) |]
+                [v] -> [| Set.insert $(lift sid) ($(varE subTo) $(varE v)) |]
                 _ -> fail "scxml: internal error, compound state expects exactly one field"
-            , \cfg -> [| fmap $(conE con) ($(varE (gFrom sub)) $(varE cfg)) |]
+            , \cfg -> [| fmap $(conE con) ($(varE subFrom) $(varE cfg)) |]
             )
         Parallel regionNodes -> do
-          regions <- forM (NE.toList regionNodes) $ \rn -> case nodeKind rn of
-            Compound _ -> Just <$> groupOf (nodeId rn)
+          regions <- forM (NE.toList regionNodes) $ \Node {nodeId = rid, nodeKind = regionKind} -> case regionKind of
+            Compound _ -> Just <$> groupOf rid
             Parallel _ ->
               fail $
-                "scxml: <parallel> " ++ T.unpack (nodeId rn) ++ " is directly inside another <parallel>, which is not supported."
+                "scxml: <parallel> " ++ T.unpack rid ++ " is directly inside another <parallel>, which is not supported."
                   ++ " Flatten it: its regions can become regions of the outer <parallel>, since all of them are active at once either way."
                   ++ " The only thing flattening loses is a done.state event for the inner one on its own."
             _ -> pure Nothing
           let regionIds = map nodeId (NE.toList regionNodes)
-              fieldTypes = [gType g | Just g <- regions]
+              fieldTypes = [subType | Just Group {gType = subType} <- regions]
               toE vs =
                 let go [] _ = []
                     go ((r, Nothing) : rest) vars = [| Set.singleton $(lift r) |] : go rest vars
-                    go ((r, Just g) : rest) (v : vars) = [| Set.insert $(lift r) ($(varE (gTo g)) $(varE v)) |] : go rest vars
+                    go ((r, Just Group {gTo = subTo}) : rest) (v : vars) = [| Set.insert $(lift r) ($(varE subTo) $(varE v)) |] : go rest vars
                     go _ [] = error "scxml: internal error, region/field mismatch"
                  in [| Set.insert $(lift sid) (Set.unions $(listE (go (zip regionIds regions) vs))) |]
-              rebuild cfg = foldl (\acc g -> [| $acc <*> $(varE (gFrom g)) $(varE cfg) |]) [| pure $(conE con) |] [g | Just g <- regions]
+              rebuild cfg = foldl (\acc subFrom -> [| $acc <*> $(varE subFrom) $(varE cfg) |]) [| pure $(conE con) |] [subFrom | Just Group {gFrom = subFrom} <- regions]
           pure (con, fieldTypes, toE, rebuild)
         _ ->
           pure
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -87,6 +87,10 @@
 --   data Processing = Authorizing | Fulfilment Shipping Invoicing | Fulfilled
 --   data Shipping   = Packing | Shipped
 --   data Invoicing  = Unpaid | Settled
+--   data FsmTransition = FsmTransition
+--     { transitionFrom :: FsmState, transitionEvent :: FsmEvent
+--     , transitionTo :: FsmState }
+--   data FsmEntry   = FsmStart FsmState | FsmStep FsmTransition
 --   data FsmEvent   = Submit | Discard | Abandon | Valid | Invalid | Poll
 --                   | PaymentAuthorized | Packed | Paid | DoneFulfilment
 --                   | PaymentDeclined | DoneProcessing | Cancel
@@ -109,41 +113,49 @@
 initiateStateMachine :: M FsmState
 notifyStateMachine :: FsmState -> FsmEvent -> M FsmState
 
--- Callbacks named in the SCXML. Entry callbacks return m (Maybe FsmEvent),
--- raising an event with Just; exit callbacks return m (). All in one monad,
--- or it does not compile.
+-- Callbacks named in the SCXML. Entry callbacks take an FsmEntry and return
+-- m (Maybe FsmEvent), raising an event with Just; exit callbacks take the
+-- FsmTransition they run in and return m (). All in one monad, or it does not
+-- compile.
 
 -- | A choice state's entry callback: decide by raising an event.
-validate :: FsmState -> Maybe FsmEvent -> M (Maybe FsmEvent)
-validate _ _ = do
+validate :: FsmEntry -> M (Maybe FsmEvent)
+validate _ = do
   ok <- gets (not . null . items)
   pure (Just (if ok then Valid else Invalid))
 
 -- | Poll something on entry; move on immediately if it is already settled.
 -- Re-entered by the Poll self-transition, which is how polling replaces a
 -- transition script.
-checkPrepayment :: FsmState -> Maybe FsmEvent -> M (Maybe FsmEvent)
-checkPrepayment _ _ = do
+checkPrepayment :: FsmEntry -> M (Maybe FsmEvent)
+checkPrepayment _ = do
   say "checked prepayment"
   paid <- gets prepaid
   pure (if paid then Just PaymentAuthorized else Nothing)
 
 -- Entry callbacks that raise nothing still say so, with pure Nothing.
-reserveStock, notifyCustomer :: FsmState -> Maybe FsmEvent -> M (Maybe FsmEvent)
-reserveStock _ _ = do
+reserveStock, notifyCustomer :: FsmEntry -> M (Maybe FsmEvent)
+reserveStock _ = do
   n <- gets (length . items)
   modify' (\s -> s {reserved = n})
   say "reserved stock"
   pure Nothing
--- Entry callbacks see the state entered and the event that caused it.
-notifyCustomer s ev = do
-  say ("notified customer: " <> tshow s <> " after " <> maybe "start" tshow ev)
+-- Entry callbacks see the state left, the event that caused the step, and
+-- the state entered, unless the chart is only starting.
+notifyCustomer entry = do
+  say ("notified customer: " <> case entry of FsmStart s -> "at start in " <> tshow s; FsmStep step -> describe step)
   pure Nothing
 
--- Exit callbacks see the state being left, and cannot raise.
-releaseStock :: FsmState -> Maybe FsmEvent -> M ()
-releaseStock s _ = modify' (\s' -> s' {reserved = 0}) >> say ("released stock leaving " <> tshow s)
+-- Exit callbacks see the same step, always with all three, and cannot raise.
+releaseStock :: FsmTransition -> M ()
+releaseStock step = modify' (\s' -> s' {reserved = 0}) >> say ("released stock: " <> describe step)
 
+describe :: FsmTransition -> Text
+describe step =
+  tshow (transitionFrom step)
+    <> " --" <> tshow (transitionEvent step)
+    <> "--> " <> tshow (transitionTo step)
+
 say :: Text -> M ()
 say t = modify' (\s -> s {log_ = log_ s ++ [t]})
 
@@ -184,8 +196,8 @@
   check "callbacks in order, with state and event"
     [ "reserved stock"
     , "checked prepayment"
-    , "released stock leaving Processing Fulfilled"
-    , "notified customer: Completed after DoneProcessing"
+    , "released stock: Processing Fulfilled --DoneProcessing--> Completed"
+    , "notified customer: Processing Fulfilled --DoneProcessing--> Completed"
     ]
     (log_ shop)
   check "release resets the reservation" 0 (reserved shop)
@@ -229,7 +241,8 @@
   (end3, shop3) <- runEvents (shopWith ["book"]) [Submit, PaymentAuthorized, Packed, Cancel]
   check "cancel from inside Fulfilment" Cancelled end3
   check "cancel releases stock"
-    ["reserved stock", "checked prepayment", "released stock leaving Processing (Fulfilment Shipped Unpaid)"]
+    [ "reserved stock", "checked prepayment"
+    , "released stock: Processing (Fulfilment Shipped Unpaid) --Cancel--> Cancelled" ]
     (log_ shop3)
 
   -- An event with no transition in the current state is ignored: same state,
@@ -237,7 +250,7 @@
   (end4, shop4) <- runEvents (shopWith ["book"]) [Submit, Packed, Paid, Cancel, Cancel]
   check "unhandled events leave the state alone" Cancelled end4
   check "unhandled events run nothing"
-    ["reserved stock", "checked prepayment", "released stock leaving Processing Authorizing"]
+    ["reserved stock", "checked prepayment", "released stock: Processing Authorizing --Cancel--> Cancelled"]
     (log_ shop4)
 
   -- An entry callback raising an event: processed before the step returns.
diff --git a/test/Overrides.hs b/test/Overrides.hs
--- a/test/Overrides.hs
+++ b/test/Overrides.hs
@@ -31,10 +31,12 @@
 initiateStateMachine :: StateT [String] IO FsmState
 notifyStateMachine :: FsmState -> FsmEvent -> StateT [String] IO FsmState
 
-noteEntry, raiseYes, raiseNo :: FsmState -> Maybe FsmEvent -> StateT [String] IO (Maybe FsmEvent)
-noteEntry _ _ = modify' (++ ["entered Middle"]) >> pure Nothing
-raiseYes _ _ = modify' (++ ["raising Yes"]) >> pure (Just Yes)
-raiseNo _ _ = modify' (++ ["raising No"]) >> pure (Just No)
+noteEntry, raiseYes, raiseNo :: FsmEntry -> StateT [String] IO (Maybe FsmEvent)
+-- A record field per thing the step has, so a callback reads only what it uses.
+noteEntry (FsmStart s) = modify' (++ ["entered " ++ show s ++ " at start"]) >> pure Nothing
+noteEntry (FsmStep step) = modify' (++ ["entered " ++ show (transitionTo step) ++ " from " ++ show (transitionFrom step)]) >> pure Nothing
+raiseYes _ = modify' (++ ["raising Yes"]) >> pure (Just Yes)
+raiseNo _ = modify' (++ ["raising No"]) >> pure (Just No)
 
 -- | Checks to run, as (label, expected, actual) triples.
 spec :: IO [(String, String, String)]
@@ -48,7 +50,7 @@
   pure
     [ ("an inner transition overrides the enclosing default", show (Outer Yeah), show afterPoke)
     , ("every entry callback runs, in document order"
-      , show ["entered Middle", "raising Yes", "raising No"]
+      , show ["entered Outer Middle from Outer Inner", "raising Yes", "raising No"]
       , show lg
       )
     , ("the enclosing default applies where nothing inner handles the event"
diff --git a/test/Payloads.hs b/test/Payloads.hs
--- a/test/Payloads.hs
+++ b/test/Payloads.hs
@@ -74,13 +74,15 @@
 newtype Reason = Reason Text
   deriving (Show, Read, Eq)
 
-arrive, check, note :: FsmState -> Maybe FsmEvent -> M (Maybe FsmEvent)
-arrive _ ev = do
-  say ("idle after " <> maybe "start" (T.pack . show) ev)
+arrive, check, note :: FsmEntry -> M (Maybe FsmEvent)
+arrive entry = do
+  say $ case entry of
+    FsmStart _ -> "idle at start"
+    FsmStep t -> "idle after " <> tshow (transitionEvent t) <> " from " <> tshow (transitionFrom t)
   pure Nothing
 
-check _ ev = case ev of
-  Just (Order items n)
+check entry = case entry of
+  FsmStep FsmTransition {transitionEvent = Order items n}
     | n > 0 -> do
         say ("checking " <> T.pack (show n) <> " x " <> named items)
         pure (Just Ok)
@@ -89,13 +91,16 @@
   where
     named items = T.intercalate " + " [what | Item what <- items]
 
-note _ ev = do
-  say (case ev of Just (Reject (Reason why)) -> "refused: " <> why; _ -> "refused")
+note entry = do
+  say (case entry of FsmStep FsmTransition {transitionEvent = Reject (Reason why)} -> "refused: " <> why; _ -> "refused")
   pure Nothing
 
 say :: Text -> M ()
 say t = modify' (++ [t])
 
+tshow :: Show a => a -> Text
+tshow = T.pack . show
+
 -- | Start the chart and feed events. Returns the final state and the log.
 run :: [FsmEvent] -> IO (FsmState, [Text])
 run evs = runStateT (initiateStateMachine >>= go evs) []
@@ -112,14 +117,14 @@
   (again, _) <- run [Order [Item "book"] 0, Order [Item "pen"] 1]
   pure
     [ ("a payload reaches the entry callback of the state the event causes"
-      , show ["idle after start", "checking 2 x book" :: Text]
+      , show ["idle at start", "checking 2 x book" :: Text]
       , show acceptedLog
       )
     , ("and decides what that callback raises", show (Shipping Packing), show accepted)
     , -- The Reject raised by Checking's callback carries a Reason, which the
       -- callback of the state it leads to reads back.
       ("a raised event carries its payload to the next callback"
-      , show ["idle after start", "refused: nothing ordered of book" :: Text]
+      , show ["idle at start", "refused: nothing ordered of book" :: Text]
       , show refusedLog
       )
     , ("a raised event still selects by name alone", show Refused, show refused)
@@ -127,8 +132,11 @@
       , show Idle
       , show shipped
       )
-    , ("a done.state event carries nothing, and reaches the callback after it"
-      , show ["idle after start", "checking 2 x book", "idle after DoneShipping" :: Text]
+    , -- A step caused by a raised event leaves the state the previous step
+      -- settled in, so the callback sees Shipping Sent rather than where the
+      -- caller's Ship was delivered.
+      ("a done.state event carries nothing, and reaches the callback after it"
+      , show ["idle at start", "checking 2 x book", "idle after DoneShipping from Shipping Sent" :: Text]
       , show shippedLog
       )
     , ("the same event on two transitions is one constructor"
