diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,8 @@
 # Changelog for wireform-derive
 
-## 0.1.0.0 -- 2026-05-16
+## 0.2.0.0 -- 2026-07-16
 
-Initial public release of the cross-format deriver core.
+Breaking release from 0.1.0.0.
 
 ### Highlights
 
diff --git a/src/Wireform/Derive.hs b/src/Wireform/Derive.hs
--- a/src/Wireform/Derive.hs
+++ b/src/Wireform/Derive.hs
@@ -1,56 +1,61 @@
--- | Annotation-driven Template Haskell deriver core, shared across
--- every wireform format package.
---
--- This module re-exports the public surface so that downstream
--- deriver packages (@wireform-proto@, @wireform-cbor@,
--- @wireform-msgpack@, @wireform-thrift@, and the 18 newer per-format
--- derivers) can depend on a single import:
---
--- @
--- import Wireform.Derive
--- @
---
--- The Aeson deriver is bundled here as 'Wireform.Derive.Aeson' rather
--- than living in a separate package; it doubles as the canonical
--- worked example for adding a new backend on top of this core.
---
--- == Usage
---
--- 1. Annotate types and fields with 'Modifier's via @ANN@ pragmas.
--- 2. In a deriver splice, call 'reifyTypeInfo' for the data type and
---    'reifyModifierInfoFor' / 'reifyModifierInfo' for each affected
---    'Name' to obtain a backend-resolved 'ModifierInfo'.
--- 3. Use 'renderRenameKey' or 'renderWireKey' to splice the
---    appropriate 'Text' (or runtime expression) for each field's wire
---    key.
---
--- See "Wireform.Derive.Aeson" for a complete worked example.
-module Wireform.Derive
-  ( -- * Backends
-    module Wireform.Derive.Backend
+{- | Annotation-driven Template Haskell deriver core, shared across
+every wireform format package.
 
-    -- * Name styles
-  , module Wireform.Derive.NameStyle
+This module re-exports the public surface so that downstream
+deriver packages (@wireform-proto@, @wireform-cbor@,
+@wireform-msgpack@, @wireform-thrift@, and the 18 newer per-format
+derivers) can depend on a single import:
 
-    -- * Modifiers
-  , module Wireform.Derive.Modifier
+@
+import Wireform.Derive
+@
 
-    -- * Type reflection
-  , module Wireform.Derive.TypeInfo
+The Aeson deriver is bundled here as 'Wireform.Derive.Aeson' rather
+than living in a separate package; it doubles as the canonical
+worked example for adding a new backend on top of this core.
 
-    -- * Resolution
-  , module Wireform.Derive.ModifierInfo
+== Usage
 
-    -- * Backend extension vocabulary
-    --
-    -- | Per-backend modifiers without modifying the core ADT. See
-    -- 'Wireform.Derive.Extension' for the rationale.
-  , module Wireform.Derive.Extension
-  ) where
+1. Annotate types and fields with 'Modifier's via @ANN@ pragmas.
+2. In a deriver splice, call 'reifyTypeInfo' for the data type and
+   'reifyModifierInfoFor' / 'reifyModifierInfo' for each affected
+   'Name' to obtain a backend-resolved 'ModifierInfo'.
+3. Use 'renderRenameKey' or 'renderWireKey' to splice the
+   appropriate 'Text' (or runtime expression) for each field's wire
+   key.
 
+See "Wireform.Derive.Aeson" for a complete worked example.
+-}
+module Wireform.Derive (
+  -- * Backends
+  module Wireform.Derive.Backend,
+
+  -- * Name styles
+  module Wireform.Derive.NameStyle,
+
+  -- * Modifiers
+  module Wireform.Derive.Modifier,
+
+  -- * Type reflection
+  module Wireform.Derive.TypeInfo,
+
+  -- * Resolution
+  module Wireform.Derive.ModifierInfo,
+
+  -- * Backend extension vocabulary
+
+  --
+
+  {- | Per-backend modifiers without modifying the core ADT. See
+  'Wireform.Derive.Extension' for the rationale.
+  -}
+  module Wireform.Derive.Extension,
+) where
+
 import Wireform.Derive.Backend
 import Wireform.Derive.Extension
 import Wireform.Derive.Modifier
 import Wireform.Derive.ModifierInfo
 import Wireform.Derive.NameStyle
 import Wireform.Derive.TypeInfo
+
diff --git a/src/Wireform/Derive/Aeson.hs b/src/Wireform/Derive/Aeson.hs
--- a/src/Wireform/Derive/Aeson.hs
+++ b/src/Wireform/Derive/Aeson.hs
@@ -1,55 +1,56 @@
 {-# LANGUAGE TemplateHaskell #-}
 
--- | Annotation-driven Aeson 'ToJSON' / 'FromJSON' deriver.
---
--- Adapted from riz0id's @aeson-th@ but reimplemented on top of the
--- 'Wireform.Derive' core so a single set of @ANN@ pragmas can drive
--- every wireform-supported format simultaneously. Riz0id's original
--- left sum-of-products unfinished; this deriver implements them as
--- aeson's @TaggedObject@ shape (the proto3-JSON-friendly default).
---
--- The deriver covers four type shapes:
---
--- * 'TypeShapeNewtype' — the inner field's instance is reused.
--- * 'TypeShapeRecord'  — encoded as @{ "k1": v1, ... }@.
--- * 'TypeShapeEnum'    — encoded as a JSON string of the constructor
---   name (with rename modifiers applied).
--- * 'TypeShapeSum'     — encoded as @{ "tag": "Ctor", "contents": ... }@.
---   When a constructor has zero fields, @contents@ is 'Aeson.Null';
---   one field, the field's JSON; multiple fields, a JSON array.
---
--- == Quick start
---
--- @
--- data Person = Person
---   { personName :: !Text
---   , personAge  :: !Int
---   } deriving (Eq, Show)
---
--- {-\# ANN type Person   (rename "person")           \#-}
--- {-\# ANN personName    (rename "name")             \#-}
--- {-\# ANN personAge     (renameStyle SnakeCase)     \#-}
---
--- 'deriveJSON' ''Person
--- @
-module Wireform.Derive.Aeson
-  ( deriveJSON
-  , deriveToJSON
-  , deriveFromJSON
-  ) where
+{- | Annotation-driven Aeson 'ToJSON' / 'FromJSON' deriver.
 
+Adapted from riz0id's @aeson-th@ but reimplemented on top of the
+'Wireform.Derive' core so a single set of @ANN@ pragmas can drive
+every wireform-supported format simultaneously. Riz0id's original
+left sum-of-products unfinished; this deriver implements them as
+aeson's @TaggedObject@ shape (the proto3-JSON-friendly default).
+
+The deriver covers four type shapes:
+
+* 'TypeShapeNewtype' — the inner field's instance is reused.
+* 'TypeShapeRecord'  — encoded as @{ "k1": v1, ... }@.
+* 'TypeShapeEnum'    — encoded as a JSON string of the constructor
+  name (with rename modifiers applied).
+* 'TypeShapeSum'     — encoded as @{ "tag": "Ctor", "contents": ... }@.
+  When a constructor has zero fields, @contents@ is 'Aeson.Null';
+  one field, the field's JSON; multiple fields, a JSON array.
+
+== Quick start
+
+@
+data Person = Person
+  { personName :: !Text
+  , personAge  :: !Int
+  } deriving (Eq, Show)
+
+{\-\# ANN type Person   (rename "person")           \#-\}
+{\-\# ANN personName    (rename "name")             \#-\}
+{\-\# ANN personAge     (renameStyle SnakeCase)     \#-\}
+
+'deriveJSON' ''Person
+@
+-}
+module Wireform.Derive.Aeson (
+  deriveJSON,
+  deriveToJSON,
+  deriveFromJSON,
+) where
+
 import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=))
-import qualified Data.Aeson as Aeson
-import qualified Data.Aeson.Key as Aeson.Key
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Key qualified as Aeson.Key
 import Data.Coerce (coerce)
 import Data.Foldable (foldlM)
-import qualified Data.Text as T
+import Data.Text qualified as T
 import Language.Haskell.TH
-
 import Wireform.Derive.Backend
 import Wireform.Derive.ModifierInfo
 import Wireform.Derive.TypeInfo
 
+
 -- ---------------------------------------------------------------------------
 -- Public entry points
 -- ---------------------------------------------------------------------------
@@ -58,60 +59,73 @@
 deriveJSON :: Name -> Q [Dec]
 deriveJSON nm = (++) <$> deriveToJSON nm <*> deriveFromJSON nm
 
+
 -- | Derive only 'ToJSON'.
 deriveToJSON :: Name -> Q [Dec]
 deriveToJSON nm = do
   ti <- reifyTypeInfo nm
   body <- toJSONBody ti
   let typ = applyTypeArgs (ConT (typeInfoName ti)) (typeInfoVarTypes ti)
-      decl = InstanceD Nothing []
-              (AppT (ConT ''ToJSON) typ)
-              [FunD 'toJSON [Clause [] (NormalB body) []]]
+      decl =
+        InstanceD
+          Nothing
+          []
+          (AppT (ConT ''ToJSON) typ)
+          [FunD 'toJSON [Clause [] (NormalB body) []]]
   pure [decl]
 
+
 -- | Derive only 'FromJSON'.
 deriveFromJSON :: Name -> Q [Dec]
 deriveFromJSON nm = do
   ti <- reifyTypeInfo nm
   body <- fromJSONBody ti
   let typ = applyTypeArgs (ConT (typeInfoName ti)) (typeInfoVarTypes ti)
-      decl = InstanceD Nothing []
-              (AppT (ConT ''FromJSON) typ)
-              [FunD 'parseJSON [Clause [] (NormalB body) []]]
+      decl =
+        InstanceD
+          Nothing
+          []
+          (AppT (ConT ''FromJSON) typ)
+          [FunD 'parseJSON [Clause [] (NormalB body) []]]
   pure [decl]
 
+
 -- ---------------------------------------------------------------------------
 -- ToJSON dispatch
 -- ---------------------------------------------------------------------------
 
 toJSONBody :: TypeInfo -> Q Exp
 toJSONBody ti = case typeInfoShape ti of
-  TypeShapeNewtype c   -> toJSONNewtype c
-  TypeShapeRecord  c   -> toJSONRecord  c
-  TypeShapeEnum    cs  -> toJSONEnum    cs
-  TypeShapeSum     cs  -> toJSONSum     cs
+  TypeShapeNewtype c -> toJSONNewtype c
+  TypeShapeRecord c -> toJSONRecord c
+  TypeShapeEnum cs -> toJSONEnum cs
+  TypeShapeSum cs -> toJSONSum cs
 
+
 toJSONNewtype :: ConInfo -> Q Exp
 toJSONNewtype c = case conInfoFields c of
   [FieldInfo (Just sel) _] -> do
     x <- newName "x"
-    lamE [varP x] [| toJSON ($(varE sel) $(varE x)) |]
+    lamE [varP x] [|toJSON ($(varE sel) $(varE x))|]
   [FieldInfo Nothing _] -> do
     x <- newName "x"
-    lamE [conP (conInfoName c) [varP x]] [| toJSON $(varE x) |]
+    lamE [conP (conInfoName c) [varP x]] [|toJSON $(varE x)|]
   _ -> fail "Wireform.Derive.Aeson: newtype must have exactly one field"
 
+
 toJSONRecord :: ConInfo -> Q Exp
 toJSONRecord c = do
   x <- newName "x"
   pairsExp <- recordToJSONPairs (varE x) c
-  lamE [varP x] [| Aeson.object $(pure pairsExp) |]
+  lamE [varP x] [|Aeson.object $(pure pairsExp)|]
 
+
 recordToJSONPairs :: Q Exp -> ConInfo -> Q Exp
 recordToJSONPairs varExp c = do
   pairExpss <- mapM (toJSONField varExp) (conInfoFields c)
   pure (ListE (concat pairExpss))
 
+
 toJSONField :: Q Exp -> FieldInfo -> Q [Exp]
 toJSONField varExp (FieldInfo mSel _) = do
   selName <- requireSelector mSel
@@ -123,13 +137,15 @@
       keyExp <- renderWireKey mi selBase
       let getter = appE (varE selName) varExp
           encoded = case miCoerce mi of
-            Nothing -> [| toJSON $getter |]
-            Just _  -> [| toJSON (coerce $getter) |]
-      pair <- [| (Aeson.Key.fromText $(pure keyExp) .= ($encoded :: Aeson.Value)) |]
+            Nothing -> [|toJSON $getter|]
+            Just _ -> [|toJSON (coerce $getter)|]
+      pair <- [|(Aeson.Key.fromText $(pure keyExp) .= ($encoded :: Aeson.Value))|]
       pure [pair]
 
--- | Enum: encode as a JSON string. Each constructor's wire key is
--- baked at splice time.
+
+{- | Enum: encode as a JSON string. Each constructor's wire key is
+baked at splice time.
+-}
 toJSONEnum :: [ConInfo] -> Q Exp
 toJSONEnum cs = do
   v <- newName "v"
@@ -137,17 +153,20 @@
   body <- caseE (varE v) (map pure matches)
   lamE [varP v] (pure body)
 
+
 enumToJSONMatch :: ConInfo -> Q Match
 enumToJSONMatch c = do
   mi <- reifyModifierInfoFor backendJSON (conInfoName c)
   keyExp <- renderWireKey mi (T.pack (nameBase (conInfoName c)))
-  body <- [| toJSON ($(pure keyExp) :: T.Text) |]
+  body <- [|toJSON ($(pure keyExp) :: T.Text)|]
   pure (Match (ConP (conInfoName c) [] []) (NormalB body) [])
 
--- | Sum: tagged-object encoding with @tag@ + @contents@ fields. The
--- @contents@ payload is 'Aeson.Null' for nullary constructors, the
--- single field for unary constructors, and a JSON array for n-ary
--- constructors.
+
+{- | Sum: tagged-object encoding with @tag@ + @contents@ fields. The
+@contents@ payload is 'Aeson.Null' for nullary constructors, the
+single field for unary constructors, and a JSON array for n-ary
+constructors.
+-}
 toJSONSum :: [ConInfo] -> Q Exp
 toJSONSum cs = do
   v <- newName "v"
@@ -155,6 +174,7 @@
   body <- caseE (varE v) (map pure matches)
   lamE [varP v] (pure body)
 
+
 sumCtorToJSON :: ConInfo -> Q Match
 sumCtorToJSON c = do
   mi <- reifyModifierInfoFor backendJSON (conInfoName c)
@@ -162,171 +182,212 @@
   fieldNames <- mapM (\_ -> newName "f") (conInfoFields c)
   let pat = ConP (conInfoName c) [] (map VarP fieldNames)
   contentsE <- case fieldNames of
-    []   -> [| Aeson.Null |]
-    [n]  -> [| toJSON $(varE n) |]
-    ns   -> [| toJSON
-                 ($(pure (ListE (map (AppE (VarE 'toJSON) . VarE) ns)))
-                    :: [Aeson.Value]) |]
-  body <- [| Aeson.object
-              [ Aeson.Key.fromText (T.pack "tag")      .= ($(pure keyExp)   :: T.Text)
-              , Aeson.Key.fromText (T.pack "contents") .= ($(pure contentsE) :: Aeson.Value)
-              ] |]
+    [] -> [|Aeson.Null|]
+    [n] -> [|toJSON $(varE n)|]
+    ns ->
+      [|
+        toJSON
+          ( $(pure (ListE (map (AppE (VarE 'toJSON) . VarE) ns)))
+              :: [Aeson.Value]
+          )
+        |]
+  body <-
+    [|
+      Aeson.object
+        [ Aeson.Key.fromText (T.pack "tag") .= ($(pure keyExp) :: T.Text)
+        , Aeson.Key.fromText (T.pack "contents") .= ($(pure contentsE) :: Aeson.Value)
+        ]
+      |]
   pure (Match pat (NormalB body) [])
 
+
 -- ---------------------------------------------------------------------------
 -- FromJSON dispatch
 -- ---------------------------------------------------------------------------
 
 fromJSONBody :: TypeInfo -> Q Exp
 fromJSONBody ti = case typeInfoShape ti of
-  TypeShapeNewtype c   -> fromJSONNewtype c
-  TypeShapeRecord  c   -> fromJSONRecord (nameBase (typeInfoName ti)) c
-  TypeShapeEnum    cs  -> fromJSONEnum cs
-  TypeShapeSum     cs  -> fromJSONSum  cs
+  TypeShapeNewtype c -> fromJSONNewtype c
+  TypeShapeRecord c -> fromJSONRecord (nameBase (typeInfoName ti)) c
+  TypeShapeEnum cs -> fromJSONEnum cs
+  TypeShapeSum cs -> fromJSONSum cs
 
+
 fromJSONNewtype :: ConInfo -> Q Exp
 fromJSONNewtype c = case conInfoFields c of
-  [FieldInfo _ _] -> [| fmap $(conE (conInfoName c)) . parseJSON |]
-  _               -> fail "Wireform.Derive.Aeson: newtype must have exactly one field"
+  [FieldInfo _ _] -> [|fmap $(conE (conInfoName c)) . parseJSON|]
+  _ -> fail "Wireform.Derive.Aeson: newtype must have exactly one field"
 
+
 fromJSONRecord :: String -> ConInfo -> Q Exp
 fromJSONRecord typeName c = do
   obj <- newName "o"
   parserExp <- recordParser obj c
-  [| Aeson.withObject typeName (\ $(varP obj) -> $(pure parserExp)) |]
+  [|Aeson.withObject typeName (\ $(varP obj) -> $(pure parserExp))|]
 
+
 recordParser :: Name -> ConInfo -> Q Exp
 recordParser obj c = do
   let conName = conInfoName c
-      fields  = conInfoFields c
+      fields = conInfoFields c
   case fields of
-    []        -> [| pure $(conE conName) |]
+    [] -> [|pure $(conE conName)|]
     (f0 : fs) -> do
       e0 <- fieldParser obj f0
-      hd <- [| $(conE conName) <$> $(pure e0) |]
+      hd <- [|$(conE conName) <$> $(pure e0)|]
       foldlM
-        (\acc f -> do
+        ( \acc f -> do
             ef <- fieldParser obj f
-            [| $(pure acc) <*> $(pure ef) |])
+            [|$(pure acc) <*> $(pure ef)|]
+        )
         hd
         fs
 
+
 fieldParser :: Name -> FieldInfo -> Q Exp
 fieldParser obj (FieldInfo mSel _) = do
   selName <- requireSelector mSel
   mi <- reifyModifierInfoFor backendJSON selName
   if miSkip mi
     then case miDefaults mi of
-      Just defNm -> [| pure $(varE defNm) |]
-      Nothing    -> [| pure (error "Wireform.Derive.Aeson: missing 'defaults' for skipped field") |]
+      Just defNm -> [|pure $(varE defNm)|]
+      Nothing -> [|pure (error "Wireform.Derive.Aeson: missing 'defaults' for skipped field")|]
     else do
       let selBase = T.pack (nameBase selName)
       keyExp <- renderWireKey mi selBase
       let isOptional = miRequired mi == Just False
       base <-
         if isOptional
-          then [| $(varE obj) .:? Aeson.Key.fromText $(pure keyExp) |]
-          else [| $(varE obj) .:  Aeson.Key.fromText $(pure keyExp) |]
+          then [|$(varE obj) .:? Aeson.Key.fromText $(pure keyExp)|]
+          else [|$(varE obj) .: Aeson.Key.fromText $(pure keyExp)|]
       case miCoerce mi of
         Nothing -> pure base
-        Just _  -> [| fmap coerce $(pure base) |]
+        Just _ -> [|fmap coerce $(pure base)|]
 
--- | Enum: dispatch on the JSON string against each constructor's
--- rendered key. Built as a `MultiWayIf` so that runtime-rendered keys
--- (from 'renameWith') participate naturally.
+
+{- | Enum: dispatch on the JSON string against each constructor's
+rendered key. Built as a `MultiWayIf` so that runtime-rendered keys
+(from 'renameWith') participate naturally.
+-}
 fromJSONEnum :: [ConInfo] -> Q Exp
 fromJSONEnum cs = do
   s <- newName "s"
   branches <- mapM (enumDispatch s) cs
   let fallback =
         ( NormalG (ConE 'True)
-        , AppE (VarE 'fail)
-            (InfixE
-              (Just (LitE (StringL "Wireform.Derive.Aeson: unknown enum value ")))
-              (VarE '(++))
-              (Just (AppE (VarE 'show) (VarE s))))
+        , AppE
+            (VarE 'fail)
+            ( InfixE
+                (Just (LitE (StringL "Wireform.Derive.Aeson: unknown enum value ")))
+                (VarE '(++))
+                (Just (AppE (VarE 'show) (VarE s)))
+            )
         )
   let multi = MultiIfE (branches ++ [fallback])
-  [| Aeson.withText "enum" (\ $(varP s) -> $(pure multi)) |]
+  [|Aeson.withText "enum" (\ $(varP s) -> $(pure multi))|]
 
+
 enumDispatch :: Name -> ConInfo -> Q (Guard, Exp)
 enumDispatch sVar c = do
   mi <- reifyModifierInfoFor backendJSON (conInfoName c)
   keyExp <- renderWireKey mi (T.pack (nameBase (conInfoName c)))
   let guardExp = InfixE (Just (VarE sVar)) (VarE '(==)) (Just keyExp)
-      bodyExp  = AppE (VarE 'pure) (ConE (conInfoName c))
+      bodyExp = AppE (VarE 'pure) (ConE (conInfoName c))
   pure (NormalG guardExp, bodyExp)
 
--- | Sum: read @tag@ + @contents@ from the object, then dispatch via
--- a `MultiWayIf` on the rendered tag key for each constructor.
+
+{- | Sum: read @tag@ + @contents@ from the object, then dispatch via
+a `MultiWayIf` on the rendered tag key for each constructor.
+-}
 fromJSONSum :: [ConInfo] -> Q Exp
 fromJSONSum cs = do
-  obj    <- newName "o"
+  obj <- newName "o"
   tagVar <- newName "tag"
-  cVar   <- newName "c"
+  cVar <- newName "c"
   branches <- mapM (sumDispatch tagVar cVar) cs
   let fallback =
         ( NormalG (ConE 'True)
-        , AppE (VarE 'fail)
-            (InfixE
-              (Just (LitE (StringL "Wireform.Derive.Aeson: unknown sum tag ")))
-              (VarE '(++))
-              (Just (AppE (VarE 'show) (VarE tagVar))))
+        , AppE
+            (VarE 'fail)
+            ( InfixE
+                (Just (LitE (StringL "Wireform.Derive.Aeson: unknown sum tag ")))
+                (VarE '(++))
+                (Just (AppE (VarE 'show) (VarE tagVar)))
+            )
         )
       multi = MultiIfE (branches ++ [fallback])
-  [| Aeson.withObject "sum" (\ $(varP obj) -> do
-        ($(varP tagVar) :: T.Text) <-
+  [|
+    Aeson.withObject
+      "sum"
+      ( \ $(varP obj) -> do
+          ($(varP tagVar) :: T.Text) <-
             $(varE obj) .: Aeson.Key.fromText (T.pack "tag")
-        ($(varP cVar)   :: Aeson.Value) <-
+          ($(varP cVar) :: Aeson.Value) <-
             $(varE obj) .: Aeson.Key.fromText (T.pack "contents")
-        $(pure multi)) |]
+          $(pure multi)
+      )
+    |]
 
+
 sumDispatch :: Name -> Name -> ConInfo -> Q (Guard, Exp)
 sumDispatch tagVar cVar c = do
   mi <- reifyModifierInfoFor backendJSON (conInfoName c)
   keyExp <- renderWireKey mi (T.pack (nameBase (conInfoName c)))
   let guardExp = InfixE (Just (VarE tagVar)) (VarE '(==)) (Just keyExp)
   bodyExp <- case conInfoFields c of
-    []      -> [| pure $(conE (conInfoName c)) |]
-    [_one]  -> [| fmap $(conE (conInfoName c)) (parseJSON $(varE cVar)) |]
-    many    -> sumNAry cVar (conInfoName c) (length many)
+    [] -> [|pure $(conE (conInfoName c))|]
+    [_one] -> [|fmap $(conE (conInfoName c)) (parseJSON $(varE cVar))|]
+    many -> sumNAry cVar (conInfoName c) (length many)
   pure (NormalG guardExp, bodyExp)
 
--- | Build a parser for an n-ary sum constructor: parse @contents@ as
--- a JSON array, then apply each element through 'parseJSON' and
--- combine via @ConE c \<$> parse e0 \<*> parse e1 \<*> ...@.
+
+{- | Build a parser for an n-ary sum constructor: parse @contents@ as
+a JSON array, then apply each element through 'parseJSON' and
+combine via @ConE c \<$> parse e0 \<*> parse e1 \<*> ...@.
+-}
 sumNAry :: Name -> Name -> Int -> Q Exp
 sumNAry cVar conName arity = do
   arr <- newName "arr"
   let parseI :: Int -> Q Exp
-      parseI i = [| parseJSON ($(varE arr) !! $(litE (integerL (fromIntegral i)))) |]
+      parseI i = [|parseJSON ($(varE arr) !! $(litE (integerL (fromIntegral i))))|]
   hd <- do
     e0 <- parseI 0
-    [| $(conE conName) <$> $(pure e0) |]
-  tail' <- foldlM
-    (\acc i -> do
-        ei <- parseI i
-        [| $(pure acc) <*> $(pure ei) |])
-    hd
-    [1 .. arity - 1]
+    [|$(conE conName) <$> $(pure e0)|]
+  tail' <-
+    foldlM
+      ( \acc i -> do
+          ei <- parseI i
+          [|$(pure acc) <*> $(pure ei)|]
+      )
+      hd
+      [1 .. arity - 1]
   let conNameStr = nameBase conName
-  [| do
-       ($(varP arr) :: [Aeson.Value]) <- parseJSON $(varE cVar)
-       if length $(varE arr) /= $(litE (integerL (fromIntegral arity)))
-         then fail ("Wireform.Derive.Aeson: " ++ conNameStr
-                    ++ " expected "
-                    ++ show ($(litE (integerL (fromIntegral arity))) :: Int)
-                    ++ " contents, got " ++ show (length $(varE arr)))
-         else $(pure tail') |]
+  [|
+    do
+      ($(varP arr) :: [Aeson.Value]) <- parseJSON $(varE cVar)
+      if length $(varE arr) /= $(litE (integerL (fromIntegral arity)))
+        then
+          fail
+            ( "Wireform.Derive.Aeson: "
+                ++ conNameStr
+                ++ " expected "
+                ++ show ($(litE (integerL (fromIntegral arity))) :: Int)
+                ++ " contents, got "
+                ++ show (length $(varE arr))
+            )
+        else $(pure tail')
+    |]
 
+
 -- ---------------------------------------------------------------------------
 -- Helpers
 -- ---------------------------------------------------------------------------
 
 requireSelector :: Maybe Name -> Q Name
 requireSelector (Just n) = pure n
-requireSelector Nothing  =
+requireSelector Nothing =
   fail "Wireform.Derive.Aeson: cannot derive JSON for non-record positional field"
+
 
 applyTypeArgs :: Type -> [Type] -> Type
 applyTypeArgs = foldl AppT
diff --git a/src/Wireform/Derive/Backend.hs b/src/Wireform/Derive/Backend.hs
--- a/src/Wireform/Derive/Backend.hs
+++ b/src/Wireform/Derive/Backend.hs
@@ -1,53 +1,55 @@
--- | Identifier for a serialization backend.
---
--- A 'Backend' is a textual tag attached to per-backend modifier overrides
--- (e.g. @forBackend backendProto (rename "n")@) and consulted by each
--- per-format deriver to decide which modifiers apply.
---
--- New backends can be defined by downstream packages: just construct a
--- 'Backend' with a fresh identifier. The set of \"standard\" backends
--- defined here is open-ended and exists only as a convenience so that
--- common cases compose cleanly.
-module Wireform.Derive.Backend
-  ( Backend (..)
-    -- * Standard backends
-  , backendProto
-  , backendCBOR
-  , backendMsgPack
-  , backendThrift
-  , backendJSON
-  , backendEDN
-  , backendTOML
-  , backendYAML
-  , backendXML
-  , backendHTML
-  , backendCSV
-  , backendNDJSON
-  , backendBinary
-  , backendTextFormat
+{- | Identifier for a serialization backend.
 
-    -- * Schema-driven backends
-  , backendASN1
-  , backendAvro
-  , backendBond
-  , backendFlatBuffers
-  , backendCapnProto
+A 'Backend' is a textual tag attached to per-backend modifier overrides
+(e.g. @forBackend backendProto (rename "n")@) and consulted by each
+per-format deriver to decide which modifiers apply.
 
-    -- * Document / object stores
-  , backendBSON
-  , backendBencode
-  , backendION
+New backends can be defined by downstream packages: just construct a
+'Backend' with a fresh identifier. The set of \"standard\" backends
+defined here is open-ended and exists only as a convenience so that
+common cases compose cleanly.
+-}
+module Wireform.Derive.Backend (
+  Backend (..),
 
-    -- * Cross-language object serialization
-  , backendFory
+  -- * Standard backends
+  backendProto,
+  backendCBOR,
+  backendMsgPack,
+  backendThrift,
+  backendJSON,
+  backendEDN,
+  backendTOML,
+  backendYAML,
+  backendXML,
+  backendHTML,
+  backendCSV,
+  backendNDJSON,
+  backendBinary,
+  backendTextFormat,
 
-    -- * Columnar / tabular
-  , backendArrow
-  , backendParquet
-  , backendOrc
-  , backendIceberg
-  ) where
+  -- * Schema-driven backends
+  backendASN1,
+  backendAvro,
+  backendBond,
+  backendFlatBuffers,
+  backendCapnProto,
 
+  -- * Document / object stores
+  backendBSON,
+  backendBencode,
+  backendION,
+
+  -- * Cross-language object serialization
+  backendFory,
+
+  -- * Columnar / tabular
+  backendArrow,
+  backendParquet,
+  backendOrc,
+  backendIceberg,
+) where
+
 import Control.DeepSeq (NFData)
 import Data.Data (Data)
 import Data.Hashable (Hashable)
@@ -55,75 +57,94 @@
 import Data.Text (Text)
 import GHC.Generics (Generic)
 
--- | Open identifier for a serialization backend.
---
--- 'Backend' has an 'IsString' instance, so backends can be written
--- inline as string literals, e.g. @forBackend "proto" (rename "n")@.
--- Prefer the named constants below where they exist so that typos are
--- caught at compile time.
-newtype Backend = Backend { unBackend :: Text }
+
+{- | Open identifier for a serialization backend.
+
+'Backend' has an 'IsString' instance, so backends can be written
+inline as string literals, e.g. @forBackend "proto" (rename "n")@.
+Prefer the named constants below where they exist so that typos are
+caught at compile time.
+-}
+newtype Backend = Backend {unBackend :: Text}
   deriving stock (Eq, Ord, Show, Data, Generic)
   deriving newtype (IsString, Hashable, NFData)
 
+
 -- | Protocol Buffers wire encoding.
 backendProto :: Backend
 backendProto = Backend "proto"
 
+
 -- | RFC 8949 CBOR.
 backendCBOR :: Backend
 backendCBOR = Backend "cbor"
 
+
 -- | MessagePack.
 backendMsgPack :: Backend
 backendMsgPack = Backend "msgpack"
 
+
 -- | Apache Thrift binary protocol.
 backendThrift :: Backend
 backendThrift = Backend "thrift"
 
--- | JSON (used by both Aeson-style and Proto3 JSON mappings;
--- Proto-JSON-specific overrides should additionally tag with
--- 'backendProto').
+
+{- | JSON (used by both Aeson-style and Proto3 JSON mappings;
+Proto-JSON-specific overrides should additionally tag with
+'backendProto').
+-}
 backendJSON :: Backend
 backendJSON = Backend "json"
 
+
 -- | Extensible Data Notation (Clojure / EDN).
 backendEDN :: Backend
 backendEDN = Backend "edn"
 
+
 -- | TOML.
 backendTOML :: Backend
 backendTOML = Backend "toml"
 
+
 -- | YAML.
 backendYAML :: Backend
 backendYAML = Backend "yaml"
 
+
 -- | XML.
 backendXML :: Backend
 backendXML = Backend "xml"
 
+
 -- | CSV / TSV header rows.
 backendCSV :: Backend
 backendCSV = Backend "csv"
 
+
 -- | HTML serialization (sub-vocabulary of XML; element/attribute split).
 backendHTML :: Backend
 backendHTML = Backend "html"
 
+
 -- | Newline-delimited JSON. One record per line.
 backendNDJSON :: Backend
 backendNDJSON = Backend "ndjson"
 
--- | Catch-all for hand-rolled binary formats that do not fit one of the
--- standard backends above.
+
+{- | Catch-all for hand-rolled binary formats that do not fit one of the
+standard backends above.
+-}
 backendBinary :: Backend
 backendBinary = Backend "binary"
 
+
 -- | Protobuf text format (@google.protobuf.TextFormat@ / @pbtxt@).
 backendTextFormat :: Backend
 backendTextFormat = Backend "textformat"
 
+
 -- ---------------------------------------------------------------------------
 -- Schema-driven binary formats
 -- ---------------------------------------------------------------------------
@@ -132,22 +153,27 @@
 backendASN1 :: Backend
 backendASN1 = Backend "asn1"
 
+
 -- | Apache Avro.
 backendAvro :: Backend
 backendAvro = Backend "avro"
 
+
 -- | Microsoft Bond.
 backendBond :: Backend
 backendBond = Backend "bond"
 
+
 -- | FlatBuffers.
 backendFlatBuffers :: Backend
 backendFlatBuffers = Backend "flatbuffers"
 
+
 -- | Cap\'n Proto.
 backendCapnProto :: Backend
 backendCapnProto = Backend "capnproto"
 
+
 -- ---------------------------------------------------------------------------
 -- Document / object stores
 -- ---------------------------------------------------------------------------
@@ -156,24 +182,29 @@
 backendBSON :: Backend
 backendBSON = Backend "bson"
 
+
 -- | BitTorrent's Bencode.
 backendBencode :: Backend
 backendBencode = Backend "bencode"
 
+
 -- | Amazon ION (rich-typed JSON superset).
 backendION :: Backend
 backendION = Backend "ion"
 
+
 -- ---------------------------------------------------------------------------
 -- Cross-language object serialization
 -- ---------------------------------------------------------------------------
 
--- | Apache Fory (formerly Fury) xlang serialization. Field names
--- are converted to @snake_case@ before being written as the
--- spec\'s meta-string field name.
+{- | Apache Fory (formerly Fury) xlang serialization. Field names
+are converted to @snake_case@ before being written as the
+spec\'s meta-string field name.
+-}
 backendFory :: Backend
 backendFory = Backend "fory"
 
+
 -- ---------------------------------------------------------------------------
 -- Columnar / tabular
 -- ---------------------------------------------------------------------------
@@ -182,13 +213,16 @@
 backendArrow :: Backend
 backendArrow = Backend "arrow"
 
+
 -- | Apache Parquet.
 backendParquet :: Backend
 backendParquet = Backend "parquet"
 
+
 -- | Apache ORC.
 backendOrc :: Backend
 backendOrc = Backend "orc"
+
 
 -- | Apache Iceberg table format.
 backendIceberg :: Backend
diff --git a/src/Wireform/Derive/Extension.hs b/src/Wireform/Derive/Extension.hs
--- a/src/Wireform/Derive/Extension.hs
+++ b/src/Wireform/Derive/Extension.hs
@@ -2,131 +2,141 @@
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
 
--- | Typed, per-backend modifier extensions.
---
--- This module is the answer to the question: \"how does a brand-new
--- backend define its own annotation vocabulary without modifying the
--- core 'Modifier' ADT?\"
---
--- The core ADT exposes one open slot — 'Wireform.Derive.Modifier.ModCustom' —
--- a @('Text', 'ByteString')@ pair where the 'Text' identifies the
--- vocabulary and the 'ByteString' is an opaque payload. Backends were
--- always free to use it directly, but the ergonomics were poor: every
--- backend had to invent its own serialization story.
---
--- The 'BackendModifier' typeclass standardises that story:
---
--- 1. The backend declares its modifier type with derived 'Show' /
---    'Read' instances and provides a stable 'backendModifierTag'.
--- 2. Users embed the typed value into an annotation via 'extension'.
--- 3. The backend's TH deriver pulls the typed value back out via
---    'lookupExtension' or 'lookupExtensions'.
---
--- Serialization uses @show@\/@read@ so we incur no extra package
--- dependencies. The payload only ever lives in @.hi@ files at
--- compile time, so the verbosity is a non-issue.
---
--- == Example: an Iceberg-only modifier
---
--- @
--- module Iceberg.Derive.Modifiers where
---
--- import Wireform.Derive.Extension
--- import Wireform.Derive.Modifier (Modifier)
---
--- data IcebergFieldOpt
---   = PartitionColumn
---   | OptimisticTransform !Text
---   deriving stock (Eq, Show, Read, Typeable)
---
--- instance BackendModifier IcebergFieldOpt where
---   backendModifierTag _ = "wireform-iceberg.field-opt"
---
--- partition :: Modifier
--- partition = extension PartitionColumn
--- @
---
--- and the deriver does:
---
--- @
--- mi <- reifyModifierInfoFor backendIceberg fieldName
--- case lookupExtension @IcebergFieldOpt mi of
---   Just PartitionColumn        -> ...
---   Just (OptimisticTransform t)-> ...
---   Nothing                     -> defaultBehaviour
--- @
-module Wireform.Derive.Extension
-  ( -- * Typed payloads
-    BackendModifier (..)
+{- | Typed, per-backend modifier extensions.
 
-    -- * Embedding
-  , extension
+This module is the answer to the question: \"how does a brand-new
+backend define its own annotation vocabulary without modifying the
+core 'Modifier' ADT?\"
 
-    -- * Reading back
-  , lookupExtension
-  , lookupExtensions
-  , hasExtension
-  ) where
+The core ADT exposes one open slot — 'Wireform.Derive.Modifier.ModCustom' —
+a @('Text', 'ByteString')@ pair where the 'Text' identifies the
+vocabulary and the 'ByteString' is an opaque payload. Backends were
+always free to use it directly, but the ergonomics were poor: every
+backend had to invent its own serialization story.
 
-import qualified Data.Map.Strict as Map
+The 'BackendModifier' typeclass standardises that story:
+
+1. The backend declares its modifier type with derived 'Show' /
+   'Read' instances and provides a stable 'backendModifierTag'.
+2. Users embed the typed value into an annotation via 'extension'.
+3. The backend's TH deriver pulls the typed value back out via
+   'lookupExtension' or 'lookupExtensions'.
+
+Serialization uses @show@\/@read@ so we incur no extra package
+dependencies. The payload only ever lives in @.hi@ files at
+compile time, so the verbosity is a non-issue.
+
+== Example: an Iceberg-only modifier
+
+@
+module Iceberg.Derive.Modifiers where
+
+import Wireform.Derive.Extension
+import Wireform.Derive.Modifier (Modifier)
+
+data IcebergFieldOpt
+  = PartitionColumn
+  | OptimisticTransform !Text
+  deriving stock (Eq, Show, Read, Typeable)
+
+instance BackendModifier IcebergFieldOpt where
+  backendModifierTag _ = "wireform-iceberg.field-opt"
+
+partition :: Modifier
+partition = extension PartitionColumn
+@
+
+and the deriver does:
+
+@
+mi <- reifyModifierInfoFor backendIceberg fieldName
+case lookupExtension @IcebergFieldOpt mi of
+  Just PartitionColumn        -> ...
+  Just (OptimisticTransform t)-> ...
+  Nothing                     -> defaultBehaviour
+@
+-}
+module Wireform.Derive.Extension (
+  -- * Typed payloads
+  BackendModifier (..),
+
+  -- * Embedding
+  extension,
+
+  -- * Reading back
+  lookupExtension,
+  lookupExtensions,
+  hasExtension,
+) where
+
+import Data.Map.Strict qualified as Map
 import Data.Maybe (mapMaybe)
 import Data.Proxy (Proxy (..))
 import Data.Text (Text)
 import Data.Typeable (Typeable)
 import Text.Read (readMaybe)
-
 import Wireform.Derive.Modifier (Modifier (..), customModifier)
 import Wireform.Derive.ModifierInfo (ModifierInfo (..))
 
--- | Types that may be embedded into a 'Modifier' as a backend-specific
--- extension payload.
---
--- The 'Show' representation is what gets persisted into the @.hi@
--- file via the underlying @ANN@ pragma; the 'Read' instance reverses
--- it at splice time. 'Typeable' is required so derivers can assert
--- the expected vocabulary on extraction.
---
--- The 'backendModifierTag' is a globally-unique key for the
--- extension. Convention: @\"wireform-\<backend\>.\<concept\>\"@ so
--- two backends that happen to use the same concept name (e.g.
--- @\"partition\"@) do not collide.
+
+{- | Types that may be embedded into a 'Modifier' as a backend-specific
+extension payload.
+
+The 'Show' representation is what gets persisted into the @.hi@
+file via the underlying @ANN@ pragma; the 'Read' instance reverses
+it at splice time. 'Typeable' is required so derivers can assert
+the expected vocabulary on extraction.
+
+The 'backendModifierTag' is a globally-unique key for the
+extension. Convention: @\"wireform-\<backend\>.\<concept\>\"@ so
+two backends that happen to use the same concept name (e.g.
+@\"partition\"@) do not collide.
+-}
 class (Eq a, Show a, Read a, Typeable a) => BackendModifier a where
   backendModifierTag :: Proxy a -> Text
 
--- | Lift a typed value into a 'Modifier'. The result can be attached
--- via an @ANN@ pragma like any other modifier.
---
--- @
--- {-\# ANN myField (extension PartitionColumn) \#-}
--- @
+
+{- | Lift a typed value into a 'Modifier'. The result can be attached
+via an @ANN@ pragma like any other modifier.
+
+@
+{\-\# ANN myField (extension PartitionColumn) \#-\}
+@
+-}
 extension :: forall a. BackendModifier a => a -> Modifier
 extension a =
   customModifier
     (backendModifierTag (Proxy @a))
     (show a)
 
--- | Read back the first 'BackendModifier' of the requested type
--- attached to a name. Returns 'Nothing' if no annotation tagged for
--- this type was present, or if the payload failed to parse.
---
--- The latter case (parse failure) most often indicates that the
--- backend's modifier vocabulary changed in an incompatible way; in
--- that case derivers may want to call 'lookupExtensions' instead and
--- decide their own policy.
+
+{- | Read back the first 'BackendModifier' of the requested type
+attached to a name. Returns 'Nothing' if no annotation tagged for
+this type was present, or if the payload failed to parse.
+
+The latter case (parse failure) most often indicates that the
+backend's modifier vocabulary changed in an incompatible way; in
+that case derivers may want to call 'lookupExtensions' instead and
+decide their own policy.
+-}
 lookupExtension
-  :: forall a. BackendModifier a
+  :: forall a
+   . BackendModifier a
   => ModifierInfo
   -> Maybe a
 lookupExtension mi =
   case lookupExtensions @a mi of
     (a : _) -> Just a
-    []      -> Nothing
+    [] -> Nothing
 
--- | Like 'lookupExtension' but returns every successfully-decoded
--- value of the requested type. Order matches the order in which @ANN@
--- pragmas were processed.
+
+{- | Like 'lookupExtension' but returns every successfully-decoded
+value of the requested type. Order matches the order in which @ANN@
+pragmas were processed.
+-}
 lookupExtensions
-  :: forall a. BackendModifier a
+  :: forall a
+   . BackendModifier a
   => ModifierInfo
   -> [a]
 lookupExtensions mi =
@@ -140,16 +150,19 @@
     -- declaration order so per-call iteration matches user intent.
     decodeOne :: Modifier -> Maybe a
     decodeOne (ModCustom _ s) = readMaybe s
-    decodeOne _               = Nothing
+    decodeOne _ = Nothing
 
--- | True iff at least one 'BackendModifier' of the requested type is
--- attached to the name.
+
+{- | True iff at least one 'BackendModifier' of the requested type is
+attached to the name.
+-}
 hasExtension
-  :: forall a. BackendModifier a
+  :: forall a
+   . BackendModifier a
   => ModifierInfo
   -> Bool
 hasExtension mi =
   let key = backendModifierTag (Proxy @a)
   in case Map.lookup key (miCustom mi) of
        Nothing -> False
-       Just _  -> True
+       Just _ -> True
diff --git a/src/Wireform/Derive/Modifier.hs b/src/Wireform/Derive/Modifier.hs
--- a/src/Wireform/Derive/Modifier.hs
+++ b/src/Wireform/Derive/Modifier.hs
@@ -81,12 +81,14 @@
 data Rename
   = -- | Use this exact 'Text' as the wire key.
     RenameTo !Text
-  | -- | Apply a 'NameStyle' transformation. Evaluated entirely at
-    -- splice time, so the result is baked into the generated code as
-    -- a literal 'Text'.
+  | {- | Apply a 'NameStyle' transformation. Evaluated entirely at
+    splice time, so the result is baked into the generated code as
+    a literal 'Text'.
+    -}
     RenameStyle !NameStyle
-  | -- | Apply the named @Text -> Text@ function at runtime. Use only
-    -- when 'NameStyle' cannot express the desired transformation.
+  | {- | Apply the named @Text -> Text@ function at runtime. Use only
+    when 'NameStyle' cannot express the desired transformation.
+    -}
     RenameFn !Name
   deriving stock (Eq, Ord, Show, Data, Generic)
 
@@ -97,19 +99,23 @@
 
 -- | Force a non-default wire encoding for a numeric or string field.
 data WireOverride
-  = -- | Use ZigZag encoding for signed integers (proto's @sint32@ /
-    -- @sint64@).
+  = {- | Use ZigZag encoding for signed integers (proto's @sint32@ /
+    @sint64@).
+    -}
     WireZigZag
-  | -- | Use a fixed-width little-endian encoding (proto's @fixed32@
-    -- / @fixed64@ / @sfixed32@ / @sfixed64@).
+  | {- | Use a fixed-width little-endian encoding (proto's @fixed32@
+    / @fixed64@ / @sfixed32@ / @sfixed64@).
+    -}
     WireFixed
   | -- | Pack a repeated scalar field (proto3 default for scalars).
     WirePacked
-  | -- | Encode this field as a UTF-8 'Text' regardless of the inferred
-    -- representation.
+  | {- | Encode this field as a UTF-8 'Text' regardless of the inferred
+    representation.
+    -}
     WireString
-  | -- | Encode this field as raw bytes regardless of the inferred
-    -- representation.
+  | {- | Encode this field as raw bytes regardless of the inferred
+    representation.
+    -}
     WireBytes
   deriving stock (Eq, Ord, Show, Data, Generic)
 
@@ -154,20 +160,25 @@
 data Modifier
   = -- | Rename a field on the wire.
     ModRename !Rename
-  | -- | Encode / decode the field via the named @newtype@-style
-    -- coercion target.
+  | {- | Encode / decode the field via the named @newtype@-style
+    coercion target.
+    -}
     ModCoerce !Name
-  | -- | Inline the contents of this nested record into its parent's
-    -- field set, rather than nesting it as a sub-message.
+  | {- | Inline the contents of this nested record into its parent's
+    field set, rather than nesting it as a sub-message.
+    -}
     ModFlatten
-  | -- | Omit this field from the wire encoding entirely (decoders
-    -- supply 'mempty' / @def@).
+  | {- | Omit this field from the wire encoding entirely (decoders
+    supply 'mempty' / @def@).
+    -}
     ModSkip
-  | -- | Use the named function as the field's default when decoding
-    -- a missing value.
+  | {- | Use the named function as the field's default when decoding
+    a missing value.
+    -}
     ModDefaults !Name
-  | -- | Manually fix this field's numeric tag / id (proto field
-    -- number, Thrift field id).
+  | {- | Manually fix this field's numeric tag / id (proto field
+    number, Thrift field id).
+    -}
     ModTag !Int
   | -- | Mark a field required (Thrift / proto2 semantics).
     ModRequired
@@ -177,32 +188,37 @@
     ModWireOverride !WireOverride
   | -- | Apply the inner modifiers /only/ for the listed backends.
     ModForBackends ![Backend] ![Modifier]
-  | -- | Apply this single modifier /only/ for the listed backends.
-    -- Equivalent to @ModForBackends bs [m]@; provided for cheaper
-    -- pretty-printing and tighter @ANN@ payloads in common cases.
+  | {- | Apply this single modifier /only/ for the listed backends.
+    Equivalent to @ModForBackends bs [m]@; provided for cheaper
+    pretty-printing and tighter @ANN@ payloads in common cases.
+    -}
     ModBackendOnly ![Backend] !Modifier
-  | -- | Skip this name entirely for the listed backends. Equivalent to
-    -- @ModForBackends bs [ModSkip]@; provided as its own constructor
-    -- so per-format derivers can short-circuit cheaply.
+  | {- | Skip this name entirely for the listed backends. Equivalent to
+    @ModForBackends bs [ModSkip]@; provided as its own constructor
+    so per-format derivers can short-circuit cheaply.
+    -}
     ModBackendDisable ![Backend]
-  | -- | Backend-specific opaque payload. Tagged by an arbitrary
-    -- 'Text' identifier so backends can recognise their own; the
-    -- payload itself is a 'String' rather than a 'ByteString'
-    -- because GHC's 'ANN' machinery serialises the modifier via
-    -- @Data.Data@, which works for any algebraic type but fails on
-    -- the sealed 'ByteString' instance.
+  | {- | Backend-specific opaque payload. Tagged by an arbitrary
+    'Text' identifier so backends can recognise their own; the
+    payload itself is a 'String' rather than a 'ByteString'
+    because GHC's 'ANN' machinery serialises the modifier via
+    @Data.Data@, which works for any algebraic type but fails on
+    the sealed 'ByteString' instance.
+    -}
     ModCustom !Text !String
-  | -- | This @HashMap@ / @Map@ field is encoded as a proto3 @map@.
-    -- The 'MapKeyScalar' picks the wire encoding for the key half.
-    -- Value type is inferred from the field's Haskell type.
-    --
-    -- Only consulted by the proto deriver; other backends ignore it.
+  | {- | This @HashMap@ / @Map@ field is encoded as a proto3 @map@.
+    The 'MapKeyScalar' picks the wire encoding for the key half.
+    Value type is inferred from the field's Haskell type.
+
+    Only consulted by the proto deriver; other backends ignore it.
+    -}
     ModMapKey !MapKeyScalar
-  | -- | Group this constructor (in a sum) or this field (in a
-    -- record) into the named proto @oneof@. All fields sharing the
-    -- same group name encode under one @oneof@ block.
-    --
-    -- Only consulted by the proto deriver; other backends ignore it.
+  | {- | Group this constructor (in a sum) or this field (in a
+    record) into the named proto @oneof@. All fields sharing the
+    same group name encode under one @oneof@ block.
+
+    Only consulted by the proto deriver; other backends ignore it.
+    -}
     ModOneof !Text
   deriving stock (Eq, Ord, Show, Data, Generic)
 
diff --git a/src/Wireform/Derive/ModifierInfo.hs b/src/Wireform/Derive/ModifierInfo.hs
--- a/src/Wireform/Derive/ModifierInfo.hs
+++ b/src/Wireform/Derive/ModifierInfo.hs
@@ -1,151 +1,166 @@
 {-# LANGUAGE TemplateHaskell #-}
 
--- | Backend-aware folding of @[Modifier]@ annotations into a typed
--- 'ModifierInfo' record.
---
--- A 'ModifierInfo' is the input each per-format deriver consumes. It
--- has been resolved against a single 'Backend' so the deriver does
--- not need to think about per-backend overrides itself.
---
--- == Resolution strategy
---
--- Modifiers are folded in two passes:
---
--- 1. /Global pass/ — every modifier that is not 'modifierIsBackendScoped'
---    is folded in. Conflicts within the global pass raise a
---    'ModifierError' (e.g. two distinct global renames).
--- 2. /Backend pass/ — modifiers wrapped in 'ModForBackends' /
---    'ModBackendOnly' / 'ModBackendDisable' that target the active
---    'Backend' are then folded in /on top of/ the global result. This
---    pass shadows global directives without raising a conflict, which
---    is the whole point of per-backend overrides.
-module Wireform.Derive.ModifierInfo
-  ( -- * Folded representation
-    ModifierInfo (..)
-  , RenameSpec (..)
-  , emptyModifierInfo
+{- | Backend-aware folding of @[Modifier]@ annotations into a typed
+'ModifierInfo' record.
 
-    -- * Resolution
-  , foldModifiers
-  , reifyModifierInfoFor
-  , reifyModifierInfo
+A 'ModifierInfo' is the input each per-format deriver consumes. It
+has been resolved against a single 'Backend' so the deriver does
+not need to think about per-backend overrides itself.
 
-    -- * Errors
-  , ModifierError (..)
+== Resolution strategy
 
-    -- * Wire-key rendering
-  , renderRenameKey
-  , renderWireKey
+Modifiers are folded in two passes:
 
-    -- * Helpers
-  , defaultRenameForBackend
-  ) where
+1. /Global pass/ — every modifier that is not 'modifierIsBackendScoped'
+   is folded in. Conflicts within the global pass raise a
+   'ModifierError' (e.g. two distinct global renames).
+2. /Backend pass/ — modifiers wrapped in 'ModForBackends' /
+   'ModBackendOnly' / 'ModBackendDisable' that target the active
+   'Backend' are then folded in /on top of/ the global result. This
+   pass shadows global directives without raising a conflict, which
+   is the whole point of per-backend overrides.
+-}
+module Wireform.Derive.ModifierInfo (
+  -- * Folded representation
+  ModifierInfo (..),
+  RenameSpec (..),
+  emptyModifierInfo,
 
+  -- * Resolution
+  foldModifiers,
+  reifyModifierInfoFor,
+  reifyModifierInfo,
+
+  -- * Errors
+  ModifierError (..),
+
+  -- * Wire-key rendering
+  renderRenameKey,
+  renderWireKey,
+
+  -- * Helpers
+  defaultRenameForBackend,
+) where
+
 import Control.Exception (Exception)
-import qualified Data.Map.Strict as Map
 import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
 import Data.Text (Text)
-import qualified Data.Text as T
+import Data.Text qualified as T
 import Language.Haskell.TH (Exp, Name, Q, varE)
 import Language.Haskell.TH.Syntax (Lift (lift), Quasi, qReifyAnnotations)
-import qualified Language.Haskell.TH.Syntax as TH
-
+import Language.Haskell.TH.Syntax qualified as TH
 import Wireform.Derive.Backend
 import Wireform.Derive.Modifier
 import Wireform.Derive.NameStyle
 
+
 -- ---------------------------------------------------------------------------
 -- Folded ModifierInfo
 -- ---------------------------------------------------------------------------
 
--- | A field's resolved rename spec.
---
--- * 'RenameSpecLiteral' — a fully-baked 'Text' (from 'RenameTo'). Used
---   verbatim as the wire key.
--- * 'RenameSpecStyle' — a 'NameStyle' to apply to the field's
---   selector base name at render time (so 'Idiomatic' can be resolved
---   against the deriver's active backend).
--- * 'RenameSpecApply' — a @Text -> Text@ function to call at /runtime/
---   on the selector base name.
+{- | A field's resolved rename spec.
+
+* 'RenameSpecLiteral' — a fully-baked 'Text' (from 'RenameTo'). Used
+  verbatim as the wire key.
+* 'RenameSpecStyle' — a 'NameStyle' to apply to the field's
+  selector base name at render time (so 'Idiomatic' can be resolved
+  against the deriver's active backend).
+* 'RenameSpecApply' — a @Text -> Text@ function to call at /runtime/
+  on the selector base name.
+-}
 data RenameSpec
   = RenameSpecLiteral !Text
-  | RenameSpecStyle   !NameStyle
-  | RenameSpecApply   !Name
+  | RenameSpecStyle !NameStyle
+  | RenameSpecApply !Name
   deriving (Eq, Show)
 
--- | A fully resolved view of the modifiers attached to a single
--- 'Name', filtered for one active 'Backend'.
+
+{- | A fully resolved view of the modifiers attached to a single
+'Name', filtered for one active 'Backend'.
+-}
 data ModifierInfo = ModifierInfo
-  { miBackend       :: !Backend
-    -- ^ The backend this 'ModifierInfo' was resolved for.
-  , miRename        :: !(Maybe RenameSpec)
-    -- ^ The wire key, if explicitly customised. When 'Nothing' the
-    -- deriver should fall back to its default policy (typically
-    -- 'defaultRenameForBackend').
-  , miCoerce        :: !(Maybe Name)
-  , miFlatten       :: !Bool
-  , miSkip          :: !Bool
-  , miDefaults      :: !(Maybe Name)
-  , miTag           :: !(Maybe Int)
-  , miRequired      :: !(Maybe Bool)
-    -- ^ 'Just True' = required; 'Just False' = optional; 'Nothing' =
-    -- format default.
-  , miWireOverride  :: !(Maybe WireOverride)
-  , miMapKey        :: !(Maybe MapKeyScalar)
-    -- ^ Proto map key scalar (proto deriver only).
-  , miOneof         :: !(Maybe Text)
-    -- ^ Name of the proto @oneof@ this field belongs to (proto
-    -- deriver only).
-  , miCustom        :: !(Map Text [Modifier])
-    -- ^ All 'ModCustom' payloads grouped by tag. Backends can scan
-    -- their own tag and ignore the rest.
-  } deriving (Eq, Show)
+  { miBackend :: !Backend
+  -- ^ The backend this 'ModifierInfo' was resolved for.
+  , miRename :: !(Maybe RenameSpec)
+  {- ^ The wire key, if explicitly customised. When 'Nothing' the
+  deriver should fall back to its default policy (typically
+  'defaultRenameForBackend').
+  -}
+  , miCoerce :: !(Maybe Name)
+  , miFlatten :: !Bool
+  , miSkip :: !Bool
+  , miDefaults :: !(Maybe Name)
+  , miTag :: !(Maybe Int)
+  , miRequired :: !(Maybe Bool)
+  {- ^ 'Just True' = required; 'Just False' = optional; 'Nothing' =
+  format default.
+  -}
+  , miWireOverride :: !(Maybe WireOverride)
+  , miMapKey :: !(Maybe MapKeyScalar)
+  -- ^ Proto map key scalar (proto deriver only).
+  , miOneof :: !(Maybe Text)
+  {- ^ Name of the proto @oneof@ this field belongs to (proto
+  deriver only).
+  -}
+  , miCustom :: !(Map Text [Modifier])
+  {- ^ All 'ModCustom' payloads grouped by tag. Backends can scan
+  their own tag and ignore the rest.
+  -}
+  }
+  deriving (Eq, Show)
 
+
 -- | The empty 'ModifierInfo' for a given backend.
 emptyModifierInfo :: Backend -> ModifierInfo
-emptyModifierInfo b = ModifierInfo
-  { miBackend       = b
-  , miRename        = Nothing
-  , miCoerce        = Nothing
-  , miFlatten       = False
-  , miSkip          = False
-  , miDefaults      = Nothing
-  , miTag           = Nothing
-  , miRequired      = Nothing
-  , miWireOverride  = Nothing
-  , miMapKey        = Nothing
-  , miOneof         = Nothing
-  , miCustom        = Map.empty
-  }
+emptyModifierInfo b =
+  ModifierInfo
+    { miBackend = b
+    , miRename = Nothing
+    , miCoerce = Nothing
+    , miFlatten = False
+    , miSkip = False
+    , miDefaults = Nothing
+    , miTag = Nothing
+    , miRequired = Nothing
+    , miWireOverride = Nothing
+    , miMapKey = Nothing
+    , miOneof = Nothing
+    , miCustom = Map.empty
+    }
 
+
 -- ---------------------------------------------------------------------------
 -- Errors
 -- ---------------------------------------------------------------------------
 
 -- | Conflicts surfaced during 'foldModifiers'.
 data ModifierError
-  = ConflictRename       !RenameSpec !RenameSpec
-  | ConflictCoerce       !Name !Name
-  | ConflictDefaults     !Name !Name
-  | ConflictTag          !Int !Int
-  | ConflictRequired     !Bool !Bool
+  = ConflictRename !RenameSpec !RenameSpec
+  | ConflictCoerce !Name !Name
+  | ConflictDefaults !Name !Name
+  | ConflictTag !Int !Int
+  | ConflictRequired !Bool !Bool
   | ConflictWireOverride !WireOverride !WireOverride
-  | ConflictMapKey       !MapKeyScalar !MapKeyScalar
-  | ConflictOneof        !Text !Text
-  | ConflictFlattenSkip
-    -- ^ Both 'flatten' and 'skip' set on the same name.
+  | ConflictMapKey !MapKeyScalar !MapKeyScalar
+  | ConflictOneof !Text !Text
+  | -- | Both 'flatten' and 'skip' set on the same name.
+    ConflictFlattenSkip
   | InvalidRenameFnArity !Name !Int
   deriving (Eq, Show)
 
+
 instance Exception ModifierError
 
+
 -- ---------------------------------------------------------------------------
 -- foldModifiers
 -- ---------------------------------------------------------------------------
 
--- | Resolve a list of modifiers against a backend. Conflicts within
--- the global pass yield 'Left'; per-backend overrides cleanly shadow
--- global directives.
+{- | Resolve a list of modifiers against a backend. Conflicts within
+the global pass yield 'Left'; per-backend overrides cleanly shadow
+global directives.
+-}
 foldModifiers :: Backend -> [Modifier] -> Either ModifierError ModifierInfo
 foldModifiers b ms = do
   let (globals, scoped) = partitionScope ms
@@ -154,26 +169,34 @@
   let active = expandScoped b scoped
   pure (foldShadow active globalInfo)
 
+
 -- ---------------------------------------------------------------------------
 -- reifyModifierInfo / reifyModifierInfoFor
 -- ---------------------------------------------------------------------------
 
--- | Reify all 'Modifier' annotations attached to a 'Name', resolved
--- for the given 'Backend'. Failure short-circuits the splice with
--- 'fail'.
+{- | Reify all 'Modifier' annotations attached to a 'Name', resolved
+for the given 'Backend'. Failure short-circuits the splice with
+'fail'.
+-}
 reifyModifierInfoFor :: Quasi m => Backend -> Name -> m ModifierInfo
 reifyModifierInfoFor b n = do
   (ms :: [Modifier]) <- qReifyAnnotations (TH.AnnLookupName n)
   case foldModifiers b ms of
     Left err ->
-      let msg = "Wireform.Derive.ModifierInfo: invalid modifier "
-             ++ "annotations on " ++ show n ++ ": " ++ show err
+      let msg =
+            "Wireform.Derive.ModifierInfo: invalid modifier "
+              ++ "annotations on "
+              ++ show n
+              ++ ": "
+              ++ show err
       in TH.qReport True msg >> fail msg
     Right mi -> pure mi
 
--- | Convenience: reify modifiers for every 'Backend' the deriver may
--- consult, returning a 'Map'. Useful when a single deriver wants to
--- emit instances for several formats from one annotation pass.
+
+{- | Convenience: reify modifiers for every 'Backend' the deriver may
+consult, returning a 'Map'. Useful when a single deriver wants to
+emit instances for several formats from one annotation pass.
+-}
 reifyModifierInfo
   :: Quasi m
   => [Backend]
@@ -183,69 +206,80 @@
   pairs <- mapM (\b -> (b,) <$> reifyModifierInfoFor b n) backends
   pure (Map.fromList pairs)
 
+
 -- ---------------------------------------------------------------------------
 -- Wire-key rendering
 -- ---------------------------------------------------------------------------
 
--- | Splice a 'RenameSpec' into a Haskell expression of type 'Text'.
---
--- 'RenameSpecLiteral' bakes a string literal. 'RenameSpecStyle'
--- resolves any 'Idiomatic' inside the style against the supplied
--- 'Backend' and bakes the post-style 'Text' as a literal — zero
--- runtime cost. 'RenameSpecApply' splices a runtime function call
--- against the selector base name.
+{- | Splice a 'RenameSpec' into a Haskell expression of type 'Text'.
+
+'RenameSpecLiteral' bakes a string literal. 'RenameSpecStyle'
+resolves any 'Idiomatic' inside the style against the supplied
+'Backend' and bakes the post-style 'Text' as a literal — zero
+runtime cost. 'RenameSpecApply' splices a runtime function call
+against the selector base name.
+-}
 renderRenameKey :: Backend -> RenameSpec -> Text -> Q Exp
 renderRenameKey b spec selBase = case spec of
-  RenameSpecLiteral t  -> lift t
-  RenameSpecStyle s    -> lift (applyStyle (resolveIdiomatic b s) selBase)
-  RenameSpecApply  fn  -> [| $(varE fn) (T.pack $(lift (T.unpack selBase))) |]
+  RenameSpecLiteral t -> lift t
+  RenameSpecStyle s -> lift (applyStyle (resolveIdiomatic b s) selBase)
+  RenameSpecApply fn -> [|$(varE fn) (T.pack $(lift (T.unpack selBase)))|]
 
--- | High-level wire-key renderer. If 'miRename' is set, defers to
--- 'renderRenameKey'; otherwise applies the backend's idiomatic style
--- (see 'idiomaticFor' / 'defaultRenameForBackend') to the selector
--- base name and bakes the result in as a literal.
+
+{- | High-level wire-key renderer. If 'miRename' is set, defers to
+'renderRenameKey'; otherwise applies the backend's idiomatic style
+(see 'idiomaticFor' / 'defaultRenameForBackend') to the selector
+base name and bakes the result in as a literal.
+-}
 renderWireKey :: ModifierInfo -> Text -> Q Exp
 renderWireKey mi selBase = case miRename mi of
   Just spec -> renderRenameKey (miBackend mi) spec selBase
-  Nothing   -> lift (defaultRenameForBackend (miBackend mi) selBase)
+  Nothing -> lift (defaultRenameForBackend (miBackend mi) selBase)
 
--- | The default wire key for a backend when no 'rename' modifier was
--- supplied: applies the backend's idiomatic style to the selector
--- base name.
+
+{- | The default wire key for a backend when no 'rename' modifier was
+supplied: applies the backend's idiomatic style to the selector
+base name.
+-}
 defaultRenameForBackend :: Backend -> Text -> Text
 defaultRenameForBackend b = applyStyle (resolveIdiomatic b (idiomaticFor b))
 
+
 -- ---------------------------------------------------------------------------
 -- Internals: scope partitioning
 -- ---------------------------------------------------------------------------
 
--- | Split modifiers into global (apply to every backend) vs.
--- backend-scoped.
+{- | Split modifiers into global (apply to every backend) vs.
+backend-scoped.
+-}
 partitionScope :: [Modifier] -> ([Modifier], [Modifier])
 partitionScope = foldr step ([], [])
   where
     step m (g, s)
       | modifierIsBackendScoped m = (g, m : s)
-      | otherwise                 = (m : g, s)
+      | otherwise = (m : g, s)
 
--- | Expand backend-scoped wrappers into a flat list of modifiers
--- that apply to the active backend.
+
+{- | Expand backend-scoped wrappers into a flat list of modifiers
+that apply to the active backend.
+-}
 expandScoped :: Backend -> [Modifier] -> [Modifier]
 expandScoped b = concatMap unwrap
   where
     unwrap (ModForBackends bs ms)
       | b `elem` bs = concatMap unwrap ms
-      | otherwise   = []
+      | otherwise = []
     unwrap (ModBackendOnly bs m)
       | b `elem` bs = unwrap m
-      | otherwise   = []
+      | otherwise = []
     unwrap (ModBackendDisable bs)
       | b `elem` bs = [ModSkip]
-      | otherwise   = []
+      | otherwise = []
     -- Nested non-scoped modifier — should not occur post-partition,
     -- but if it does, treat it as global for this backend.
     unwrap m = [m]
 
+
 -- ---------------------------------------------------------------------------
 -- Internals: folding
 -- ---------------------------------------------------------------------------
@@ -256,110 +290,106 @@
   -> ModifierInfo
   -> [Modifier]
   -> Either ModifierError ModifierInfo
-foldList _ acc []     = pure acc
-foldList b acc (m:ms) = do
+foldList _ acc [] = pure acc
+foldList b acc (m : ms) = do
   acc' <- mergeOne acc m
   foldList b acc' ms
 
--- | Strict left fold that silently shadows existing fields. Used for
--- per-backend overrides.
+
+{- | Strict left fold that silently shadows existing fields. Used for
+per-backend overrides.
+-}
 foldShadow :: [Modifier] -> ModifierInfo -> ModifierInfo
 foldShadow ms acc0 = foldr (flip shadowOne) acc0 (reverse ms)
 
--- | Merge a single modifier into a 'ModifierInfo', raising on
--- conflict.
+
+{- | Merge a single modifier into a 'ModifierInfo', raising on
+conflict.
+-}
 mergeOne :: ModifierInfo -> Modifier -> Either ModifierError ModifierInfo
 mergeOne mi = \case
   ModRename r ->
     let new = renameSpec r
     in case miRename mi of
          Just old | old /= new -> Left (ConflictRename old new)
-         _                     -> pure mi { miRename = Just new }
-
+         _ -> pure mi {miRename = Just new}
   ModCoerce n ->
     case miCoerce mi of
       Just old | old /= n -> Left (ConflictCoerce old n)
-      _                   -> pure mi { miCoerce = Just n }
-
+      _ -> pure mi {miCoerce = Just n}
   ModFlatten
     | miSkip mi -> Left ConflictFlattenSkip
-    | otherwise -> pure mi { miFlatten = True }
-
+    | otherwise -> pure mi {miFlatten = True}
   ModSkip
     | miFlatten mi -> Left ConflictFlattenSkip
-    | otherwise    -> pure mi { miSkip = True }
-
+    | otherwise -> pure mi {miSkip = True}
   ModDefaults n ->
     case miDefaults mi of
       Just old | old /= n -> Left (ConflictDefaults old n)
-      _                   -> pure mi { miDefaults = Just n }
-
+      _ -> pure mi {miDefaults = Just n}
   ModTag t ->
     case miTag mi of
       Just old | old /= t -> Left (ConflictTag old t)
-      _                   -> pure mi { miTag = Just t }
-
+      _ -> pure mi {miTag = Just t}
   ModRequired ->
     case miRequired mi of
       Just False -> Left (ConflictRequired False True)
-      _          -> pure mi { miRequired = Just True }
-
+      _ -> pure mi {miRequired = Just True}
   ModOptional ->
     case miRequired mi of
       Just True -> Left (ConflictRequired True False)
-      _         -> pure mi { miRequired = Just False }
-
+      _ -> pure mi {miRequired = Just False}
   ModWireOverride wo ->
     case miWireOverride mi of
       Just old | old /= wo -> Left (ConflictWireOverride old wo)
-      _                    -> pure mi { miWireOverride = Just wo }
-
+      _ -> pure mi {miWireOverride = Just wo}
   ModMapKey k ->
     case miMapKey mi of
       Just old | old /= k -> Left (ConflictMapKey old k)
-      _                   -> pure mi { miMapKey = Just k }
-
+      _ -> pure mi {miMapKey = Just k}
   ModOneof o ->
     case miOneof mi of
       Just old | old /= o -> Left (ConflictOneof old o)
-      _                   -> pure mi { miOneof = Just o }
-
+      _ -> pure mi {miOneof = Just o}
   m@(ModCustom tagName _) ->
-    pure mi { miCustom = Map.insertWith (++) tagName [m] (miCustom mi) }
-
+    pure mi {miCustom = Map.insertWith (++) tagName [m] (miCustom mi)}
   -- Backend-scoped wrappers should not appear in the global pass; if
   -- they do (e.g. through nesting), shadow rather than raise.
-  ModForBackends   _ _ -> pure mi
-  ModBackendOnly   _ _ -> pure mi
-  ModBackendDisable _  -> pure mi
+  ModForBackends _ _ -> pure mi
+  ModBackendOnly _ _ -> pure mi
+  ModBackendDisable _ -> pure mi
 
--- | Like 'mergeOne' but unconditionally overwrites; used for
--- per-backend overrides.
+
+{- | Like 'mergeOne' but unconditionally overwrites; used for
+per-backend overrides.
+-}
 shadowOne :: ModifierInfo -> Modifier -> ModifierInfo
 shadowOne mi = \case
-  ModRename r       -> mi { miRename       = Just (renameSpec r) }
-  ModCoerce n       -> mi { miCoerce       = Just n }
-  ModFlatten        -> mi { miFlatten      = True, miSkip = False }
-  ModSkip           -> mi { miSkip         = True, miFlatten = False }
-  ModDefaults n     -> mi { miDefaults     = Just n }
-  ModTag t          -> mi { miTag          = Just t }
-  ModRequired       -> mi { miRequired     = Just True }
-  ModOptional       -> mi { miRequired     = Just False }
-  ModWireOverride w -> mi { miWireOverride = Just w }
-  ModMapKey k       -> mi { miMapKey       = Just k }
-  ModOneof o        -> mi { miOneof        = Just o }
+  ModRename r -> mi {miRename = Just (renameSpec r)}
+  ModCoerce n -> mi {miCoerce = Just n}
+  ModFlatten -> mi {miFlatten = True, miSkip = False}
+  ModSkip -> mi {miSkip = True, miFlatten = False}
+  ModDefaults n -> mi {miDefaults = Just n}
+  ModTag t -> mi {miTag = Just t}
+  ModRequired -> mi {miRequired = Just True}
+  ModOptional -> mi {miRequired = Just False}
+  ModWireOverride w -> mi {miWireOverride = Just w}
+  ModMapKey k -> mi {miMapKey = Just k}
+  ModOneof o -> mi {miOneof = Just o}
   m@(ModCustom tagName _) ->
-    mi { miCustom = Map.insertWith (++) tagName [m] (miCustom mi) }
-  ModForBackends   _ _ -> mi
-  ModBackendOnly   _ _ -> mi
-  ModBackendDisable _  -> mi
+    mi {miCustom = Map.insertWith (++) tagName [m] (miCustom mi)}
+  ModForBackends _ _ -> mi
+  ModBackendOnly _ _ -> mi
+  ModBackendDisable _ -> mi
 
--- | Convert a 'Rename' modifier into a 'RenameSpec'. 'RenameStyle'
--- entries keep their 'NameStyle' verbatim so per-backend resolution
--- of 'Idiomatic' (and application against the selector base name)
--- can happen at render time in 'renderRenameKey'.
+
+{- | Convert a 'Rename' modifier into a 'RenameSpec'. 'RenameStyle'
+entries keep their 'NameStyle' verbatim so per-backend resolution
+of 'Idiomatic' (and application against the selector base name)
+can happen at render time in 'renderRenameKey'.
+-}
 renameSpec :: Rename -> RenameSpec
 renameSpec = \case
-  RenameTo t    -> RenameSpecLiteral t
+  RenameTo t -> RenameSpecLiteral t
   RenameStyle s -> RenameSpecStyle s
-  RenameFn n    -> RenameSpecApply n
+  RenameFn n -> RenameSpecApply n
diff --git a/src/Wireform/Derive/NameStyle.hs b/src/Wireform/Derive/NameStyle.hs
--- a/src/Wireform/Derive/NameStyle.hs
+++ b/src/Wireform/Derive/NameStyle.hs
@@ -1,49 +1,55 @@
--- | Rich DSL for transforming Haskell field selector names into
--- on-the-wire field names.
---
--- 'NameStyle' is intentionally a pure ADT with a 'Data' instance so that
--- it can survive splice-time annotation reflection: a 'NameStyle' value
--- attached via @ANN@ can be reified, fully evaluated at compile time,
--- and inlined into the generated encode/decode code as a literal 'Text'.
---
--- A handful of constructors (notably 'Idiomatic') refer to the active
--- backend rather than to a fixed transformation. These are resolved by
--- 'resolveIdiomatic' before 'applyStyle' is run.
-module Wireform.Derive.NameStyle
-  ( -- * The style DSL
-    NameStyle (..)
-  , andThen
-    -- * Application
-  , applyStyle
-    -- * Idiomatic resolution
-  , idiomaticFor
-  , resolveIdiomatic
-    -- * Building blocks (re-exported for testing)
-  , toSnakeCase
-  , toUpperSnake
-  , toKebabCase
-  , toUpperKebab
-  , toCamelCase
-  , toPascalCase
-  ) where
+{- | Rich DSL for transforming Haskell field selector names into
+on-the-wire field names.
 
+'NameStyle' is intentionally a pure ADT with a 'Data' instance so that
+it can survive splice-time annotation reflection: a 'NameStyle' value
+attached via @ANN@ can be reified, fully evaluated at compile time,
+and inlined into the generated encode/decode code as a literal 'Text'.
+
+A handful of constructors (notably 'Idiomatic') refer to the active
+backend rather than to a fixed transformation. These are resolved by
+'resolveIdiomatic' before 'applyStyle' is run.
+-}
+module Wireform.Derive.NameStyle (
+  -- * The style DSL
+  NameStyle (..),
+  andThen,
+
+  -- * Application
+  applyStyle,
+
+  -- * Idiomatic resolution
+  idiomaticFor,
+  resolveIdiomatic,
+
+  -- * Building blocks (re-exported for testing)
+  toSnakeCase,
+  toUpperSnake,
+  toKebabCase,
+  toUpperKebab,
+  toCamelCase,
+  toPascalCase,
+) where
+
 import Control.DeepSeq (NFData)
 import Data.Char (isLower, isUpper, toUpper)
 import Data.Data (Data)
 import Data.Hashable (Hashable)
 import Data.Text (Text)
-import qualified Data.Text as T
+import Data.Text qualified as T
 import GHC.Generics (Generic)
 import Language.Haskell.TH.Syntax (Lift)
-
 import Wireform.Derive.Backend
 
--- | A composable rename strategy. 'NameStyle' is a closed ADT so that
--- per-format derivers can interpret it at splice time and bake the
--- result into generated wire-key 'Text' literals (zero runtime cost).
+
+{- | A composable rename strategy. 'NameStyle' is a closed ADT so that
+per-format derivers can interpret it at splice time and bake the
+result into generated wire-key 'Text' literals (zero runtime cost).
+-}
 data NameStyle
-  = -- | @snake_case@. Boundaries inferred from camelCase / PascalCase
-    -- humps: @"personName"@ becomes @"person_name"@.
+  = {- | @snake_case@. Boundaries inferred from camelCase / PascalCase
+    humps: @"personName"@ becomes @"person_name"@.
+    -}
     SnakeCase
   | -- | @SCREAMING_SNAKE_CASE@.
     UpperSnake
@@ -51,8 +57,9 @@
     KebabCase
   | -- | @SCREAMING-KEBAB-CASE@.
     UpperKebab
-  | -- | @lowerCamelCase@. The first character is lowercased; subsequent
-    -- humps preserved.
+  | {- | @lowerCamelCase@. The first character is lowercased; subsequent
+    humps preserved.
+    -}
     CamelCase
   | -- | @UpperCamelCase@ / @PascalCase@.
     PascalCase
@@ -60,8 +67,9 @@
     LowerCase
   | -- | All uppercase.
     UpperCase
-  | -- | Strip a literal prefix if present; otherwise leave the input
-    -- unchanged.
+  | {- | Strip a literal prefix if present; otherwise leave the input
+    unchanged.
+    -}
     StripPrefix !Text
   | -- | Strip a literal suffix if present; otherwise unchanged.
     StripSuffix !Text
@@ -77,115 +85,129 @@
     Replace !Text !Text
   | -- | Replace only the first occurrence.
     ReplaceFirst !Text !Text
-  | -- | Sequential composition: @Compose a b@ first applies @a@, then
-    -- @b@.
+  | {- | Sequential composition: @Compose a b@ first applies @a@, then
+    @b@.
+    -}
     Compose !NameStyle !NameStyle
   | -- | Identity transformation.
     NoStyle
-  | -- | Apply the active backend's idiomatic naming convention. This
-    -- constructor is resolved by 'resolveIdiomatic' before
-    -- 'applyStyle' is called.
+  | {- | Apply the active backend's idiomatic naming convention. This
+    constructor is resolved by 'resolveIdiomatic' before
+    'applyStyle' is called.
+    -}
     Idiomatic
   deriving stock (Eq, Ord, Show, Data, Generic, Lift)
   deriving anyclass (NFData, Hashable)
 
--- | Left-to-right composition. @a `andThen` b@ first applies @a@, then
--- @b@.
+
+{- | Left-to-right composition. @a `andThen` b@ first applies @a@, then
+@b@.
+-}
 andThen :: NameStyle -> NameStyle -> NameStyle
 andThen = Compose
+
+
 infixl 1 `andThen`
 
--- | Substitute every 'Idiomatic' marker with the concrete style for
--- the given backend.
+
+{- | Substitute every 'Idiomatic' marker with the concrete style for
+the given backend.
+-}
 resolveIdiomatic :: Backend -> NameStyle -> NameStyle
 resolveIdiomatic b = go
   where
-    go Idiomatic        = idiomaticFor b
-    go (Compose x y)    = Compose (go x) (go y)
-    go s                = s
+    go Idiomatic = idiomaticFor b
+    go (Compose x y) = Compose (go x) (go y)
+    go s = s
 
--- | The conventional rename style for a given backend.
---
--- Defaults follow the dominant on-the-wire convention for each format:
---
--- * 'backendJSON' / 'backendProto' → 'CamelCase' (proto3's @json_name@
---   default is @lowerCamelCase@; common JS / TS APIs use the same).
--- * 'backendEDN' / 'backendYAML' → 'KebabCase'.
--- * 'backendTOML' → 'SnakeCase'.
--- * 'backendXML' → 'PascalCase'.
--- * 'backendCBOR' / 'backendMsgPack' / 'backendThrift' / 'backendBinary' /
---   'backendCSV' / 'backendTextFormat' → 'NoStyle' (selector base used
---   verbatim).
---
--- Downstream backends not listed here fall through to 'NoStyle'.
+
+{- | The conventional rename style for a given backend.
+
+Defaults follow the dominant on-the-wire convention for each format:
+
+* 'backendJSON' / 'backendProto' → 'CamelCase' (proto3's @json_name@
+  default is @lowerCamelCase@; common JS / TS APIs use the same).
+* 'backendEDN' / 'backendYAML' → 'KebabCase'.
+* 'backendTOML' → 'SnakeCase'.
+* 'backendXML' → 'PascalCase'.
+* 'backendCBOR' / 'backendMsgPack' / 'backendThrift' / 'backendBinary' /
+  'backendCSV' / 'backendTextFormat' → 'NoStyle' (selector base used
+  verbatim).
+
+Downstream backends not listed here fall through to 'NoStyle'.
+-}
 idiomaticFor :: Backend -> NameStyle
 idiomaticFor b
-  | b == backendJSON        = CamelCase
-  | b == backendNDJSON      = CamelCase
-  | b == backendProto       = CamelCase
-  | b == backendBSON        = CamelCase
-  | b == backendION         = CamelCase
-  | b == backendEDN         = KebabCase
-  | b == backendYAML        = KebabCase
-  | b == backendHTML        = KebabCase
-  | b == backendTOML        = SnakeCase
-  | b == backendAvro        = SnakeCase
-  | b == backendBond        = SnakeCase
-  | b == backendArrow       = SnakeCase
-  | b == backendParquet     = SnakeCase
-  | b == backendOrc         = SnakeCase
-  | b == backendIceberg     = SnakeCase
-  | b == backendXML         = PascalCase
-  | b == backendCBOR        = NoStyle
-  | b == backendMsgPack     = NoStyle
-  | b == backendThrift      = NoStyle
-  | b == backendBinary      = NoStyle
-  | b == backendCSV         = NoStyle
-  | b == backendTextFormat  = NoStyle
-  | b == backendASN1        = NoStyle  -- ASN.1 names are positional
-  | b == backendBencode     = NoStyle  -- byte-string keys, verbatim
+  | b == backendJSON = CamelCase
+  | b == backendNDJSON = CamelCase
+  | b == backendProto = CamelCase
+  | b == backendBSON = CamelCase
+  | b == backendION = CamelCase
+  | b == backendEDN = KebabCase
+  | b == backendYAML = KebabCase
+  | b == backendHTML = KebabCase
+  | b == backendTOML = SnakeCase
+  | b == backendAvro = SnakeCase
+  | b == backendBond = SnakeCase
+  | b == backendArrow = SnakeCase
+  | b == backendParquet = SnakeCase
+  | b == backendOrc = SnakeCase
+  | b == backendIceberg = SnakeCase
+  | b == backendXML = PascalCase
+  | b == backendCBOR = NoStyle
+  | b == backendMsgPack = NoStyle
+  | b == backendThrift = NoStyle
+  | b == backendBinary = NoStyle
+  | b == backendCSV = NoStyle
+  | b == backendTextFormat = NoStyle
+  | b == backendASN1 = NoStyle -- ASN.1 names are positional
+  | b == backendBencode = NoStyle -- byte-string keys, verbatim
   | b == backendFlatBuffers = SnakeCase
-  | b == backendCapnProto   = CamelCase
-  | b == backendFory        = SnakeCase
-  | otherwise               = NoStyle
+  | b == backendCapnProto = CamelCase
+  | b == backendFory = SnakeCase
+  | otherwise = NoStyle
 
--- | Apply a style. 'Idiomatic' constructors that have not been
--- resolved by 'resolveIdiomatic' degrade to 'NoStyle' rather than
--- raise.
+
+{- | Apply a style. 'Idiomatic' constructors that have not been
+resolved by 'resolveIdiomatic' degrade to 'NoStyle' rather than
+raise.
+-}
 applyStyle :: NameStyle -> Text -> Text
 applyStyle = go
   where
-    go SnakeCase          = toSnakeCase
-    go UpperSnake         = T.toUpper . toSnakeCase
-    go KebabCase          = toKebabCase
-    go UpperKebab         = T.toUpper . toKebabCase
-    go CamelCase          = toCamelCase
-    go PascalCase         = toPascalCase
-    go LowerCase          = T.toLower
-    go UpperCase          = T.toUpper
-    go (StripPrefix p)    = stripPrefixCS p
-    go (StripSuffix s)    = stripSuffixCS s
-    go (StripPrefixCI p)  = stripPrefixCI p
-    go (StripSuffixCI s)  = stripSuffixCI s
-    go (DropChars n)      = T.drop n
-    go (TakeChars n)      = T.take n
-    go (Replace o n)      = T.replace o n
+    go SnakeCase = toSnakeCase
+    go UpperSnake = T.toUpper . toSnakeCase
+    go KebabCase = toKebabCase
+    go UpperKebab = T.toUpper . toKebabCase
+    go CamelCase = toCamelCase
+    go PascalCase = toPascalCase
+    go LowerCase = T.toLower
+    go UpperCase = T.toUpper
+    go (StripPrefix p) = stripPrefixCS p
+    go (StripSuffix s) = stripSuffixCS s
+    go (StripPrefixCI p) = stripPrefixCI p
+    go (StripSuffixCI s) = stripSuffixCI s
+    go (DropChars n) = T.drop n
+    go (TakeChars n) = T.take n
+    go (Replace o n) = T.replace o n
     go (ReplaceFirst o n) = replaceFirst o n
-    go (Compose a b)      = applyStyle b . applyStyle a
-    go NoStyle            = id
-    go Idiomatic          = id
+    go (Compose a b) = applyStyle b . applyStyle a
+    go NoStyle = id
+    go Idiomatic = id
 
+
 -- ---------------------------------------------------------------------------
 -- Hump-aware splitting
 -- ---------------------------------------------------------------------------
 
--- | Split an identifier into lowercase \"words\" on:
---
--- * existing @_@ / @-@ / spaces, and
--- * camelCase / PascalCase humps (so @PersonName@ → @["person", "name"]@,
---   and @HTTPRequest@ → @["http", "request"]@).
---
--- Used by every snake / kebab / camel transformation.
+{- | Split an identifier into lowercase \"words\" on:
+
+* existing @_@ / @-@ / spaces, and
+* camelCase / PascalCase humps (so @PersonName@ → @["person", "name"]@,
+  and @HTTPRequest@ → @["http", "request"]@).
+
+Used by every snake / kebab / camel transformation.
+-}
 splitWords :: Text -> [Text]
 splitWords =
   filter (not . T.null)
@@ -195,24 +217,28 @@
   where
     isPunct c = c == '_' || c == '-' || c == ' '
 
--- | Insert word boundaries at every @lower → upper@ transition and at
--- every @upper-run → (Upper, lower)@ transition. E.g.
--- @"HTTPRequest"@ → @["HTTP", "Request"]@.
+
+{- | Insert word boundaries at every @lower → upper@ transition and at
+every @upper-run → (Upper, lower)@ transition. E.g.
+@"HTTPRequest"@ → @["HTTP", "Request"]@.
+-}
 splitCamelStr :: String -> [String]
 splitCamelStr [] = []
 splitCamelStr (c0 : cs0) = go [c0] cs0
   where
     -- @go acc xs@ accumulates the current word in reverse in @acc@.
     go :: String -> String -> [String]
-    go acc []                                          = [reverse acc]
+    go acc [] = [reverse acc]
     go acc@(prev : _) (x : xs)
-      | isLower prev && isUpper x                      = reverse acc : go [x] xs
-      | isUpper x, isAcronymRun acc, peekLower xs      =
+      | isLower prev && isUpper x = reverse acc : go [x] xs
+      | isUpper x
+      , isAcronymRun acc
+      , peekLower xs =
           -- We are inside an upper-run and the *next* char after @x@
           -- starts a lowercase tail. Close the acronym at @acc@; @x@
           -- begins the new word.
           reverse acc : go [x] xs
-      | otherwise                                      = go (x : acc) xs
+      | otherwise = go (x : acc) xs
     go [] _ = error "splitCamelStr: impossible empty acc"
 
     isAcronymRun :: String -> Bool
@@ -220,39 +246,47 @@
 
     peekLower :: String -> Bool
     peekLower (y : _) = isLower y
-    peekLower []      = False
+    peekLower [] = False
 
+
 -- | Lowercase, underscore-separated.
 toSnakeCase :: Text -> Text
 toSnakeCase = T.intercalate "_" . splitWords
 
+
 -- | Uppercase, underscore-separated.
 toUpperSnake :: Text -> Text
 toUpperSnake = T.toUpper . toSnakeCase
 
+
 -- | Lowercase, hyphen-separated.
 toKebabCase :: Text -> Text
 toKebabCase = T.intercalate "-" . splitWords
 
+
 -- | Uppercase, hyphen-separated.
 toUpperKebab :: Text -> Text
 toUpperKebab = T.toUpper . toKebabCase
 
+
 -- | @lowerCamelCase@.
 toCamelCase :: Text -> Text
 toCamelCase t = case splitWords t of
-  []     -> T.empty
-  (w:ws) -> T.concat (w : map capitalise ws)
+  [] -> T.empty
+  (w : ws) -> T.concat (w : map capitalise ws)
 
+
 -- | @UpperCamelCase@ / @PascalCase@.
 toPascalCase :: Text -> Text
 toPascalCase = T.concat . map capitalise . splitWords
 
+
 capitalise :: Text -> Text
 capitalise t = case T.uncons t of
-  Nothing      -> t
+  Nothing -> t
   Just (c, cs) -> T.cons (toUpper c) cs
 
+
 -- ---------------------------------------------------------------------------
 -- Affix stripping
 -- ---------------------------------------------------------------------------
@@ -260,27 +294,31 @@
 stripPrefixCS :: Text -> Text -> Text
 stripPrefixCS p t = case T.stripPrefix p t of
   Just rest -> rest
-  Nothing   -> t
+  Nothing -> t
 
+
 stripSuffixCS :: Text -> Text -> Text
 stripSuffixCS s t = case T.stripSuffix s t of
   Just rest -> rest
-  Nothing   -> t
+  Nothing -> t
 
+
 stripPrefixCI :: Text -> Text -> Text
 stripPrefixCI p t
   | T.length t >= T.length p
-  , T.toLower (T.take (T.length p) t) == T.toLower p
-    = T.drop (T.length p) t
+  , T.toLower (T.take (T.length p) t) == T.toLower p =
+      T.drop (T.length p) t
   | otherwise = t
 
+
 stripSuffixCI :: Text -> Text -> Text
 stripSuffixCI s t
   | T.length t >= T.length s
-  , T.toLower (T.takeEnd (T.length s) t) == T.toLower s
-    = T.dropEnd (T.length s) t
+  , T.toLower (T.takeEnd (T.length s) t) == T.toLower s =
+      T.dropEnd (T.length s) t
   | otherwise = t
 
+
 -- ---------------------------------------------------------------------------
 -- Misc helpers
 -- ---------------------------------------------------------------------------
@@ -292,4 +330,4 @@
       (before, after) ->
         case T.stripPrefix needle after of
           Just rest -> before <> replacement <> rest
-          Nothing   -> haystack
+          Nothing -> haystack
diff --git a/src/Wireform/Derive/TypeInfo.hs b/src/Wireform/Derive/TypeInfo.hs
--- a/src/Wireform/Derive/TypeInfo.hs
+++ b/src/Wireform/Derive/TypeInfo.hs
@@ -1,172 +1,197 @@
 {-# LANGUAGE TemplateHaskell #-}
 
--- | A simplified, format-agnostic view over a Haskell @data@ /
--- @newtype@ declaration suitable for code generation.
---
--- Adapted from riz0id's @serde-th@ but kept thinner: we cover the four
--- shapes ('TypeShapeNewtype', 'TypeShapeRecord', 'TypeShapeEnum',
--- 'TypeShapeSum') that wireform's per-format derivers actually act on,
--- and defer to @th-abstraction@ for the heavy lifting.
-module Wireform.Derive.TypeInfo
-  ( TypeInfo (..)
-  , TypeShape (..)
-  , ConInfo (..)
-  , FieldInfo (..)
-  , reifyTypeInfo
+{- | A simplified, format-agnostic view over a Haskell @data@ /
+@newtype@ declaration suitable for code generation.
 
-    -- * Convenience
-  , typeInfoConstructors
-  , isRecordShape
-  , isEnumShape
-  , isNewtypeShape
-  ) where
+Adapted from riz0id's @serde-th@ but kept thinner: we cover the four
+shapes ('TypeShapeNewtype', 'TypeShapeRecord', 'TypeShapeEnum',
+'TypeShapeSum') that wireform's per-format derivers actually act on,
+and defer to @th-abstraction@ for the heavy lifting.
+-}
+module Wireform.Derive.TypeInfo (
+  TypeInfo (..),
+  TypeShape (..),
+  ConInfo (..),
+  FieldInfo (..),
+  reifyTypeInfo,
 
+  -- * Convenience
+  typeInfoConstructors,
+  isRecordShape,
+  isEnumShape,
+  isNewtypeShape,
+) where
+
 import Language.Haskell.TH (Cxt, Name, Q, Type, nameBase, reportError)
-import Language.Haskell.TH.Datatype
-  ( ConstructorInfo (..)
-  , ConstructorVariant (..)
-  , DatatypeInfo (..)
-  , DatatypeVariant (..)
-  , reifyDatatype
-  )
+import Language.Haskell.TH.Datatype (
+  ConstructorInfo (..),
+  ConstructorVariant (..),
+  DatatypeInfo (..),
+  DatatypeVariant (..),
+  reifyDatatype,
+ )
 
+
 -- | Reified summary of a data declaration.
 data TypeInfo = TypeInfo
-  { typeInfoName     :: !Name
-    -- ^ Type-constructor name (e.g. @\'\'Person@).
-  , typeInfoContext  :: !Cxt
-    -- ^ Datatype context (rare; non-empty only for the legacy
-    -- @data Eq a => T a@ syntax).
+  { typeInfoName :: !Name
+  -- ^ Type-constructor name (e.g. @\'\'Person@).
+  , typeInfoContext :: !Cxt
+  {- ^ Datatype context (rare; non-empty only for the legacy
+  @data Eq a => T a@ syntax).
+  -}
   , typeInfoVarTypes :: ![Type]
-    -- ^ The instantiated type variables, one entry per free variable
-    -- in declaration order. Suitable for splicing into @instance@
-    -- heads.
-  , typeInfoShape    :: !TypeShape
-  } deriving (Show)
+  {- ^ The instantiated type variables, one entry per free variable
+  in declaration order. Suitable for splicing into @instance@
+  heads.
+  -}
+  , typeInfoShape :: !TypeShape
+  }
+  deriving (Show)
 
--- | High-level shape of a data declaration. The four shapes carry
--- enough information for the per-format derivers in this package; types
--- that do not fit cleanly (GADTs, existentials, type families) are
--- rejected at 'reifyTypeInfo' time.
+
+{- | High-level shape of a data declaration. The four shapes carry
+enough information for the per-format derivers in this package; types
+that do not fit cleanly (GADTs, existentials, type families) are
+rejected at 'reifyTypeInfo' time.
+-}
 data TypeShape
   = -- | A @newtype@. Always exactly one constructor with one field.
     TypeShapeNewtype !ConInfo
   | -- | A @data@ with exactly one record constructor.
     TypeShapeRecord !ConInfo
-  | -- | A @data@ in which every constructor is nullary (a C-style
-    -- enum). Encodes especially compactly across most formats.
+  | {- | A @data@ in which every constructor is nullary (a C-style
+    enum). Encodes especially compactly across most formats.
+    -}
     TypeShapeEnum ![ConInfo]
-  | -- | A @data@ with a mix of constructors, some carrying fields,
-    -- some not.
+  | {- | A @data@ with a mix of constructors, some carrying fields,
+    some not.
+    -}
     TypeShapeSum ![ConInfo]
   deriving (Show)
 
+
 -- | A single constructor as seen by the derivers.
 data ConInfo = ConInfo
-  { conInfoName    :: !Name
+  { conInfoName :: !Name
   , conInfoIsRecord :: !Bool
-  , conInfoFields  :: ![FieldInfo]
-  } deriving (Show)
+  , conInfoFields :: ![FieldInfo]
+  }
+  deriving (Show)
 
+
 -- | A single field.
 data FieldInfo = FieldInfo
   { fieldInfoName :: !(Maybe Name)
-    -- ^ The record selector, if any. Positional constructors leave
-    -- this as 'Nothing'; per-format derivers index by position in
-    -- 'conInfoFields' in that case.
+  {- ^ The record selector, if any. Positional constructors leave
+  this as 'Nothing'; per-format derivers index by position in
+  'conInfoFields' in that case.
+  -}
   , fieldInfoType :: !Type
-  } deriving (Show)
+  }
+  deriving (Show)
 
--- | Reify a 'TypeInfo' from a type-constructor 'Name'. Reports a
--- splice-time error and aborts if the input does not refer to a
--- @data@ or @newtype@ supported by this package.
+
+{- | Reify a 'TypeInfo' from a type-constructor 'Name'. Reports a
+splice-time error and aborts if the input does not refer to a
+@data@ or @newtype@ supported by this package.
+-}
 reifyTypeInfo :: Name -> Q TypeInfo
 reifyTypeInfo typeName = do
   dti <- reifyDatatype typeName
   shape <- shapeOf dti
-  pure TypeInfo
-    { typeInfoName     = datatypeName dti
-    , typeInfoContext  = datatypeContext dti
-    , typeInfoVarTypes = datatypeInstTypes dti
-    , typeInfoShape    = shape
-    }
+  pure
+    TypeInfo
+      { typeInfoName = datatypeName dti
+      , typeInfoContext = datatypeContext dti
+      , typeInfoVarTypes = datatypeInstTypes dti
+      , typeInfoShape = shape
+      }
   where
     shapeOf :: DatatypeInfo -> Q TypeShape
     shapeOf dti = case datatypeVariant dti of
       Newtype -> case datatypeCons dti of
         [c] -> pure (TypeShapeNewtype (toConInfo c))
-        _   -> reifyError dti "newtype must have exactly one constructor"
-      Datatype  -> classifyData dti
-      DataInstance      -> reifyError dti "data instances are not supported"
-      NewtypeInstance   -> reifyError dti "newtype instances are not supported"
-      TypeData          -> reifyError dti "type data declarations are not supported"
+        _ -> reifyError dti "newtype must have exactly one constructor"
+      Datatype -> classifyData dti
+      DataInstance -> reifyError dti "data instances are not supported"
+      NewtypeInstance -> reifyError dti "newtype instances are not supported"
+      TypeData -> reifyError dti "type data declarations are not supported"
 
     classifyData :: DatatypeInfo -> Q TypeShape
     classifyData dti = case datatypeCons dti of
-      []  -> reifyError dti "type has no constructors"
-      [c] | constructorVariant c == NormalConstructor && null (constructorFields c)
-            -> pure (TypeShapeEnum [toConInfo c])
-          | RecordConstructor _ <- constructorVariant c
-            -> pure (TypeShapeRecord (toConInfo c))
-          | otherwise
-            -> pure (TypeShapeSum [toConInfo c])
+      [] -> reifyError dti "type has no constructors"
+      [c]
+        | constructorVariant c == NormalConstructor && null (constructorFields c) ->
+            pure (TypeShapeEnum [toConInfo c])
+        | RecordConstructor _ <- constructorVariant c ->
+            pure (TypeShapeRecord (toConInfo c))
+        | otherwise ->
+            pure (TypeShapeSum [toConInfo c])
       cs
-        | all isNullary cs
-            -> pure (TypeShapeEnum (map toConInfo cs))
-        | otherwise
-            -> pure (TypeShapeSum (map toConInfo cs))
+        | all isNullary cs ->
+            pure (TypeShapeEnum (map toConInfo cs))
+        | otherwise ->
+            pure (TypeShapeSum (map toConInfo cs))
 
     isNullary :: ConstructorInfo -> Bool
     isNullary c =
-         constructorVariant c == NormalConstructor
-      && null (constructorFields c)
+      constructorVariant c == NormalConstructor
+        && null (constructorFields c)
 
     reifyError :: DatatypeInfo -> String -> Q a
     reifyError dti msg = do
       reportError $
         "Wireform.Derive.TypeInfo.reifyTypeInfo: "
           ++ nameBase (datatypeName dti)
-          ++ ": " ++ msg
+          ++ ": "
+          ++ msg
       fail msg
 
--- | Convert a th-abstraction 'ConstructorInfo' into our smaller
--- 'ConInfo' representation.
+
+{- | Convert a th-abstraction 'ConstructorInfo' into our smaller
+'ConInfo' representation.
+-}
 toConInfo :: ConstructorInfo -> ConInfo
 toConInfo c = case constructorVariant c of
   RecordConstructor fieldNames ->
     ConInfo
-      { conInfoName    = constructorName c
+      { conInfoName = constructorName c
       , conInfoIsRecord = True
-      , conInfoFields  =
+      , conInfoFields =
           zipWith (FieldInfo . Just) fieldNames (constructorFields c)
       }
   _ ->
     ConInfo
-      { conInfoName    = constructorName c
+      { conInfoName = constructorName c
       , conInfoIsRecord = False
-      , conInfoFields  =
+      , conInfoFields =
           map (FieldInfo Nothing) (constructorFields c)
       }
 
+
 -- | All constructors in a 'TypeInfo', flattened across every shape.
 typeInfoConstructors :: TypeInfo -> [ConInfo]
 typeInfoConstructors ti = case typeInfoShape ti of
   TypeShapeNewtype c -> [c]
-  TypeShapeRecord  c -> [c]
-  TypeShapeEnum    cs -> cs
-  TypeShapeSum     cs -> cs
+  TypeShapeRecord c -> [c]
+  TypeShapeEnum cs -> cs
+  TypeShapeSum cs -> cs
 
+
 isRecordShape :: TypeShape -> Bool
 isRecordShape = \case
   TypeShapeRecord _ -> True
-  _                 -> False
+  _ -> False
 
+
 isEnumShape :: TypeShape -> Bool
 isEnumShape = \case
   TypeShapeEnum _ -> True
-  _               -> False
+  _ -> False
 
+
 isNewtypeShape :: TypeShape -> Bool
 isNewtypeShape = \case
   TypeShapeNewtype _ -> True
-  _                  -> False
+  _ -> False
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -1,21 +1,24 @@
 module Main (main) where
 
-import Test.Tasty (TestTree, defaultMain, testGroup)
+import Test.Derive.Aeson qualified
+import Test.Derive.Extension qualified
+import Test.Derive.Fixtures qualified
+import Test.Derive.Modifier qualified
+import Test.Derive.NameStyle qualified
+import Test.Syd
 
-import qualified Test.Derive.Aeson
-import qualified Test.Derive.Extension
-import qualified Test.Derive.Fixtures
-import qualified Test.Derive.Modifier
-import qualified Test.Derive.NameStyle
 
 main :: IO ()
-main = defaultMain tests
+main = sydTest tests
 
-tests :: TestTree
-tests = testGroup "wireform-derive"
-  [ Test.Derive.NameStyle.tests
-  , Test.Derive.Modifier.tests
-  , Test.Derive.Extension.tests
-  , Test.Derive.Fixtures.tests
-  , Test.Derive.Aeson.tests
-  ]
+
+tests :: Spec
+tests =
+  describe "wireform-derive" $
+    sequence_
+      [ Test.Derive.NameStyle.tests
+      , Test.Derive.Modifier.tests
+      , Test.Derive.Extension.tests
+      , Test.Derive.Fixtures.tests
+      , Test.Derive.Aeson.tests
+      ]
diff --git a/test/Test/Derive/Aeson.hs b/test/Test/Derive/Aeson.hs
--- a/test/Test/Derive/Aeson.hs
+++ b/test/Test/Derive/Aeson.hs
@@ -2,139 +2,143 @@
 
 module Test.Derive.Aeson (tests) where
 
-import qualified Data.Aeson as A
-import qualified Data.Aeson.Key as Key
-import qualified Data.Aeson.KeyMap as KM
-import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (assertBool, assertEqual, testCase, (@?=))
-
+import Data.Aeson qualified as A
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KM
 import Test.Derive.Aeson.Instances ()
 import Test.Derive.Aeson.Types
+import Test.Syd
 
-tests :: TestTree
-tests = testGroup "Aeson deriver"
-  [ recordTests
-  , newtypeTests
-  , enumTests
-  , sumTests
-  ]
 
+tests :: Spec
+tests =
+  describe "Aeson deriver" $
+    sequence_
+      [ recordTests
+      , newtypeTests
+      , enumTests
+      , sumTests
+      ]
+
+
 -- ---------------------------------------------------------------------------
 -- Record
 -- ---------------------------------------------------------------------------
 
-recordTests :: TestTree
-recordTests = testGroup "record"
-  [ testCase "encode applies rename / renameStyle" $ do
-      let a = Address "1 Main"  "Springfield" "12345" "secret"
-      let v = A.toJSON a
-      case v of
-        A.Object o -> do
-          assertEqual "street key (literal rename)"
-            (Just (A.String "1 Main"))
-            (KM.lookup (Key.fromText "street") o)
-          assertEqual "city key (snake rename)"
-            (Just (A.String "Springfield"))
-            (KM.lookup (Key.fromText "addr_city") o)
-          assertEqual "zip key (strip-prefix + snake)"
-            (Just (A.String "12345"))
-            (KM.lookup (Key.fromText "zip") o)
-          assertBool "internal key skipped under JSON"
-            (not (KM.member (Key.fromText "addrInternal") o))
-        _ -> fail "expected JSON object"
+recordTests :: Spec
+recordTests =
+  describe "record" $
+    sequence_
+      [ it "encode applies rename / renameStyle" $ do
+          let a = Address "1 Main" "Springfield" "12345" "secret"
+          let v = A.toJSON a
+          case v of
+            A.Object o -> do
+              KM.lookup (Key.fromText "street") o `shouldBe` Just (A.String "1 Main")
+              KM.lookup (Key.fromText "addr_city") o `shouldBe` Just (A.String "Springfield")
+              KM.lookup (Key.fromText "zip") o `shouldBe` Just (A.String "12345")
+              (not (KM.member (Key.fromText "addrInternal") o)) `shouldBe` True
+            _ -> expectationFailure "expected JSON object"
+      , it "decode round-trips (skipped field filled by defaults)" $ do
+          let a = Address "1 Main" "Springfield" "12345" "secret"
+          case A.fromJSON (A.toJSON a) of
+            A.Success a' -> do
+              addrStreet a' `shouldBe` addrStreet a
+              addrCity a' `shouldBe` addrCity a
+              addrZip a' `shouldBe` addrZip a
+              addrInternal a' `shouldBe` defaultAddrInternal
+            A.Error e -> expectationFailure ("decode failed: " ++ e)
+      ]
 
-  , testCase "decode round-trips (skipped field filled by defaults)" $ do
-      let a = Address "1 Main" "Springfield" "12345" "secret"
-      case A.fromJSON (A.toJSON a) of
-        A.Success a' -> do
-          assertEqual "street" (addrStreet a) (addrStreet a')
-          assertEqual "city"   (addrCity a)   (addrCity a')
-          assertEqual "zip"    (addrZip a)    (addrZip a')
-          assertEqual "internal -> defaultAddrInternal"
-            defaultAddrInternal
-            (addrInternal a')
-        A.Error e -> fail ("decode failed: " ++ e)
-  ]
 
 -- ---------------------------------------------------------------------------
 -- Newtype
 -- ---------------------------------------------------------------------------
 
-newtypeTests :: TestTree
-newtypeTests = testGroup "newtype"
-  [ testCase "encode passes through" $
-      A.toJSON (UserId 42) @?= A.Number 42
+newtypeTests :: Spec
+newtypeTests =
+  describe "newtype" $
+    sequence_
+      [ it "encode passes through" $
+          A.toJSON (UserId 42) `shouldBe` A.Number 42
+      , it "round-trip" $ do
+          case A.fromJSON (A.toJSON (UserId 7)) of
+            A.Success (UserId n) -> n `shouldBe` 7
+            A.Error e -> expectationFailure e
+      ]
 
-  , testCase "round-trip" $ do
-      case A.fromJSON (A.toJSON (UserId 7)) of
-        A.Success (UserId n) -> n @?= 7
-        A.Error e -> fail e
-  ]
 
 -- ---------------------------------------------------------------------------
 -- Enum
 -- ---------------------------------------------------------------------------
 
-enumTests :: TestTree
-enumTests = testGroup "enum"
-  [ testCase "encode literal-renamed constructor"
-      (A.toJSON Red @?= A.String "red")
-  , testCase "encode style-renamed constructor (DarkBlue -> dark-blue)"
-      (A.toJSON DarkBlue @?= A.String "dark-blue")
-  , testCase "round-trip Red" $
-      A.fromJSON (A.String "red")     @?= A.Success Red
-  , testCase "round-trip Green" $
-      A.fromJSON (A.String "green")   @?= A.Success Green
-  , testCase "round-trip DarkBlue" $
-      A.fromJSON (A.String "dark-blue") @?= A.Success DarkBlue
-  , testCase "unknown value fails" $ do
-      case A.fromJSON (A.String "purple") :: A.Result Color of
-        A.Error _ -> pure ()
-        A.Success c -> fail ("unexpected " ++ show c)
-  ]
+enumTests :: Spec
+enumTests =
+  describe "enum" $
+    sequence_
+      [ it
+          "encode literal-renamed constructor"
+          (A.toJSON Red `shouldBe` A.String "red")
+      , it
+          "encode style-renamed constructor (DarkBlue -> dark-blue)"
+          (A.toJSON DarkBlue `shouldBe` A.String "dark-blue")
+      , it "round-trip Red" $
+          A.fromJSON (A.String "red") `shouldBe` A.Success Red
+      , it "round-trip Green" $
+          A.fromJSON (A.String "green") `shouldBe` A.Success Green
+      , it "round-trip DarkBlue" $
+          A.fromJSON (A.String "dark-blue") `shouldBe` A.Success DarkBlue
+      , it "unknown value fails" $ do
+          case A.fromJSON (A.String "purple") :: A.Result Color of
+            A.Error _ -> pure ()
+            A.Success c -> expectationFailure ("unexpected " ++ show c)
+      ]
 
+
 -- ---------------------------------------------------------------------------
 -- Sum
 -- ---------------------------------------------------------------------------
 
-sumTests :: TestTree
-sumTests = testGroup "sum"
-  [ testCase "Point  -> tag/contents (null payload)" $
-      A.toJSON Point @?= A.object
-        [ Key.fromText "tag"      A..= A.String "point"
-        , Key.fromText "contents" A..= A.Null
-        ]
-
-  , testCase "Circle -> tag/contents (single payload)" $
-      A.toJSON (Circle 1.5) @?= A.object
-        [ Key.fromText "tag"      A..= A.String "circle"
-        , Key.fromText "contents" A..= A.Number 1.5
-        ]
-
-  , testCase "Rect (renameStyle SnakeCase) -> tag = \"rect\", array contents" $ do
-      let v = A.toJSON (Rect 2 3)
-      case v of
-        A.Object o -> do
-          KM.lookup (Key.fromText "tag") o
-            @?= Just (A.String "rect")
-          KM.lookup (Key.fromText "contents") o
-            @?= Just (A.toJSON ([A.Number 2, A.Number 3] :: [A.Value]))
-        _ -> fail "expected JSON object"
-
-  , testCase "round-trip Point"     $ rt Point
-  , testCase "round-trip Circle"    $ rt (Circle 2.5)
-  , testCase "round-trip Rect"      $ rt (Rect 4 5)
-  , testCase "unknown tag fails" $ do
-      let bad = A.object
-            [ Key.fromText "tag"      A..= A.String "triangle"
-            , Key.fromText "contents" A..= A.Null
-            ]
-      case A.fromJSON bad :: A.Result Shape of
-        A.Error _ -> pure ()
-        A.Success s -> fail ("unexpected " ++ show s)
-  ]
+sumTests :: Spec
+sumTests =
+  describe "sum" $
+    sequence_
+      [ it "Point  -> tag/contents (null payload)" $
+          A.toJSON Point
+            `shouldBe` A.object
+              [ Key.fromText "tag" A..= A.String "point"
+              , Key.fromText "contents" A..= A.Null
+              ]
+      , it "Circle -> tag/contents (single payload)" $
+          A.toJSON (Circle 1.5)
+            `shouldBe` A.object
+              [ Key.fromText "tag" A..= A.String "circle"
+              , Key.fromText "contents" A..= A.Number 1.5
+              ]
+      , it "Rect (renameStyle SnakeCase) -> tag = \"rect\", array contents" $ do
+          let v = A.toJSON (Rect 2 3)
+          case v of
+            A.Object o -> do
+              KM.lookup (Key.fromText "tag") o
+                `shouldBe` Just (A.String "rect")
+              KM.lookup (Key.fromText "contents") o
+                `shouldBe` Just (A.toJSON ([A.Number 2, A.Number 3] :: [A.Value]))
+            _ -> expectationFailure "expected JSON object"
+      , it "round-trip Point" $ rt Point
+      , it "round-trip Circle" $ rt (Circle 2.5)
+      , it "round-trip Rect" $ rt (Rect 4 5)
+      , it "unknown tag fails" $ do
+          let bad =
+                A.object
+                  [ Key.fromText "tag" A..= A.String "triangle"
+                  , Key.fromText "contents" A..= A.Null
+                  ]
+          case A.fromJSON bad :: A.Result Shape of
+            A.Error _ -> pure ()
+            A.Success s -> expectationFailure ("unexpected " ++ show s)
+      ]
   where
     rt :: Shape -> IO ()
     rt s = case A.fromJSON (A.toJSON s) of
-      A.Success s' -> s' @?= s
-      A.Error e    -> fail ("round-trip failed: " ++ e)
+      A.Success s' -> s' `shouldBe` s
+      A.Error e -> expectationFailure ("round-trip failed: " ++ e)
diff --git a/test/Test/Derive/Aeson/Instances.hs b/test/Test/Derive/Aeson/Instances.hs
--- a/test/Test/Derive/Aeson/Instances.hs
+++ b/test/Test/Derive/Aeson/Instances.hs
@@ -1,14 +1,15 @@
-{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell #-}
 
--- | TH splice site for the Aeson deriver. Lives in its own module so
--- that the @ANN@ pragmas in "Test.Derive.Aeson.Types" are visible to
--- 'reifyAnnotations' (TH stage restriction).
+{- | TH splice site for the Aeson deriver. Lives in its own module so
+that the @ANN@ pragmas in "Test.Derive.Aeson.Types" are visible to
+'reifyAnnotations' (TH stage restriction).
+-}
 module Test.Derive.Aeson.Instances () where
 
+import Test.Derive.Aeson.Types
 import Wireform.Derive.Aeson
 
-import Test.Derive.Aeson.Types
 
 deriveJSON ''Address
 deriveJSON ''UserId
diff --git a/test/Test/Derive/Aeson/Types.hs b/test/Test/Derive/Aeson/Types.hs
--- a/test/Test/Derive/Aeson/Types.hs
+++ b/test/Test/Derive/Aeson/Types.hs
@@ -1,55 +1,70 @@
-{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell #-}
 
 -- | Annotated types exercised by the Aeson deriver round-trip tests.
-module Test.Derive.Aeson.Types
-  ( -- * Record
-    Address (..)
-  , defaultAddrInternal
-    -- * Newtype
-  , UserId (..)
-    -- * Enum
-  , Color (..)
-    -- * Sum
-  , Shape (..)
-  ) where
+module Test.Derive.Aeson.Types (
+  -- * Record
+  Address (..),
+  defaultAddrInternal,
 
-import Data.Text (Text)
+  -- * Newtype
+  UserId (..),
 
+  -- * Enum
+  Color (..),
+
+  -- * Sum
+  Shape (..),
+) where
+
+import Data.Text (Text)
 import Wireform.Derive.Backend
 import Wireform.Derive.Modifier
 import Wireform.Derive.NameStyle
 
+
 -- | Default value supplied by the deriver for the skipped field.
 defaultAddrInternal :: Text
 defaultAddrInternal = "<missing>"
 
+
 -- ---------------------------------------------------------------------------
 -- Record (with rename + renameStyle + per-backend overrides)
 -- ---------------------------------------------------------------------------
 
 data Address = Address
   { addrStreet :: !Text
-  , addrCity   :: !Text
-  , addrZip    :: !Text
+  , addrCity :: !Text
+  , addrZip :: !Text
   , addrInternal :: !Text
-  } deriving (Eq, Show)
+  }
+  deriving (Eq, Show)
 
+
 {-# ANN addrStreet (rename "street") #-}
-{-# ANN addrCity   (renameStyle SnakeCase) #-}
-{-# ANN addrZip    (renameStyle (StripPrefix "addr" `andThen` SnakeCase)) #-}
--- | Internal field skipped from JSON entirely (no defaults required —
--- the round-trip test seeds the value directly).
+
+
+{-# ANN addrCity (renameStyle SnakeCase) #-}
+
+
+{-# ANN addrZip (renameStyle (StripPrefix "addr" `andThen` SnakeCase)) #-}
+
+
+{- | Internal field skipped from JSON entirely (no defaults required —
+the round-trip test seeds the value directly).
+-}
 {-# ANN addrInternal (forBackend backendJSON skip) #-}
 {-# ANN addrInternal (forBackend backendJSON (defaults 'defaultAddrInternal)) #-}
 
+
 -- ---------------------------------------------------------------------------
 -- Newtype
 -- ---------------------------------------------------------------------------
 
-newtype UserId = UserId { unUserId :: Int }
+newtype UserId = UserId {unUserId :: Int}
   deriving (Eq, Show)
 
+
 -- ---------------------------------------------------------------------------
 -- Enum (renamed constructors)
 -- ---------------------------------------------------------------------------
@@ -57,10 +72,16 @@
 data Color = Red | Green | DarkBlue
   deriving (Eq, Show)
 
-{-# ANN Red       (rename "red") #-}
-{-# ANN Green     (rename "green") #-}
-{-# ANN DarkBlue  (renameStyle KebabCase) #-}
 
+{-# ANN Red (rename "red") #-}
+
+
+{-# ANN Green (rename "green") #-}
+
+
+{-# ANN DarkBlue (renameStyle KebabCase) #-}
+
+
 -- ---------------------------------------------------------------------------
 -- Sum-of-products
 -- ---------------------------------------------------------------------------
@@ -71,6 +92,11 @@
   | Rect !Double !Double
   deriving (Eq, Show)
 
-{-# ANN Point  (rename "point") #-}
+
+{-# ANN Point (rename "point") #-}
+
+
 {-# ANN Circle (rename "circle") #-}
-{-# ANN Rect   (renameStyle SnakeCase) #-}
+
+
+{-# ANN Rect (renameStyle SnakeCase) #-}
diff --git a/test/Test/Derive/Extension.hs b/test/Test/Derive/Extension.hs
--- a/test/Test/Derive/Extension.hs
+++ b/test/Test/Derive/Extension.hs
@@ -4,35 +4,34 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TypeApplications #-}
 
--- | Round-trip tests for 'Wireform.Derive.Extension'. Two distinct
--- backend-defined modifier types coexist on the same 'Name' and are
--- recovered with full type fidelity.
+{- | Round-trip tests for 'Wireform.Derive.Extension'. Two distinct
+backend-defined modifier types coexist on the same 'Name' and are
+recovered with full type fidelity.
+-}
 module Test.Derive.Extension (tests) where
 
-import qualified Data.Map.Strict as Map
+import Data.Map.Strict qualified as Map
 import Data.Text (Text)
-import qualified Data.Text as T
+import Data.Text qualified as T
 import Data.Typeable (Typeable)
 import GHC.Generics (Generic)
-
-import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (testCase, (@?=))
-
+import Test.Syd
 import Wireform.Derive.Backend (backendIceberg)
-import Wireform.Derive.Extension
-  ( BackendModifier (..)
-  , extension
-  , hasExtension
-  , lookupExtension
-  , lookupExtensions
-  )
+import Wireform.Derive.Extension (
+  BackendModifier (..),
+  extension,
+  hasExtension,
+  lookupExtension,
+  lookupExtensions,
+ )
 import Wireform.Derive.Modifier (Modifier)
-import Wireform.Derive.ModifierInfo
-  ( ModifierInfo (..)
-  , emptyModifierInfo
-  , foldModifiers
-  )
+import Wireform.Derive.ModifierInfo (
+  ModifierInfo (..),
+  emptyModifierInfo,
+  foldModifiers,
+ )
 
+
 -- ---------------------------------------------------------------------------
 -- Two pretend backend extension vocabularies
 -- ---------------------------------------------------------------------------
@@ -42,71 +41,75 @@
   | OptimisticTransform !Text
   deriving stock (Eq, Show, Read, Typeable, Generic)
 
+
 instance BackendModifier IcebergFieldOpt where
   backendModifierTag _ = "wireform-iceberg.field-opt"
 
+
 data XmlFieldOpt
   = AsAttribute
   | AsElement
   | NamespacedTo !Text
   deriving stock (Eq, Show, Read, Typeable, Generic)
 
+
 instance BackendModifier XmlFieldOpt where
   backendModifierTag _ = "wireform-xml.field-opt"
 
+
 -- ---------------------------------------------------------------------------
 -- Tests
 -- ---------------------------------------------------------------------------
 
-tests :: TestTree
-tests = testGroup "Wireform.Derive.Extension"
-  [ testCase "round-trip a single typed extension" $ do
-      let m = extension PartitionColumn
-      let mi = applyMods [m]
-      lookupExtension @IcebergFieldOpt mi @?= Just PartitionColumn
-      hasExtension    @IcebergFieldOpt mi @?= True
+tests :: Spec
+tests =
+  describe "Wireform.Derive.Extension" $
+    sequence_
+      [ it "round-trip a single typed extension" $ do
+          let m = extension PartitionColumn
+          let mi = applyMods [m]
+          lookupExtension @IcebergFieldOpt mi `shouldBe` Just PartitionColumn
+          hasExtension @IcebergFieldOpt mi `shouldBe` True
+      , it "round-trip an extension with a Text payload" $ do
+          let m = extension (OptimisticTransform (T.pack "year"))
+          let mi = applyMods [m]
+          lookupExtension @IcebergFieldOpt mi `shouldBe` Just (OptimisticTransform (T.pack "year"))
+      , it "two extensions of the same type are stacked" $ do
+          let ms =
+                [ extension PartitionColumn
+                , extension (OptimisticTransform (T.pack "month"))
+                ]
+          let mi = applyMods ms
+          lookupExtensions @IcebergFieldOpt mi
+            `shouldBe` [ PartitionColumn
+                       , OptimisticTransform (T.pack "month")
+                       ]
+      , it "two extensions of distinct types coexist" $ do
+          let ms =
+                [ extension PartitionColumn
+                , extension AsAttribute
+                , extension (NamespacedTo (T.pack "ns0"))
+                ]
+          let mi = applyMods ms
+          lookupExtension @IcebergFieldOpt mi `shouldBe` Just PartitionColumn
+          lookupExtensions @XmlFieldOpt mi
+            `shouldBe` [AsAttribute, NamespacedTo (T.pack "ns0")]
+      , it "absent extension is Nothing" $ do
+          let mi = emptyModifierInfo backendIceberg
+          lookupExtension @IcebergFieldOpt mi `shouldBe` Nothing
+          hasExtension @IcebergFieldOpt mi `shouldBe` False
+      , it "miCustom keys match the BackendModifier tag" $ do
+          let mi = applyMods [extension PartitionColumn, extension AsAttribute]
+          Map.keys (miCustom mi)
+            `shouldBe` [ "wireform-iceberg.field-opt"
+                       , "wireform-xml.field-opt"
+                       ]
+      ]
 
-  , testCase "round-trip an extension with a Text payload" $ do
-      let m = extension (OptimisticTransform (T.pack "year"))
-      let mi = applyMods [m]
-      lookupExtension @IcebergFieldOpt mi @?= Just (OptimisticTransform (T.pack "year"))
 
-  , testCase "two extensions of the same type are stacked" $ do
-      let ms =
-            [ extension PartitionColumn
-            , extension (OptimisticTransform (T.pack "month"))
-            ]
-      let mi = applyMods ms
-      lookupExtensions @IcebergFieldOpt mi @?=
-        [ PartitionColumn
-        , OptimisticTransform (T.pack "month")
-        ]
-
-  , testCase "two extensions of distinct types coexist" $ do
-      let ms = [ extension PartitionColumn
-               , extension AsAttribute
-               , extension (NamespacedTo (T.pack "ns0"))
-               ]
-      let mi = applyMods ms
-      lookupExtension @IcebergFieldOpt mi @?= Just PartitionColumn
-      lookupExtensions @XmlFieldOpt   mi @?=
-        [AsAttribute, NamespacedTo (T.pack "ns0")]
-
-  , testCase "absent extension is Nothing" $ do
-      let mi = emptyModifierInfo backendIceberg
-      lookupExtension @IcebergFieldOpt mi @?= Nothing
-      hasExtension    @IcebergFieldOpt mi @?= False
-
-  , testCase "miCustom keys match the BackendModifier tag" $ do
-      let mi = applyMods [extension PartitionColumn, extension AsAttribute]
-      Map.keys (miCustom mi) @?=
-        [ "wireform-iceberg.field-opt"
-        , "wireform-xml.field-opt"
-        ]
-  ]
-
--- | Fold the given modifiers as if they were attached to a name, so
--- the test mirrors what 'reifyModifierInfoFor' would produce.
+{- | Fold the given modifiers as if they were attached to a name, so
+the test mirrors what 'reifyModifierInfoFor' would produce.
+-}
 applyMods :: [Modifier] -> ModifierInfo
 applyMods ms = case foldModifiers backendIceberg ms of
   Right mi -> mi
diff --git a/test/Test/Derive/Fixtures.hs b/test/Test/Derive/Fixtures.hs
--- a/test/Test/Derive/Fixtures.hs
+++ b/test/Test/Derive/Fixtures.hs
@@ -2,42 +2,54 @@
 
 module Test.Derive.Fixtures (tests) where
 
-import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (testCase, (@?=))
+import Test.Derive.Fixtures.Reified qualified as R
+import Test.Syd
 
-import qualified Test.Derive.Fixtures.Reified as R
 
-tests :: TestTree
-tests = testGroup "ANN round-trip"
-  [ testGroup "wire keys (literal renames)"
-      [ testCase "personName JSON  -> \"name\""
-          (R.personNameKeyJSON @?= "name")
-      , testCase "personName CBOR  -> \"name\""
-          (R.personNameKeyCBOR @?= "name")
-      ]
-
-  , testGroup "wire keys (renameStyle)"
-      [ testCase "personAge JSON  -> \"person_age\" (literal from SnakeCase)"
-          (R.personAgeKeyJSON @?= "person_age")
-      , testCase "personAge Proto -> \"person_age\" (same; backend rename only on JSON-style)"
-          (R.personAgeKeyProto @?= "person_age")
-      ]
-
-  , testGroup "wire keys (renameWith — runtime call)"
-      [ testCase "personSSN JSON  -> \"_personssn\" (lowercase + underscore prefix)"
-          (R.personSSNKeyJSON @?= "_personssn")
-      , testCase "personSSN CBOR  -> \"_personssn\""
-          (R.personSSNKeyCBOR @?= "_personssn")
-      ]
-
-  , testGroup "per-backend overrides"
-      [ testCase "personSSN skipped in JSON (via disableFor)"
-          (R.personSSNSkipJSON @?= True)
-      , testCase "personSSN NOT skipped in CBOR"
-          (R.personSSNSkipCBOR @?= False)
-      , testCase "personAge tag visible only under proto"
-          (R.personAgeTagProto @?= Just 7)
-      , testCase "personAge has no tag under JSON"
-          (R.personAgeTagJSON @?= Nothing)
+tests :: Spec
+tests =
+  describe "ANN round-trip" $
+    sequence_
+      [ describe "wire keys (literal renames)" $
+          sequence_
+            [ it
+                "personName JSON  -> \"name\""
+                (R.personNameKeyJSON `shouldBe` "name")
+            , it
+                "personName CBOR  -> \"name\""
+                (R.personNameKeyCBOR `shouldBe` "name")
+            ]
+      , describe "wire keys (renameStyle)" $
+          sequence_
+            [ it
+                "personAge JSON  -> \"person_age\" (literal from SnakeCase)"
+                (R.personAgeKeyJSON `shouldBe` "person_age")
+            , it
+                "personAge Proto -> \"person_age\" (same; backend rename only on JSON-style)"
+                (R.personAgeKeyProto `shouldBe` "person_age")
+            ]
+      , describe "wire keys (renameWith — runtime call)" $
+          sequence_
+            [ it
+                "personSSN JSON  -> \"_personssn\" (lowercase + underscore prefix)"
+                (R.personSSNKeyJSON `shouldBe` "_personssn")
+            , it
+                "personSSN CBOR  -> \"_personssn\""
+                (R.personSSNKeyCBOR `shouldBe` "_personssn")
+            ]
+      , describe "per-backend overrides" $
+          sequence_
+            [ it
+                "personSSN skipped in JSON (via disableFor)"
+                (R.personSSNSkipJSON `shouldBe` True)
+            , it
+                "personSSN NOT skipped in CBOR"
+                (R.personSSNSkipCBOR `shouldBe` False)
+            , it
+                "personAge tag visible only under proto"
+                (R.personAgeTagProto `shouldBe` Just 7)
+            , it
+                "personAge has no tag under JSON"
+                (R.personAgeTagJSON `shouldBe` Nothing)
+            ]
       ]
-  ]
diff --git a/test/Test/Derive/Fixtures/Reified.hs b/test/Test/Derive/Fixtures/Reified.hs
--- a/test/Test/Derive/Fixtures/Reified.hs
+++ b/test/Test/Derive/Fixtures/Reified.hs
@@ -1,93 +1,122 @@
-{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell #-}
 
--- | Splices that exercise the public deriver API against the
--- annotated types in "Test.Derive.Fixtures.Types".
---
--- Rather than try to embed entire 'ModifierInfo' values (which would
--- require @Lift@ instances we deliberately do not provide), we splice
--- only the values per-format derivers actually consume:
---
--- * the wire-key 'Text' returned by 'renderWireKey'
--- * 'Bool's that summarise key flags after resolution.
-module Test.Derive.Fixtures.Reified
-  ( -- * Wire keys
-    personNameKeyJSON
-  , personNameKeyCBOR
-  , personAgeKeyJSON
-  , personAgeKeyProto
-  , personSSNKeyJSON
-  , personSSNKeyCBOR
+{- | Splices that exercise the public deriver API against the
+annotated types in "Test.Derive.Fixtures.Types".
 
-    -- * Resolved flags
-  , personSSNSkipJSON
-  , personSSNSkipCBOR
-  , personAgeTagProto
-  , personAgeTagJSON
-  ) where
+Rather than try to embed entire 'ModifierInfo' values (which would
+require @Lift@ instances we deliberately do not provide), we splice
+only the values per-format derivers actually consume:
 
+* the wire-key 'Text' returned by 'renderWireKey'
+* 'Bool's that summarise key flags after resolution.
+-}
+module Test.Derive.Fixtures.Reified (
+  -- * Wire keys
+  personNameKeyJSON,
+  personNameKeyCBOR,
+  personAgeKeyJSON,
+  personAgeKeyProto,
+  personSSNKeyJSON,
+  personSSNKeyCBOR,
+
+  -- * Resolved flags
+  personSSNSkipJSON,
+  personSSNSkipCBOR,
+  personAgeTagProto,
+  personAgeTagJSON,
+) where
+
 import Data.Text (Text)
 import Language.Haskell.TH.Syntax (lift)
-
+import Test.Derive.Fixtures.Types qualified as F
 import Wireform.Derive.Backend
 import Wireform.Derive.ModifierInfo
 
-import qualified Test.Derive.Fixtures.Types as F
 
 -- ---------------------------------------------------------------------------
 -- Wire keys
 -- ---------------------------------------------------------------------------
 
 personNameKeyJSON :: Text
-personNameKeyJSON = $(do
-  mi <- reifyModifierInfoFor backendJSON 'F.personName
-  renderWireKey mi "personName")
+personNameKeyJSON =
+  $( do
+       mi <- reifyModifierInfoFor backendJSON 'F.personName
+       renderWireKey mi "personName"
+   )
 
+
 personNameKeyCBOR :: Text
-personNameKeyCBOR = $(do
-  mi <- reifyModifierInfoFor backendCBOR 'F.personName
-  renderWireKey mi "personName")
+personNameKeyCBOR =
+  $( do
+       mi <- reifyModifierInfoFor backendCBOR 'F.personName
+       renderWireKey mi "personName"
+   )
 
+
 personAgeKeyJSON :: Text
-personAgeKeyJSON = $(do
-  mi <- reifyModifierInfoFor backendJSON 'F.personAge
-  renderWireKey mi "personAge")
+personAgeKeyJSON =
+  $( do
+       mi <- reifyModifierInfoFor backendJSON 'F.personAge
+       renderWireKey mi "personAge"
+   )
 
+
 personAgeKeyProto :: Text
-personAgeKeyProto = $(do
-  mi <- reifyModifierInfoFor backendProto 'F.personAge
-  renderWireKey mi "personAge")
+personAgeKeyProto =
+  $( do
+       mi <- reifyModifierInfoFor backendProto 'F.personAge
+       renderWireKey mi "personAge"
+   )
 
+
 personSSNKeyJSON :: Text
-personSSNKeyJSON = $(do
-  mi <- reifyModifierInfoFor backendJSON 'F.personSSN
-  renderWireKey mi "personSSN")
+personSSNKeyJSON =
+  $( do
+       mi <- reifyModifierInfoFor backendJSON 'F.personSSN
+       renderWireKey mi "personSSN"
+   )
 
+
 personSSNKeyCBOR :: Text
-personSSNKeyCBOR = $(do
-  mi <- reifyModifierInfoFor backendCBOR 'F.personSSN
-  renderWireKey mi "personSSN")
+personSSNKeyCBOR =
+  $( do
+       mi <- reifyModifierInfoFor backendCBOR 'F.personSSN
+       renderWireKey mi "personSSN"
+   )
 
+
 -- ---------------------------------------------------------------------------
 -- Flags
 -- ---------------------------------------------------------------------------
 
 personSSNSkipJSON :: Bool
-personSSNSkipJSON = $(do
-  mi <- reifyModifierInfoFor backendJSON 'F.personSSN
-  lift (miSkip mi))
+personSSNSkipJSON =
+  $( do
+       mi <- reifyModifierInfoFor backendJSON 'F.personSSN
+       lift (miSkip mi)
+   )
 
+
 personSSNSkipCBOR :: Bool
-personSSNSkipCBOR = $(do
-  mi <- reifyModifierInfoFor backendCBOR 'F.personSSN
-  lift (miSkip mi))
+personSSNSkipCBOR =
+  $( do
+       mi <- reifyModifierInfoFor backendCBOR 'F.personSSN
+       lift (miSkip mi)
+   )
 
+
 personAgeTagProto :: Maybe Int
-personAgeTagProto = $(do
-  mi <- reifyModifierInfoFor backendProto 'F.personAge
-  lift (miTag mi))
+personAgeTagProto =
+  $( do
+       mi <- reifyModifierInfoFor backendProto 'F.personAge
+       lift (miTag mi)
+   )
 
+
 personAgeTagJSON :: Maybe Int
-personAgeTagJSON = $(do
-  mi <- reifyModifierInfoFor backendJSON 'F.personAge
-  lift (miTag mi))
+personAgeTagJSON =
+  $( do
+       mi <- reifyModifierInfoFor backendJSON 'F.personAge
+       lift (miTag mi)
+   )
diff --git a/test/Test/Derive/Fixtures/Types.hs b/test/Test/Derive/Fixtures/Types.hs
--- a/test/Test/Derive/Fixtures/Types.hs
+++ b/test/Test/Derive/Fixtures/Types.hs
@@ -1,47 +1,59 @@
-{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell #-}
 {-# OPTIONS_GHC -Wno-orphans #-}
 
--- | Annotated data types used to verify that 'ANN' pragmas survive
--- the TH reflection round-trip.
---
--- This module deliberately performs no splices: 'ANN' pragmas are
--- only visible to 'reifyAnnotations' /after/ the declaring module has
--- been compiled, so the splice that reifies them lives in
--- "Test.Derive.Fixtures.Reified".
-module Test.Derive.Fixtures.Types
-  ( Person (..)
-  , personNameRenamer
-  ) where
+{- | Annotated data types used to verify that 'ANN' pragmas survive
+the TH reflection round-trip.
 
-import Data.Text (Text)
-import qualified Data.Text as T
+This module deliberately performs no splices: 'ANN' pragmas are
+only visible to 'reifyAnnotations' /after/ the declaring module has
+been compiled, so the splice that reifies them lives in
+"Test.Derive.Fixtures.Reified".
+-}
+module Test.Derive.Fixtures.Types (
+  Person (..),
+  personNameRenamer,
+) where
 
+import Data.Text (Text)
+import Data.Text qualified as T
 import Wireform.Derive.Backend
 import Wireform.Derive.Modifier
 import Wireform.Derive.NameStyle
 
+
 -- | Test-only @Text -> Text@ function used by 'renameWith'.
 personNameRenamer :: Text -> Text
 personNameRenamer = T.cons '_' . T.toLower
 
+
 data Person = Person
   { personName :: !Text
-  , personAge  :: !Int
-  , personSSN  :: !Text
-  } deriving (Eq, Show)
+  , personAge :: !Int
+  , personSSN :: !Text
+  }
+  deriving (Eq, Show)
 
+
 -- | Type-level rename: identity (kept here for completeness).
 {-# ANN type Person (rename "person") #-}
 
+
 -- | Field-level customisations used by the round-trip tests.
 {-# ANN personName (rename "name") #-}
-{-# ANN personAge  (renameStyle SnakeCase) #-}
-{-# ANN personSSN  (renameWith 'personNameRenamer) #-}
 
--- | Per-backend overrides: in JSON, 'personSSN' should be skipped;
--- in CBOR it should still be encoded.
+
+{-# ANN personAge (renameStyle SnakeCase) #-}
+
+
+{-# ANN personSSN (renameWith 'personNameRenamer) #-}
+
+
+{- | Per-backend overrides: in JSON, 'personSSN' should be skipped;
+in CBOR it should still be encoded.
+-}
 {-# ANN personSSN (disableFor [backendJSON]) #-}
+
 
 -- | Demonstrate that several modifiers compose on a single name.
 {-# ANN personAge (forBackend backendProto (tag 7)) #-}
diff --git a/test/Test/Derive/Modifier.hs b/test/Test/Derive/Modifier.hs
--- a/test/Test/Derive/Modifier.hs
+++ b/test/Test/Derive/Modifier.hs
@@ -2,169 +2,193 @@
 
 module Test.Derive.Modifier (tests) where
 
-import qualified Data.Map.Strict as Map
-import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (assertBool, assertEqual, testCase, (@?=))
-
+import Data.Map.Strict qualified as Map
+import Test.Syd
 import Wireform.Derive.Backend
 import Wireform.Derive.Modifier
 import Wireform.Derive.ModifierInfo
 import Wireform.Derive.NameStyle
 
-tests :: TestTree
-tests = testGroup "Modifier"
-  [ testGroup "foldModifiers — happy path"
-      [ testCase "empty input gives empty info" $
-          foldModifiers backendJSON [] @?= Right (emptyModifierInfo backendJSON)
 
-      , testCase "single rename" $
-          fmap miRename (foldModifiers backendJSON [rename "name"])
-            @?= Right (Just (RenameSpecLiteral "name"))
-
-      , testCase "duplicate identical rename is no conflict" $
-          fmap miRename (foldModifiers backendJSON
-            [rename "name", rename "name"])
-            @?= Right (Just (RenameSpecLiteral "name"))
-
-      , testCase "skip flag" $
-          fmap miSkip (foldModifiers backendJSON [skip])
-            @?= Right True
-
-      , testCase "tag set" $
-          fmap miTag (foldModifiers backendJSON [tag 7])
-            @?= Right (Just 7)
-
-      , testCase "required + only set once" $
-          fmap miRequired (foldModifiers backendJSON [required])
-            @?= Right (Just True)
-
-      , testCase "optional + only set once" $
-          fmap miRequired (foldModifiers backendJSON [optional])
-            @?= Right (Just False)
-      ]
-
-  , testGroup "foldModifiers — conflicts"
-      [ testCase "two distinct renames conflict" $ do
-          let r = foldModifiers backendJSON
-                    [rename "a", rename "b"]
-          case r of
-            Left ConflictRename {} -> pure ()
-            other -> fail ("expected ConflictRename, got " ++ show other)
-
-      , testCase "two distinct tags conflict" $ do
-          let r = foldModifiers backendJSON [tag 1, tag 2]
-          case r of
-            Left ConflictTag {} -> pure ()
-            other -> fail ("expected ConflictTag, got " ++ show other)
-
-      , testCase "required + optional conflict" $ do
-          let r = foldModifiers backendJSON [required, optional]
-          case r of
-            Left ConflictRequired {} -> pure ()
-            other -> fail ("expected ConflictRequired, got " ++ show other)
-
-      , testCase "flatten + skip conflict" $ do
-          let r = foldModifiers backendJSON [flatten, skip]
-          case r of
-            Left ConflictFlattenSkip -> pure ()
-            other -> fail ("expected ConflictFlattenSkip, got " ++ show other)
-      ]
-
-  , testGroup "per-backend overrides"
-      [ testCase "backend-only modifier applies for active backend" $
-          fmap miRename
-              (foldModifiers backendProto
-                  [forBackend backendProto (rename "n")])
-            @?= Right (Just (RenameSpecLiteral "n"))
-
-      , testCase "backend-only modifier ignored for other backend" $
-          fmap miRename
-              (foldModifiers backendJSON
-                  [forBackend backendProto (rename "n")])
-            @?= Right Nothing
-
-      , testCase "per-backend rename shadows global, no conflict" $
-          fmap miRename
-              (foldModifiers backendProto
-                  [ rename "global"
-                  , forBackend backendProto (rename "scoped")
-                  ])
-            @?= Right (Just (RenameSpecLiteral "scoped"))
-
-      , testCase "per-backend rename does not shadow on other backend" $
-          fmap miRename
-              (foldModifiers backendJSON
-                  [ rename "global"
-                  , forBackend backendProto (rename "scoped")
-                  ])
-            @?= Right (Just (RenameSpecLiteral "global"))
-
-      , testCase "disableFor on active backend marks skip" $
-          fmap miSkip
-              (foldModifiers backendCBOR
-                  [ disableFor [backendCBOR, backendMsgPack] ])
-            @?= Right True
-
-      , testCase "disableFor on other backend has no effect" $
-          fmap miSkip
-              (foldModifiers backendJSON
-                  [ disableFor [backendCBOR, backendMsgPack] ])
-            @?= Right False
-
-      , testCase "forBackends groups multiple modifiers" $ do
-          let mi = foldModifiers backendProto
-                     [ forBackends [backendProto] [rename "p", tag 9] ]
-          fmap miRename mi @?= Right (Just (RenameSpecLiteral "p"))
-          fmap miTag    mi @?= Right (Just 9)
-
-      , testCase "forBackends does nothing for unlisted backend" $
-          fmap miRename
-              (foldModifiers backendJSON
-                  [ forBackends [backendProto] [rename "p"] ])
-            @?= Right Nothing
-      ]
-
-  , testGroup "RenameSpec variants"
-      [ testCase "rename literal" $
-          fmap miRename (foldModifiers backendJSON [rename "n"])
-            @?= Right (Just (RenameSpecLiteral "n"))
-
-      , testCase "renameStyle preserved" $
-          fmap miRename (foldModifiers backendJSON
-              [renameStyle (StripPrefix "person")])
-            @?= Right (Just (RenameSpecStyle (StripPrefix "person")))
-      ]
-
-  , testGroup "default rename keys"
-      [ testCase "JSON default is camel"
-          (defaultRenameForBackend backendJSON "person_name" @?= "personName")
-      , testCase "EDN default is kebab"
-          (defaultRenameForBackend backendEDN "personName"   @?= "person-name")
-      , testCase "CBOR default is verbatim"
-          (defaultRenameForBackend backendCBOR "personName"  @?= "personName")
-      ]
-
-  , testGroup "ModCustom accumulation"
-      [ testCase "two custom payloads with same tag accumulate" $ do
-          let mi = foldModifiers backendCBOR
-                     [ customModifier "ext.foo" "x"
-                     , customModifier "ext.foo" "y"
-                     ]
-          case mi of
-            Right info -> do
-              let xs = Map.findWithDefault [] "ext.foo" (miCustom info)
-              assertEqual "accumulated count" 2 (length xs)
-            Left e -> fail ("unexpected " ++ show e)
-
-      , testCase "custom payloads with different tags don't conflict" $ do
-          let mi = foldModifiers backendCBOR
-                     [ customModifier "ext.foo" "x"
-                     , customModifier "ext.bar" "y"
-                     ]
-          case mi of
-            Right info -> do
-              assertBool "foo present" (Map.member "ext.foo" (miCustom info))
-              assertBool "bar present" (Map.member "ext.bar" (miCustom info))
-            Left e -> fail ("unexpected " ++ show e)
+tests :: Spec
+tests =
+  describe "Modifier" $
+    sequence_
+      [ describe "foldModifiers — happy path" $
+          sequence_
+            [ it "empty input gives empty info" $
+                foldModifiers backendJSON [] `shouldBe` Right (emptyModifierInfo backendJSON)
+            , it "single rename" $
+                fmap miRename (foldModifiers backendJSON [rename "name"])
+                  `shouldBe` Right (Just (RenameSpecLiteral "name"))
+            , it "duplicate identical rename is no conflict" $
+                fmap
+                  miRename
+                  ( foldModifiers
+                      backendJSON
+                      [rename "name", rename "name"]
+                  )
+                  `shouldBe` Right (Just (RenameSpecLiteral "name"))
+            , it "skip flag" $
+                fmap miSkip (foldModifiers backendJSON [skip])
+                  `shouldBe` Right True
+            , it "tag set" $
+                fmap miTag (foldModifiers backendJSON [tag 7])
+                  `shouldBe` Right (Just 7)
+            , it "required + only set once" $
+                fmap miRequired (foldModifiers backendJSON [required])
+                  `shouldBe` Right (Just True)
+            , it "optional + only set once" $
+                fmap miRequired (foldModifiers backendJSON [optional])
+                  `shouldBe` Right (Just False)
+            ]
+      , describe "foldModifiers — conflicts" $
+          sequence_
+            [ it "two distinct renames conflict" $ do
+                let r =
+                      foldModifiers
+                        backendJSON
+                        [rename "a", rename "b"]
+                case r of
+                  Left ConflictRename {} -> pure ()
+                  other -> expectationFailure ("expected ConflictRename, got " ++ show other)
+            , it "two distinct tags conflict" $ do
+                let r = foldModifiers backendJSON [tag 1, tag 2]
+                case r of
+                  Left ConflictTag {} -> pure ()
+                  other -> expectationFailure ("expected ConflictTag, got " ++ show other)
+            , it "required + optional conflict" $ do
+                let r = foldModifiers backendJSON [required, optional]
+                case r of
+                  Left ConflictRequired {} -> pure ()
+                  other -> expectationFailure ("expected ConflictRequired, got " ++ show other)
+            , it "flatten + skip conflict" $ do
+                let r = foldModifiers backendJSON [flatten, skip]
+                case r of
+                  Left ConflictFlattenSkip -> pure ()
+                  other -> expectationFailure ("expected ConflictFlattenSkip, got " ++ show other)
+            ]
+      , describe "per-backend overrides" $
+          sequence_
+            [ it "backend-only modifier applies for active backend" $
+                fmap
+                  miRename
+                  ( foldModifiers
+                      backendProto
+                      [forBackend backendProto (rename "n")]
+                  )
+                  `shouldBe` Right (Just (RenameSpecLiteral "n"))
+            , it "backend-only modifier ignored for other backend" $
+                fmap
+                  miRename
+                  ( foldModifiers
+                      backendJSON
+                      [forBackend backendProto (rename "n")]
+                  )
+                  `shouldBe` Right Nothing
+            , it "per-backend rename shadows global, no conflict" $
+                fmap
+                  miRename
+                  ( foldModifiers
+                      backendProto
+                      [ rename "global"
+                      , forBackend backendProto (rename "scoped")
+                      ]
+                  )
+                  `shouldBe` Right (Just (RenameSpecLiteral "scoped"))
+            , it "per-backend rename does not shadow on other backend" $
+                fmap
+                  miRename
+                  ( foldModifiers
+                      backendJSON
+                      [ rename "global"
+                      , forBackend backendProto (rename "scoped")
+                      ]
+                  )
+                  `shouldBe` Right (Just (RenameSpecLiteral "global"))
+            , it "disableFor on active backend marks skip" $
+                fmap
+                  miSkip
+                  ( foldModifiers
+                      backendCBOR
+                      [disableFor [backendCBOR, backendMsgPack]]
+                  )
+                  `shouldBe` Right True
+            , it "disableFor on other backend has no effect" $
+                fmap
+                  miSkip
+                  ( foldModifiers
+                      backendJSON
+                      [disableFor [backendCBOR, backendMsgPack]]
+                  )
+                  `shouldBe` Right False
+            , it "forBackends groups multiple modifiers" $ do
+                let mi =
+                      foldModifiers
+                        backendProto
+                        [forBackends [backendProto] [rename "p", tag 9]]
+                fmap miRename mi `shouldBe` Right (Just (RenameSpecLiteral "p"))
+                fmap miTag mi `shouldBe` Right (Just 9)
+            , it "forBackends does nothing for unlisted backend" $
+                fmap
+                  miRename
+                  ( foldModifiers
+                      backendJSON
+                      [forBackends [backendProto] [rename "p"]]
+                  )
+                  `shouldBe` Right Nothing
+            ]
+      , describe "RenameSpec variants" $
+          sequence_
+            [ it "rename literal" $
+                fmap miRename (foldModifiers backendJSON [rename "n"])
+                  `shouldBe` Right (Just (RenameSpecLiteral "n"))
+            , it "renameStyle preserved" $
+                fmap
+                  miRename
+                  ( foldModifiers
+                      backendJSON
+                      [renameStyle (StripPrefix "person")]
+                  )
+                  `shouldBe` Right (Just (RenameSpecStyle (StripPrefix "person")))
+            ]
+      , describe "default rename keys" $
+          sequence_
+            [ it
+                "JSON default is camel"
+                (defaultRenameForBackend backendJSON "person_name" `shouldBe` "personName")
+            , it
+                "EDN default is kebab"
+                (defaultRenameForBackend backendEDN "personName" `shouldBe` "person-name")
+            , it
+                "CBOR default is verbatim"
+                (defaultRenameForBackend backendCBOR "personName" `shouldBe` "personName")
+            ]
+      , describe "ModCustom accumulation" $
+          sequence_
+            [ it "two custom payloads with same tag accumulate" $ do
+                let mi =
+                      foldModifiers
+                        backendCBOR
+                        [ customModifier "ext.foo" "x"
+                        , customModifier "ext.foo" "y"
+                        ]
+                case mi of
+                  Right info -> do
+                    let xs = Map.findWithDefault [] "ext.foo" (miCustom info)
+                    length xs `shouldBe` 2
+                  Left e -> expectationFailure ("unexpected " ++ show e)
+            , it "custom payloads with different tags don't conflict" $ do
+                let mi =
+                      foldModifiers
+                        backendCBOR
+                        [ customModifier "ext.foo" "x"
+                        , customModifier "ext.bar" "y"
+                        ]
+                case mi of
+                  Right info -> do
+                    (Map.member "ext.foo" (miCustom info)) `shouldBe` True
+                    (Map.member "ext.bar" (miCustom info)) `shouldBe` True
+                  Left e -> expectationFailure ("unexpected " ++ show e)
+            ]
       ]
-  ]
diff --git a/test/Test/Derive/NameStyle.hs b/test/Test/Derive/NameStyle.hs
--- a/test/Test/Derive/NameStyle.hs
+++ b/test/Test/Derive/NameStyle.hs
@@ -2,142 +2,185 @@
 
 module Test.Derive.NameStyle (tests) where
 
-import qualified Data.Char as Char
-import qualified Data.Text as T
+import Data.Char qualified as Char
+import Data.Text qualified as T
 import Hedgehog
-import qualified Hedgehog.Gen as Gen
-import qualified Hedgehog.Range as Range
-import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (testCase, (@?=))
-import Test.Tasty.Hedgehog (testProperty)
-
+import Hedgehog.Gen qualified as Gen
+import Hedgehog.Range qualified as Range
+import Test.Syd
+import Test.Syd.Hedgehog ()
 import Wireform.Derive.NameStyle
 
-tests :: TestTree
-tests = testGroup "NameStyle"
-  [ testGroup "applyStyle / golden"
-      [ testCase "snake personName"
-          (applyStyle SnakeCase "personName"  @?= "person_name")
-      , testCase "snake HTTPRequest"
-          (applyStyle SnakeCase "HTTPRequest" @?= "http_request")
-      , testCase "snake httpRequest"
-          (applyStyle SnakeCase "httpRequest" @?= "http_request")
-      , testCase "snake-of-snake personName"
-          (applyStyle SnakeCase "person_name" @?= "person_name")
-      , testCase "kebab personName"
-          (applyStyle KebabCase "personName"  @?= "person-name")
-      , testCase "kebab HTTPRequest"
-          (applyStyle KebabCase "HTTPRequest" @?= "http-request")
-      , testCase "camel of snake"
-          (applyStyle CamelCase  "person_name" @?= "personName")
-      , testCase "camel idempotent"
-          (applyStyle CamelCase  "personName"  @?= "personName")
-      , testCase "pascal of snake"
-          (applyStyle PascalCase "person_name" @?= "PersonName")
-      , testCase "pascal idempotent"
-          (applyStyle PascalCase "PersonName"  @?= "PersonName")
-      , testCase "upper snake"
-          (applyStyle UpperSnake "personName"  @?= "PERSON_NAME")
-      , testCase "upper kebab"
-          (applyStyle UpperKebab "personName"  @?= "PERSON-NAME")
-      , testCase "strip prefix present"
-          (applyStyle (StripPrefix "person") "personName" @?= "Name")
-      , testCase "strip prefix absent"
-          (applyStyle (StripPrefix "other") "personName" @?= "personName")
-      , testCase "strip prefix CI"
-          (applyStyle (StripPrefixCI "PERSON") "personName" @?= "Name")
-      , testCase "strip suffix present"
-          (applyStyle (StripSuffix "Name") "personName" @?= "person")
-      , testCase "compose strip + snake"
-          (applyStyle
-              (StripPrefix "person" `andThen` SnakeCase)
-              "personHttpRequest"
-              @?= "http_request")
-      , testCase "replace"
-          (applyStyle (Replace "Name" "Label") "personName" @?= "personLabel")
-      , testCase "drop / take"
-          (applyStyle (DropChars 6 `andThen` TakeChars 2) "personName" @?= "Na")
-      , testCase "Idiomatic falls through to NoStyle without resolution"
-          (applyStyle Idiomatic "personName" @?= "personName")
-      ]
 
-  , testGroup "Idiomatic resolution"
-      [ testCase "JSON resolves to CamelCase"
-          (applyStyle (resolveIdiomatic "json" Idiomatic) "person_name"
-             @?= "personName")
-      , testCase "EDN resolves to KebabCase"
-          (applyStyle (resolveIdiomatic "edn" Idiomatic) "personName"
-             @?= "person-name")
-      , testCase "Proto resolves to CamelCase"
-          (applyStyle (resolveIdiomatic "proto" Idiomatic) "person_name"
-             @?= "personName")
-      , testCase "TOML resolves to SnakeCase"
-          (applyStyle (resolveIdiomatic "toml" Idiomatic) "personName"
-             @?= "person_name")
-      , testCase "YAML resolves to KebabCase"
-          (applyStyle (resolveIdiomatic "yaml" Idiomatic) "personName"
-             @?= "person-name")
-      , testCase "XML resolves to PascalCase"
-          (applyStyle (resolveIdiomatic "xml" Idiomatic) "person_name"
-             @?= "PersonName")
-      , testCase "CBOR resolves to NoStyle (verbatim)"
-          (applyStyle (resolveIdiomatic "cbor" Idiomatic) "personName"
-             @?= "personName")
-      , testCase "compose preserves outer style"
-          (applyStyle (resolveIdiomatic "edn"
-              (StripPrefix "person" `andThen` Idiomatic))
-              "personHttpRequest"
-              @?= "http-request")
+tests :: Spec
+tests =
+  describe "NameStyle" $
+    sequence_
+      [ describe "applyStyle / golden" $
+          sequence_
+            [ it
+                "snake personName"
+                (applyStyle SnakeCase "personName" `shouldBe` "person_name")
+            , it
+                "snake HTTPRequest"
+                (applyStyle SnakeCase "HTTPRequest" `shouldBe` "http_request")
+            , it
+                "snake httpRequest"
+                (applyStyle SnakeCase "httpRequest" `shouldBe` "http_request")
+            , it
+                "snake-of-snake personName"
+                (applyStyle SnakeCase "person_name" `shouldBe` "person_name")
+            , it
+                "kebab personName"
+                (applyStyle KebabCase "personName" `shouldBe` "person-name")
+            , it
+                "kebab HTTPRequest"
+                (applyStyle KebabCase "HTTPRequest" `shouldBe` "http-request")
+            , it
+                "camel of snake"
+                (applyStyle CamelCase "person_name" `shouldBe` "personName")
+            , it
+                "camel idempotent"
+                (applyStyle CamelCase "personName" `shouldBe` "personName")
+            , it
+                "pascal of snake"
+                (applyStyle PascalCase "person_name" `shouldBe` "PersonName")
+            , it
+                "pascal idempotent"
+                (applyStyle PascalCase "PersonName" `shouldBe` "PersonName")
+            , it
+                "upper snake"
+                (applyStyle UpperSnake "personName" `shouldBe` "PERSON_NAME")
+            , it
+                "upper kebab"
+                (applyStyle UpperKebab "personName" `shouldBe` "PERSON-NAME")
+            , it
+                "strip prefix present"
+                (applyStyle (StripPrefix "person") "personName" `shouldBe` "Name")
+            , it
+                "strip prefix absent"
+                (applyStyle (StripPrefix "other") "personName" `shouldBe` "personName")
+            , it
+                "strip prefix CI"
+                (applyStyle (StripPrefixCI "PERSON") "personName" `shouldBe` "Name")
+            , it
+                "strip suffix present"
+                (applyStyle (StripSuffix "Name") "personName" `shouldBe` "person")
+            , it
+                "compose strip + snake"
+                ( applyStyle
+                    (StripPrefix "person" `andThen` SnakeCase)
+                    "personHttpRequest"
+                    `shouldBe` "http_request"
+                )
+            , it
+                "replace"
+                (applyStyle (Replace "Name" "Label") "personName" `shouldBe` "personLabel")
+            , it
+                "drop / take"
+                (applyStyle (DropChars 6 `andThen` TakeChars 2) "personName" `shouldBe` "Na")
+            , it
+                "Idiomatic falls through to NoStyle without resolution"
+                (applyStyle Idiomatic "personName" `shouldBe` "personName")
+            ]
+      , describe "Idiomatic resolution" $
+          sequence_
+            [ it
+                "JSON resolves to CamelCase"
+                ( applyStyle (resolveIdiomatic "json" Idiomatic) "person_name"
+                    `shouldBe` "personName"
+                )
+            , it
+                "EDN resolves to KebabCase"
+                ( applyStyle (resolveIdiomatic "edn" Idiomatic) "personName"
+                    `shouldBe` "person-name"
+                )
+            , it
+                "Proto resolves to CamelCase"
+                ( applyStyle (resolveIdiomatic "proto" Idiomatic) "person_name"
+                    `shouldBe` "personName"
+                )
+            , it
+                "TOML resolves to SnakeCase"
+                ( applyStyle (resolveIdiomatic "toml" Idiomatic) "personName"
+                    `shouldBe` "person_name"
+                )
+            , it
+                "YAML resolves to KebabCase"
+                ( applyStyle (resolveIdiomatic "yaml" Idiomatic) "personName"
+                    `shouldBe` "person-name"
+                )
+            , it
+                "XML resolves to PascalCase"
+                ( applyStyle (resolveIdiomatic "xml" Idiomatic) "person_name"
+                    `shouldBe` "PersonName"
+                )
+            , it
+                "CBOR resolves to NoStyle (verbatim)"
+                ( applyStyle (resolveIdiomatic "cbor" Idiomatic) "personName"
+                    `shouldBe` "personName"
+                )
+            , it
+                "compose preserves outer style"
+                ( applyStyle
+                    ( resolveIdiomatic
+                        "edn"
+                        (StripPrefix "person" `andThen` Idiomatic)
+                    )
+                    "personHttpRequest"
+                    `shouldBe` "http-request"
+                )
+            ]
+      , describe "properties" $
+          sequence_
+            [ it "NoStyle is identity" $ property $ do
+                t <- forAll genIdent
+                applyStyle NoStyle t === t
+            , it "Compose NoStyle a == a" $ property $ do
+                t <- forAll genIdent
+                applyStyle (Compose NoStyle SnakeCase) t === applyStyle SnakeCase t
+            , it "snake then camel preserves alpha" $ property $ do
+                t <- forAll genCamelIdent
+                let snaked = applyStyle SnakeCase t
+                    roundTripped = applyStyle CamelCase snaked
+                T.toLower roundTripped === T.toLower t
+            , it "snake produces lowercase output" $ property $ do
+                t <- forAll genIdent
+                let s = applyStyle SnakeCase t
+                assert (T.all (\c -> not (Char.isUpper c)) s)
+            , it "kebab produces lowercase output" $ property $ do
+                t <- forAll genIdent
+                let s = applyStyle KebabCase t
+                assert (T.all (\c -> not (Char.isUpper c)) s)
+            , it "applyStyle is total (never bottom)" $ property $ do
+                t <- forAll genIdent
+                let len = T.length (applyStyle SnakeCase t)
+                assert (len >= 0)
+            ]
       ]
 
-  , testGroup "properties"
-      [ testProperty "NoStyle is identity" $ property $ do
-          t <- forAll genIdent
-          applyStyle NoStyle t === t
 
-      , testProperty "Compose NoStyle a == a" $ property $ do
-          t <- forAll genIdent
-          applyStyle (Compose NoStyle SnakeCase) t === applyStyle SnakeCase t
-
-      , testProperty "snake then camel preserves alpha" $ property $ do
-          t <- forAll genCamelIdent
-          let snaked = applyStyle SnakeCase t
-              roundTripped = applyStyle CamelCase snaked
-          T.toLower roundTripped === T.toLower t
-
-      , testProperty "snake produces lowercase output" $ property $ do
-          t <- forAll genIdent
-          let s = applyStyle SnakeCase t
-          assert (T.all (\c -> not (Char.isUpper c)) s)
-
-      , testProperty "kebab produces lowercase output" $ property $ do
-          t <- forAll genIdent
-          let s = applyStyle KebabCase t
-          assert (T.all (\c -> not (Char.isUpper c)) s)
-
-      , testProperty "applyStyle is total (never bottom)" $ property $ do
-          t <- forAll genIdent
-          let len = T.length (applyStyle SnakeCase t)
-          assert (len >= 0)
-      ]
-  ]
-
 -- ---------------------------------------------------------------------------
 -- Generators
 -- ---------------------------------------------------------------------------
 
 -- Identifier-like text: letters, digits, underscores; non-empty.
 genIdent :: MonadGen m => m T.Text
-genIdent = T.pack <$> Gen.list (Range.linear 1 20)
-  (Gen.frequency
-    [ (5, Gen.alpha)
-    , (1, Gen.element ['_'])
-    , (1, Gen.digit)
-    ])
+genIdent =
+  T.pack
+    <$> Gen.list
+      (Range.linear 1 20)
+      ( Gen.frequency
+          [ (5, Gen.alpha)
+          , (1, Gen.element ['_'])
+          , (1, Gen.digit)
+          ]
+      )
 
+
 -- camelCase identifier: starts lower, no underscores, alphanumeric only.
 genCamelIdent :: MonadGen m => m T.Text
 genCamelIdent = do
   first <- Gen.lower
-  rest  <- Gen.list (Range.linear 0 12) Gen.alphaNum
+  rest <- Gen.list (Range.linear 0 12) Gen.alphaNum
   pure (T.pack (first : rest))
diff --git a/wireform-derive.cabal b/wireform-derive.cabal
--- a/wireform-derive.cabal
+++ b/wireform-derive.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: wireform-derive
-version: 0.1.0.0
+version: 0.2.0.0
 synopsis: Annotation-driven Template Haskell deriver core for wireform
 description:
   Shared @Modifier@ annotation vocabulary and Template Haskell
@@ -88,7 +88,7 @@
     bytestring >= 0.11 && < 0.13,
     containers >= 0.6 && < 0.9,
     text >= 2.0 && < 2.2,
-    template-haskell >= 2.18 && < 2.24,
+    template-haskell >= 2.18 && < 2.25,
     th-abstraction >= 0.5 && < 0.8,
     mtl >= 2.2 && < 2.4,
     transformers >= 0.5 && < 0.7,
@@ -120,6 +120,5 @@
     aeson,
     template-haskell,
     hedgehog >= 1.0 && < 1.8,
-    tasty >= 1.4 && < 1.6,
-    tasty-hedgehog >= 1.0 && < 1.8,
-    tasty-hunit >= 0.10 && < 0.11
+    sydtest,
+    sydtest-hedgehog
