diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,28 @@
 # Changelog for wireform-proto
 
 
+## 0.3.0.0 -- 2026-11-06
+
+* Breaking generated-code change: Editions singular scalar and enum fields
+  use `Maybe` for explicit presence, including the default in Edition 2023
+  and later. Both standalone codegen and `loadProto` preserve explicitly
+  encoded default values. File/field `EXPLICIT` and `IMPLICIT` overrides,
+  including aggregate option syntax, are resolved consistently. Unsupported
+  `LEGACY_REQUIRED` presence now produces a generation error.
+* Accept quoted ProtoJSON numbers with `e`/`E` and signed exponents in
+  generated scalar/optional/oneof float and double decoders and the runtime
+  numeric helpers. Share the numeric grammar with the integer helpers;
+  retain full input consumption, canonical special-value spellings and
+  target-type overflow checks.
+* Fix standalone integer-map encoders by applying composed value encoders
+  as complete functions. Encode and size `sint32`/`sint64` map values with
+  ZigZag, matching their decoders and the TH path.
+* Stream large string fields across bounded ring chunks without a
+  contiguous reservation larger than the ring; retain the direct-copy
+  fast path and avoid intermediate pinned bytestrings.
+* Add runtime regressions using real standalone-generated and TH-generated
+  messages, with a reproducible fixture generator and drift check.
+
 ## 0.2.0.0 -- 2026-07-16
 
 Breaking release from 0.1.0.0.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -389,7 +389,7 @@
 | :-------- | -------------: | ---------: | ----: |
 | Small     |        41.6 ns |    81.6 ns | 1.96x |
 | Medium    |         111 ns |     212 ns | 1.90x |
-| Nested    |       80.10 ns |     151 ns | 1.87x |
+| Nested    |        81.0 ns |     151 ns | 1.87x |
 | Repeated  |        1422 ns |    2293 ns | 1.61x |
 
 <sub>Last run 2026-06-27 11:56:42 UTC. ghc-9.8.4 on darwin-aarch64, criterion 1.6.5.</sub>
@@ -404,6 +404,17 @@
 ---
 
 ## Also included
+
+### Editions field presence
+
+Standalone generation and `loadProto` retain explicit presence for Editions
+singular scalar and enum fields using `Maybe`. An explicitly encoded zero
+round-trips as `Just 0`; an absent field remains `Nothing`. File and field
+`features.field_presence` settings support `EXPLICIT` and `IMPLICIT`, using
+both dotted and aggregate syntax. `LEGACY_REQUIRED` is rejected during
+code generation because required-field validation is not implemented.
+This is a source-level change from 0.2 for generated Editions records;
+proto2 and proto3 carriers keep their existing shapes.
 
 ### Proto3 canonical JSON
 
diff --git a/src/Proto/CodeGen.hs b/src/Proto/CodeGen.hs
--- a/src/Proto/CodeGen.hs
+++ b/src/Proto/CodeGen.hs
@@ -98,6 +98,7 @@
 import Proto.IDL.Options.Custom (emptyCustomOptionRegistry, extractExtensionOptions, registerCustomOption)
 import Proto.IDL.Parser.Resolver (ResolvedProto (..))
 import Proto.Internal.CodeGen.Combinators (braceBlock, instanceHead, tshow, txt)
+import Proto.Internal.FieldPresence (resolveFieldPresence)
 
 
 -- | Emit a Haddock doc comment block from a proto doc comment.
@@ -480,8 +481,9 @@
 
 -- | Generate a complete Haskell module as a 'Doc' from a proto file.
 generateModule :: GenerateOpts -> TypeRegistry -> FilePath -> ProtoFile -> Doc ann
-generateModule opts reg filePath pf =
-  let thisMod = moduleNameForProto opts filePath pf
+generateModule opts reg filePath sourceFile =
+  let pf = either (error . T.unpack) id (resolveFieldPresence sourceFile)
+      thisMod = moduleNameForProto opts filePath pf
       localTypes = collectLocalTypes (fromMaybe "" (protoPackage pf)) [] (protoTopLevels pf)
       ctx =
         GenCtx
@@ -513,7 +515,7 @@
        , mempty
        , genImports importedModules
        , mempty
-       , genFileDescriptorBinding filePath pf
+       , genFileDescriptorBinding filePath sourceFile
        , mempty
        , vsep body
        ]
@@ -1117,13 +1119,15 @@
       SString -> txt "encodeFieldString " <> pretty (T.pack (show fn))
       SBytes -> txt "encodeFieldBytes " <> pretty (T.pack (show fn))
       SBool -> txt "encodeFieldBool " <> pretty (T.pack (show fn))
+      SSInt32 -> txt "encodeFieldSVarint32 " <> pretty (T.pack (show fn))
+      SSInt64 -> txt "encodeFieldSVarint64 " <> pretty (T.pack (show fn))
       SDouble -> txt "encodeFieldDouble " <> pretty (T.pack (show fn))
       SFloat -> txt "encodeFieldFloat " <> pretty (T.pack (show fn))
       SFixed32 -> txt "encodeFieldFixed32 " <> pretty (T.pack (show fn))
       SFixed64 -> txt "encodeFieldFixed64 " <> pretty (T.pack (show fn))
-      SSFixed32 -> txt "encodeFieldFixed32 " <> pretty (T.pack (show fn)) <> txt " . fromIntegral"
-      SSFixed64 -> txt "encodeFieldFixed64 " <> pretty (T.pack (show fn)) <> txt " . fromIntegral"
-      _ -> txt "encodeFieldVarint " <> pretty (T.pack (show fn)) <> txt " . fromIntegral"
+      SSFixed32 -> txt "(encodeFieldFixed32 " <> pretty (T.pack (show fn)) <> txt " . fromIntegral)"
+      SSFixed64 -> txt "(encodeFieldFixed64 " <> pretty (T.pack (show fn)) <> txt " . fromIntegral)"
+      _ -> txt "(encodeFieldVarint " <> pretty (T.pack (show fn)) <> txt " . fromIntegral)"
 
 
 genBuildExprOneof :: GenCtx -> [Text] -> Text -> Text -> OneofDef -> Doc ann
@@ -1355,6 +1359,8 @@
 
 mapValSizeExpr :: GenCtx -> FieldType -> Doc ann
 mapValSizeExpr ctx = \case
+  FTScalar SSInt32 -> txt "fieldSVarint32Size 2 v"
+  FTScalar SSInt64 -> txt "fieldSVarint64Size 2 v"
   FTScalar SString -> txt "fieldTextSize 2 v"
   FTScalar SBytes -> txt "fieldBytesSize 2 v"
   FTScalar SBool -> txt "fieldBoolSize 2"
diff --git a/src/Proto/Internal/FieldPresence.hs b/src/Proto/Internal/FieldPresence.hs
new file mode 100644
--- /dev/null
+++ b/src/Proto/Internal/FieldPresence.hs
@@ -0,0 +1,65 @@
+{- | Resolve Editions field presence before either generator chooses field
+carriers. The public parser retains the original source-level labels.
+-}
+module Proto.Internal.FieldPresence (resolveFieldPresence) where
+
+import Data.List (find)
+import Data.Text (Text)
+import Proto.IDL.AST
+import Proto.IDL.Annotations (lookupSimpleOption)
+
+
+resolveFieldPresence :: ProtoFile -> Either Text ProtoFile
+resolveFieldPresence pf = case protoSyntax pf of
+  Editions edition -> do
+    filePresence <- presenceOption (featureFieldPresence (featuresForEdition edition)) (protoOptions pf)
+    topLevels <- traverse (topLevel filePresence) (protoTopLevels pf)
+    pure pf {protoTopLevels = topLevels}
+  _ -> Right pf
+  where
+    topLevel presence = \case
+      TLMessage msg -> TLMessage <$> message presence msg
+      TLExtend name fields -> TLExtend name <$> traverse (field presence) fields
+      other -> Right other
+    message presence msg = do
+      elements <- traverse (element presence) (msgElements msg)
+      pure msg {msgElements = elements}
+    element presence = \case
+      MEField fd -> MEField <$> field presence fd
+      MEMessage msg -> MEMessage <$> message presence msg
+      MEExtend name fields -> MEExtend name <$> traverse (field presence) fields
+      other -> Right other
+    field presence fd = case fieldLabel fd of
+      Just Repeated -> Right fd
+      _ -> do
+        resolved <- presenceOption presence (fieldOptions fd)
+        pure
+          fd
+            { fieldLabel = case resolved of
+                ExplicitPresence -> Just Optional
+                ImplicitPresence -> Nothing
+                LegacyRequired -> Just Required
+            }
+
+
+-- Field presence is scoped to files and fields, not enclosing messages.
+-- Accept both dotted and aggregate feature-option spellings.
+presenceOption :: FieldPresenceFeature -> [OptionDef] -> Either Text FieldPresenceFeature
+presenceOption fallback opts = case setting of
+  Nothing -> Right fallback
+  Just (CIdent "EXPLICIT") -> Right ExplicitPresence
+  Just (CIdent "IMPLICIT") -> Right ImplicitPresence
+  Just (CIdent "LEGACY_REQUIRED") ->
+    Left "features.field_presence = LEGACY_REQUIRED is not supported by the code generators"
+  Just _ -> Left "features.field_presence must be EXPLICIT or IMPLICIT"
+  where
+    setting = case find isPresence opts of
+      Just option -> Just (optValue option)
+      Nothing -> case lookupSimpleOption "features" opts of
+        Just (CAggregate values) -> lookup "field_presence" values
+        _ -> Nothing
+
+    isPresence option = case optNameParts (optName option) of
+      [SimpleOption "features", SimpleOption "field_presence"] -> True
+      [SimpleOption "features.field_presence"] -> True
+      _ -> False
diff --git a/src/Proto/Internal/JSON.hs b/src/Proto/Internal/JSON.hs
--- a/src/Proto/Internal/JSON.hs
+++ b/src/Proto/Internal/JSON.hs
@@ -89,12 +89,13 @@
 import Data.Int (Int64)
 import Data.Map.Strict (Map)
 import Data.Map.Strict qualified as Map
-import Data.Scientific (Scientific, fromFloatDigits, toBoundedInteger, toRealFloat)
+import Data.Scientific (Scientific, fromFloatDigits, toBoundedInteger)
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Text.Encoding qualified as TE
 import Data.Text.Lazy qualified as TL
 import Data.Word (Word64)
+import Proto.Internal.JSON.Number qualified as Number
 
 
 -- | Build a JSON object from key-value pairs (for generated code).
@@ -178,24 +179,9 @@
 protoWord64FromJSON _ = fail "Expected uint64 string or number"
 
 
-{- | Parse a 'Scientific' from a textual JSON value. Used for
-the 64-bit-int code path (proto3 spec encodes them as strings
-on output, accepts both shapes on input).
-
-@reads@ is unfortunate but @Data.Text.Read@ doesn't export a
-'Scientific' parser, and pulling in @attoparsec@ here just
-for one helper isn't worth it.
--}
+-- | Parse a quoted ProtoJSON number, including exponent notation.
 sciFromText :: Text -> Aeson.Parser Scientific
-sciFromText t
-  | hasLeadingSpace t = fail ("Invalid numeric string (leading whitespace): " <> show t)
-  | otherwise = case reads (T.unpack t) :: [(Scientific, String)] of
-      [(s, "")] -> pure s
-      _ -> fail ("Invalid numeric string: " <> show t)
-  where
-    hasLeadingSpace s = case T.uncons s of
-      Just (c, _) -> c == ' ' || c == '\t' || c == '\n' || c == '\r'
-      Nothing -> True
+sciFromText = Number.numericString
 
 
 {- | Coerce a 'Scientific' to a bounded integral type, failing
@@ -231,16 +217,12 @@
 
 -- | Parse a 'Double' from JSON, accepting NaN\/Infinity string sentinels.
 protoDoubleFromJSON :: Aeson.Value -> Aeson.Parser Double
-protoDoubleFromJSON (Aeson.Number n) = pure (toRealFloat n)
-protoDoubleFromJSON (Aeson.String "NaN") = pure (0 / 0)
-protoDoubleFromJSON (Aeson.String "Infinity") = pure (1 / 0)
-protoDoubleFromJSON (Aeson.String "-Infinity") = pure (negate (1 / 0))
-protoDoubleFromJSON _ = fail "Expected number or special float string"
+protoDoubleFromJSON = Number.floating
 
 
--- | Parse a 'Float' from JSON, accepting NaN\/Infinity string sentinels.
+-- | Parse a 'Float' with ProtoJSON sentinel, exponent and overflow handling.
 protoFloatFromJSON :: Aeson.Value -> Aeson.Parser Float
-protoFloatFromJSON v = realToFrac <$> protoDoubleFromJSON v
+protoFloatFromJSON = Number.floating
 
 
 -- Proto3 canonical JSON: bytes as base64.
diff --git a/src/Proto/Internal/JSON/Number.hs b/src/Proto/Internal/JSON/Number.hs
new file mode 100644
--- /dev/null
+++ b/src/Proto/Internal/JSON/Number.hs
@@ -0,0 +1,37 @@
+-- | Shared ProtoJSON numeric parsing for runtime helpers and generated codecs.
+module Proto.Internal.JSON.Number (numericString, floating) where
+
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Types qualified as Aeson
+import Data.Char (isSpace)
+import Data.Scientific (Scientific, toRealFloat)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as TE
+
+
+{- | Use JSON's full numeric grammar, including signed e/E exponents, with
+complete consumption. Whitespace inside a quoted number is not permitted.
+-}
+numericString :: Text -> Aeson.Parser Scientific
+numericString t
+  | T.null t || T.any isSpace t = fail "Invalid numeric string (empty or whitespace)"
+  | otherwise = case Aeson.eitherDecodeStrict' (TE.encodeUtf8 t) of
+      Right (Aeson.Number n) -> pure n
+      _ -> fail "Invalid numeric string"
+
+
+floating :: forall a. RealFloat a => Aeson.Value -> Aeson.Parser a
+floating = \case
+  Aeson.Number n -> finite n
+  Aeson.String "NaN" -> pure (0 / 0)
+  Aeson.String "Infinity" -> pure (1 / 0)
+  Aeson.String "-Infinity" -> pure (negate (1 / 0))
+  Aeson.String t -> numericString t >>= finite
+  _ -> fail "Expected JSON number or numeric string"
+  where
+    finite n =
+      let value = toRealFloat n :: a
+      in if isInfinite value
+           then fail "float/double overflow"
+           else pure value
diff --git a/src/Proto/Internal/Wire/Encode.hs b/src/Proto/Internal/Wire/Encode.hs
--- a/src/Proto/Internal/Wire/Encode.hs
+++ b/src/Proto/Internal/Wire/Encode.hs
@@ -64,7 +64,7 @@
 import Data.Text.Foreign qualified as TF
 import Data.Text.Internal qualified as TI
 import Data.Word (Word32, Word64)
-import GHC.Exts (ByteArray#, Int (..), Int#, copyByteArrayToAddr#, plusAddr#)
+import GHC.Exts (ByteArray#, Int (..), Int#, copyByteArrayToAddr#, isTrue#, minusAddr#, plusAddr#, (+#), (-#), (<=#))
 import Proto.Internal.Wire (WireType, fieldTag)
 import Wireform.Builder (Builder)
 import Wireform.Builder qualified as B
@@ -203,19 +203,28 @@
 
 This avoids the intermediate pinned 'BS.ByteString' allocation that
 @'B.byteStringCopy' . 'TE.encodeUtf8'@ would force. We use
-fast-builder's 'ensureBytes' to guarantee buffer space, then
-construct a raw 'Builder' that 'copyByteArrayToAddr#' 's the slice
-from the text's unpinned backing store into the builder's pinned
-buffer. Net cost is one buffer write, no heap allocation.
+a raw 'Builder' to copy each available chunk from the text's
+unpinned backing store. The fitting fast path performs one copy with
+no heap allocation; the overflow path advances the sink one chunk
+at a time, including rings smaller than the complete text payload.
 -}
 byteArraySliceBuilder :: ByteArray# -> Int# -> Int# -> Builder
 byteArraySliceBuilder arr# off# len# =
-  FBI.ensureBytes (I# len#)
-    <> FBI.Builder
-      ( \_ (# cur#, end#, s# #) ->
-          case copyByteArrayToAddr# arr# off# cur# len# s# of
-            s'# -> (# plusAddr# cur# len#, end#, s'# #)
-      )
+  FBI.Builder $ \sink (# cur#, end#, s# #) ->
+    let available# = minusAddr# end# cur#
+    in if isTrue# (len# <=# available#)
+         then case copyByteArrayToAddr# arr# off# cur# len# s# of
+           s'# -> (# plusAddr# cur# len#, end#, s'# #)
+         else
+           -- A ring cannot grant a contiguous reservation larger than itself.
+           -- Fill the current chunk, request one byte to advance/flush the
+           -- sink, and continue without an intermediate pinned ByteString.
+           case copyByteArrayToAddr# arr# off# cur# available# s# of
+             s'# ->
+               FBI.unBuilder
+                 (FBI.ensureBytes 1 <> byteArraySliceBuilder arr# (off# +# available#) (len# -# available#))
+                 sink
+                 (# end#, end#, s'# #)
 {-# INLINE byteArraySliceBuilder #-}
 
 
diff --git a/src/Proto/TH.hs b/src/Proto/TH.hs
--- a/src/Proto/TH.hs
+++ b/src/Proto/TH.hs
@@ -126,8 +126,6 @@
 import GHC.Generics (Generic)
 import Language.Haskell.TH
 import Language.Haskell.TH.Syntax (addDependentFile, addModFinalizer)
-import System.Directory (doesFileExist)
-import System.FilePath ((</>))
 import Proto.CodeGen (
   FieldNaming (..),
   escapeReserved,
@@ -156,10 +154,13 @@
 import Proto.IDL.Options.Custom (emptyCustomOptionRegistry, extractExtensionOptions, registerCustomOption)
 import Proto.IDL.Parser (parseProtoFile, renderParseError)
 import Proto.Internal.Derive qualified as PDI
+import Proto.Internal.FieldPresence (resolveFieldPresence)
 import Proto.Internal.JSON.Extension qualified as PJExt
 import Proto.Repr
 import Proto.Schema qualified as PS
 import Proto.TH.Metadata qualified as PTM
+import System.Directory (doesFileExist)
+import System.FilePath ((</>))
 import Wireform.Derive.Modifier (MapKeyScalar (..))
 
 
@@ -360,7 +361,8 @@
 on name clashes.
 -}
 protoFileToDeclsScoped :: FieldNaming -> RepConfig -> THHooks -> [TopLevel] -> ProtoFile -> Q [Dec]
-protoFileToDeclsScoped naming cfg hooks importedTopLevels pf = do
+protoFileToDeclsScoped naming cfg hooks importedTopLevels sourceFile = do
+  pf <- either (fail . T.unpack) pure (resolveFieldPresence sourceFile)
   let scope =
         ScopeCtx
           { scSyntax = protoSyntax pf
diff --git a/src/Proto/TH/Metadata.hs b/src/Proto/TH/Metadata.hs
--- a/src/Proto/TH/Metadata.hs
+++ b/src/Proto/TH/Metadata.hs
@@ -102,6 +102,7 @@
 import Proto.Internal.JSON qualified as PJ
 import Proto.Internal.JSON qualified as PJI
 import Proto.Internal.JSON.Extension qualified as PJExt
+import Proto.Internal.JSON.Number qualified as Number
 import Proto.Internal.JSON.WellKnown qualified as WK
 import Proto.Schema qualified as PS
 
@@ -415,11 +416,12 @@
     ]
 
 
--- | Read a record field expression: record-dot @msg.field@ when the
--- record uses the unprefixed layout ('NoFieldSelectors' + shared field
--- names), else the bare selector application @field msg@. The label for
--- the record-dot form is the selector's base name — the field name in
--- either layout.
+{- | Read a record field expression: record-dot @msg.field@ when the
+record uses the unprefixed layout ('NoFieldSelectors' + shared field
+names), else the bare selector application @field msg@. The label for
+the record-dot form is the selector's base name — the field name in
+either layout.
+-}
 dotRead :: Bool -> Name -> Name -> Exp
 dotRead recDot sel msgVar
   | recDot = GetFieldE (VarE msgVar) (nameBase sel)
@@ -1368,20 +1370,7 @@
 -}
 protoFloatFromJSONLenient
   :: forall a. (RealFloat a) => Aeson.Value -> AesonT.Parser a
-protoFloatFromJSONLenient v = case v of
-  Aeson.Number n -> finite n
-  Aeson.String "NaN" -> pure (0 / 0)
-  Aeson.String "Infinity" -> pure (1 / 0)
-  Aeson.String "-Infinity" -> pure (negate (1 / 0))
-  Aeson.String s -> sciFromText32 s >>= finite
-  _ -> fail "Expected JSON Number or numeric String"
-  where
-    finite :: Sci.Scientific -> AesonT.Parser a
-    finite n =
-      let d = Sci.toRealFloat n :: a
-      in if isInfinite d
-           then fail ("float/double overflow: " <> show n)
-           else pure d
+protoFloatFromJSONLenient = Number.floating
 
 
 {- | Parse @Maybe (Map Text v)@ where @v@ is a generated
@@ -1636,20 +1625,9 @@
   _ -> fail "Expected JSON Number or String for UInt32"
 
 
-{- | Parse 'Scientific' from a JSON-quoted numeric string. We
-can't reuse 'PJ.sciFromText' without adding a Proto.Internal.JSON
-dependency edge, so duplicate the trivial implementation.
--}
+-- | Shared full-consumption ProtoJSON number parser.
 sciFromText32 :: Text -> AesonT.Parser Sci.Scientific
-sciFromText32 t
-  | hasLeadingWs t = fail ("Invalid numeric string (leading whitespace): " <> show t)
-  | otherwise = case reads (T.unpack t) :: [(Sci.Scientific, String)] of
-      [(s, "")] -> pure s
-      _ -> fail ("Invalid numeric string: " <> show t)
-  where
-    hasLeadingWs s = case T.uncons s of
-      Just (c, _) -> c == ' ' || c == '\t' || c == '\n' || c == '\r'
-      Nothing -> True
+sciFromText32 = Number.numericString
 
 
 {- | Bounded-integer narrowing for 32-bit fields. Mirrors
diff --git a/test-conformance/failure_list_proto3.txt b/test-conformance/failure_list_proto3.txt
new file mode 100644
--- /dev/null
+++ b/test-conformance/failure_list_proto3.txt
@@ -0,0 +1,19 @@
+# Expected-failure list for the protobuf-conformance suite.
+# Covers BOTH the proto3 and the proto2 binary/json suites
+# (despite the legacy filename) — the conformance runner
+# applies a single failure list to whichever suite it's
+# currently running.
+#
+# Status: 0 expected failures / 2675 successes / 0 skipped /
+# 0 unexpected failures.
+#
+# Add an entry below when a test is intentionally not supported
+# (open issues should be tracked separately). The runner emits
+# every test under a unique '<level>.<edition>.<input>.<test
+# name>.<output>' identifier — paste the full identifier on its
+# own line to suppress it.
+#
+# Re-run procedure:
+#   cabal test wireform-proto:protobuf-conformance-test \
+#     2>&1 | tee /tmp/conformance.log
+# then mine /tmp/conformance.log for new failure names.
diff --git a/test-conformance/protos/conformance.proto b/test-conformance/protos/conformance.proto
new file mode 100644
--- /dev/null
+++ b/test-conformance/protos/conformance.proto
@@ -0,0 +1,87 @@
+// Vendored from https://github.com/protocolbuffers/protobuf/blob/main/conformance/conformance.proto
+//
+// This is the wire protocol the upstream conformance_test_runner
+// uses to drive any test program. Each test sends one
+// ConformanceRequest over stdin and expects one ConformanceResponse
+// back over stdout. See the upstream README for the runner's
+// command-line interface and the test categories it covers.
+//
+// We pull this in with loadProto in
+// `Test.Conformance.RunnerInstances` so the runner binary's
+// codecs come from the same machinery the rest of wireform-proto
+// uses (and consequently a regression in the deriver shows up
+// immediately as a conformance-suite mismatch).
+
+syntax = "proto3";
+
+package conformance;
+
+enum WireFormat {
+  UNSPECIFIED = 0;
+  PROTOBUF = 1;
+  JSON = 2;
+  JSPB = 3;
+  TEXT_FORMAT = 4;
+}
+
+enum TestCategory {
+  UNSPECIFIED_TEST = 0;
+  BINARY_TEST = 1;
+  JSON_TEST = 2;
+  JSON_IGNORE_UNKNOWN_PARSING_TEST = 3;
+  JSPB_TEST = 4;
+  TEXT_FORMAT_TEST = 5;
+}
+
+message TestStatus {
+  string name = 1;
+  string failure_message = 2;
+  string matched_name = 3;
+}
+
+message FailureSet {
+  repeated TestStatus test = 2;
+}
+
+message ConformanceRequest {
+  // The upstream schema models payload as a oneof; we honour
+  // that. The bridge's auto-detected oneof codec generates a
+  // sum-of-tagged-singletons carrier (ConformanceRequest'Payload)
+  // plus the wire round-trip in 'Proto.Derive.Internal'.
+  oneof payload {
+    bytes  protobuf_payload = 1;
+    string json_payload     = 2;
+    string jspb_payload     = 7;
+    string text_payload     = 8;
+  }
+
+  WireFormat requested_output_format = 3;
+  string message_type = 4;
+  TestCategory test_category = 5;
+
+  JspbEncodingConfig jspb_encoding_options = 6;
+  bool print_unknown_fields = 9;
+}
+
+message ConformanceResponse {
+  // The upstream schema is also a oneof. Routing our response
+  // through the oneof carrier means the wire-format presence of
+  // the chosen arm is preserved end-to-end, which the conformance
+  // runner relies on (an empty 'protobuf_payload' must be
+  // distinguishable from an absent one).
+  oneof result {
+    string parse_error      = 1;
+    string serialize_error  = 6;
+    string timeout_error    = 9;
+    string runtime_error    = 2;
+    bytes  protobuf_payload = 3;
+    string json_payload     = 4;
+    string skipped          = 5;
+    string jspb_payload     = 7;
+    string text_payload     = 8;
+  }
+}
+
+message JspbEncodingConfig {
+  bool use_jspb_array_any_format = 1;
+}
diff --git a/test-conformance/protos/test_messages_proto2.proto b/test-conformance/protos/test_messages_proto2.proto
new file mode 100644
--- /dev/null
+++ b/test-conformance/protos/test_messages_proto2.proto
@@ -0,0 +1,233 @@
+// Stripped vendor of upstream protobuf
+// src/google/protobuf/test_messages_proto2.proto. We drop the
+// proto2 features 'loadProto' doesn't yet support and that the
+// conformance suite doesn't strictly require:
+//
+//   * 'group' declarations and group-typed fields (legacy
+//     proto2-only wire format using START_GROUP / END_GROUP).
+//     Tests targeting them stay 'skipped'.
+//   * 'message_set_wire_format' (proto1-era extension layout).
+//
+// The remaining surface (singular optional/required scalars,
+// repeated, packed/unpacked, maps, enums, oneofs, recursive
+// messages, default field values, extensions declarations,
+// and the FieldName1..18 stress-test fields) is the spec area
+// the binary/json conformance suite exercises.
+
+syntax = "proto2";
+
+package protobuf_test_messages.proto2;
+
+message TestAllTypesProto2 {
+  message NestedMessage {
+    optional int32 a = 1;
+    optional TestAllTypesProto2 corecursive = 2;
+  }
+
+  enum NestedEnum {
+    FOO = 0;
+    BAR = 1;
+    BAZ = 2;
+    NEG = -1;
+  }
+
+  // Singular
+  optional int32 optional_int32 = 1;
+  optional int64 optional_int64 = 2;
+  optional uint32 optional_uint32 = 3;
+  optional uint64 optional_uint64 = 4;
+  optional sint32 optional_sint32 = 5;
+  optional sint64 optional_sint64 = 6;
+  optional fixed32 optional_fixed32 = 7;
+  optional fixed64 optional_fixed64 = 8;
+  optional sfixed32 optional_sfixed32 = 9;
+  optional sfixed64 optional_sfixed64 = 10;
+  optional float optional_float = 11;
+  optional double optional_double = 12;
+  optional bool optional_bool = 13;
+  optional string optional_string = 14;
+  optional bytes optional_bytes = 15;
+
+  optional NestedMessage optional_nested_message = 18;
+  optional ForeignMessageProto2 optional_foreign_message = 19;
+
+  optional NestedEnum optional_nested_enum = 21;
+  optional ForeignEnumProto2 optional_foreign_enum = 22;
+
+  optional TestAllTypesProto2 recursive_message = 27;
+
+  // Repeated
+  repeated int32 repeated_int32 = 31;
+  repeated int64 repeated_int64 = 32;
+  repeated uint32 repeated_uint32 = 33;
+  repeated uint64 repeated_uint64 = 34;
+  repeated sint32 repeated_sint32 = 35;
+  repeated sint64 repeated_sint64 = 36;
+  repeated fixed32 repeated_fixed32 = 37;
+  repeated fixed64 repeated_fixed64 = 38;
+  repeated sfixed32 repeated_sfixed32 = 39;
+  repeated sfixed64 repeated_sfixed64 = 40;
+  repeated float repeated_float = 41;
+  repeated double repeated_double = 42;
+  repeated bool repeated_bool = 43;
+  repeated string repeated_string = 44;
+  repeated bytes repeated_bytes = 45;
+
+  repeated NestedMessage repeated_nested_message = 48;
+  repeated ForeignMessageProto2 repeated_foreign_message = 49;
+
+  repeated NestedEnum repeated_nested_enum = 51;
+  repeated ForeignEnumProto2 repeated_foreign_enum = 52;
+
+  // Packed
+  repeated int32 packed_int32 = 75 [packed = true];
+  repeated int64 packed_int64 = 76 [packed = true];
+  repeated uint32 packed_uint32 = 77 [packed = true];
+  repeated uint64 packed_uint64 = 78 [packed = true];
+  repeated sint32 packed_sint32 = 79 [packed = true];
+  repeated sint64 packed_sint64 = 80 [packed = true];
+  repeated fixed32 packed_fixed32 = 81 [packed = true];
+  repeated fixed64 packed_fixed64 = 82 [packed = true];
+  repeated sfixed32 packed_sfixed32 = 83 [packed = true];
+  repeated sfixed64 packed_sfixed64 = 84 [packed = true];
+  repeated float packed_float = 85 [packed = true];
+  repeated double packed_double = 86 [packed = true];
+  repeated bool packed_bool = 87 [packed = true];
+  repeated NestedEnum packed_nested_enum = 88 [packed = true];
+
+  // Unpacked
+  repeated int32 unpacked_int32 = 89 [packed = false];
+  repeated int64 unpacked_int64 = 90 [packed = false];
+  repeated uint32 unpacked_uint32 = 91 [packed = false];
+  repeated uint64 unpacked_uint64 = 92 [packed = false];
+  repeated sint32 unpacked_sint32 = 93 [packed = false];
+  repeated sint64 unpacked_sint64 = 94 [packed = false];
+  repeated fixed32 unpacked_fixed32 = 95 [packed = false];
+  repeated fixed64 unpacked_fixed64 = 96 [packed = false];
+  repeated sfixed32 unpacked_sfixed32 = 97 [packed = false];
+  repeated sfixed64 unpacked_sfixed64 = 98 [packed = false];
+  repeated float unpacked_float = 99 [packed = false];
+  repeated double unpacked_double = 100 [packed = false];
+  repeated bool unpacked_bool = 101 [packed = false];
+  repeated NestedEnum unpacked_nested_enum = 102 [packed = false];
+
+  // Map
+  map<int32, int32> map_int32_int32 = 56;
+  map<int64, int64> map_int64_int64 = 57;
+  map<uint32, uint32> map_uint32_uint32 = 58;
+  map<uint64, uint64> map_uint64_uint64 = 59;
+  map<sint32, sint32> map_sint32_sint32 = 60;
+  map<sint64, sint64> map_sint64_sint64 = 61;
+  map<fixed32, fixed32> map_fixed32_fixed32 = 62;
+  map<fixed64, fixed64> map_fixed64_fixed64 = 63;
+  map<sfixed32, sfixed32> map_sfixed32_sfixed32 = 64;
+  map<sfixed64, sfixed64> map_sfixed64_sfixed64 = 65;
+  map<int32, float> map_int32_float = 66;
+  map<int32, double> map_int32_double = 67;
+  map<bool, bool> map_bool_bool = 68;
+  map<string, string> map_string_string = 69;
+  map<string, bytes> map_string_bytes = 70;
+  map<string, NestedMessage> map_string_nested_message = 71;
+  map<string, ForeignMessageProto2> map_string_foreign_message = 72;
+  map<string, NestedEnum> map_string_nested_enum = 73;
+  map<string, ForeignEnumProto2> map_string_foreign_enum = 74;
+
+  oneof oneof_field {
+    uint32 oneof_uint32 = 111;
+    NestedMessage oneof_nested_message = 112;
+    string oneof_string = 113;
+    bytes oneof_bytes = 114;
+    bool oneof_bool = 115;
+    uint64 oneof_uint64 = 116;
+    float oneof_float = 117;
+    double oneof_double = 118;
+    NestedEnum oneof_enum = 119;
+  }
+
+  // Extensions placeholder (parser supports the declaration;
+  // the actual extension fields are in the `extend` blocks
+  // below).
+  extensions 120 to 200;
+
+  // Default values
+  optional int32 default_int32 = 241 [default = -123456789];
+  optional int64 default_int64 = 242 [default = -9123456789123456789];
+  optional uint32 default_uint32 = 243 [default = 2123456789];
+  optional uint64 default_uint64 = 244 [default = 10123456789123456789];
+  optional sint32 default_sint32 = 245 [default = -123456789];
+  optional sint64 default_sint64 = 246 [default = -9123456789123456789];
+  optional fixed32 default_fixed32 = 247 [default = 2123456789];
+  optional fixed64 default_fixed64 = 248 [default = 10123456789123456789];
+  optional sfixed32 default_sfixed32 = 249 [default = -123456789];
+  optional sfixed64 default_sfixed64 = 250 [default = -9123456789123456789];
+  optional float default_float = 251 [default = 9e9];
+  optional double default_double = 252 [default = 7e22];
+  optional bool default_bool = 253 [default = true];
+  optional string default_string = 254 [default = "Rosebud"];
+  optional bytes default_bytes = 255 [default = "joshua"];
+
+  // FieldName1..18 stress test (proto3 spec for snake_case <->
+  // lowerCamelCase JSON name conversion applies to proto2 too).
+  optional int32 fieldname1 = 401;
+  optional int32 field_name2 = 402;
+  optional int32 _field_name3 = 403;
+  optional int32 field__name4_ = 404;
+  optional int32 field0name5 = 405;
+  optional int32 field_0_name6 = 406;
+  optional int32 fieldName7 = 407;
+  optional int32 FieldName8 = 408;
+  optional int32 field_Name9 = 409;
+  optional int32 Field_Name10 = 410;
+  optional int32 FIELD_NAME11 = 411;
+  optional int32 FIELD_name12 = 412;
+  optional int32 __field_name13 = 413;
+  optional int32 __Field_name14 = 414;
+  optional int32 field__name15 = 415;
+  optional int32 field__Name16 = 416;
+  optional int32 field_name17__ = 417;
+  optional int32 Field_name18__ = 418;
+
+  // Reserved for unknown fields test.
+  reserved 1000 to 9999;
+}
+
+message ForeignMessageProto2 {
+  optional int32 c = 1;
+}
+
+enum ForeignEnumProto2 {
+  FOREIGN_FOO = 0;
+  FOREIGN_BAR = 1;
+  FOREIGN_BAZ = 2;
+}
+
+extend TestAllTypesProto2 {
+  optional int32 extension_int32 = 120;
+}
+
+message UnknownToTestAllTypes {
+  optional int32 optional_int32 = 1001;
+  optional string optional_string = 1002;
+  optional ForeignMessageProto2 nested_message = 1003;
+  optional bool optional_bool = 1006;
+  repeated int32 repeated_int32 = 1011;
+}
+
+message NullHypothesisProto2 {}
+
+message EnumOnlyProto2 {
+  enum Bool {
+    kFalse = 0;
+    kTrue = 1;
+  }
+}
+
+message OneStringProto2 {
+  optional string data = 1;
+}
+
+message ProtoWithKeywords {
+  optional int32 inline = 1;
+  optional string concept = 2;
+  repeated string requires = 3;
+}
diff --git a/test-conformance/protos/test_messages_proto3.proto b/test-conformance/protos/test_messages_proto3.proto
new file mode 100644
--- /dev/null
+++ b/test-conformance/protos/test_messages_proto3.proto
@@ -0,0 +1,253 @@
+// Vendored (subset) from
+// https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/test_messages_proto3.proto
+//
+// We carry the full structural shape of TestAllTypesProto3
+// including the Well-Known-Types arms (Timestamp, Duration, Any,
+// Struct, Wrappers, FieldMask, Empty, Value, ListValue,
+// NullValue). 'loadProto' doesn't follow proto @import@s, so we
+// can't @import "google/protobuf/timestamp.proto"@ here; instead
+// the bridge has a hard-coded WKT registry (see
+// 'Proto.TH.lookupWkt') that resolves any @FTNamed
+// "google.protobuf.X"@ reference to the corresponding pre-
+// generated 'Proto.Google.Protobuf.X' type.
+//
+// To avoid duplicating the WKT type names in this file (the
+// parser doesn't know they're WKTs), we cite them by their
+// fully-qualified proto names. The bridge's WKT registry then
+// maps each FQN to the right Haskell type.
+
+syntax = "proto3";
+
+package protobuf_test_messages.proto3;
+
+message TestAllTypesProto3 {
+  message NestedMessage {
+    int32 a = 1;
+    TestAllTypesProto3 corecursive = 2;
+  }
+
+  enum NestedEnum {
+    FOO = 0;
+    BAR = 1;
+    BAZ = 2;
+    NEG = -1;
+  }
+
+  // Mirrors upstream test_messages_proto3 — multiple proto-side
+  // names share wire number 2 (allow_alias). The conformance
+  // EnumFieldWithAlias{*} tests fire on this enum.
+  enum AliasedEnum {
+    option allow_alias = true;
+
+    ALIAS_FOO = 0;
+    ALIAS_BAR = 1;
+    ALIAS_BAZ = 2;
+    MOO = 2;
+    moo = 2;
+    bAz = 2;
+  }
+
+  // Singular scalars.
+  int32    optional_int32    = 1;
+  int64    optional_int64    = 2;
+  uint32   optional_uint32   = 3;
+  uint64   optional_uint64   = 4;
+  sint32   optional_sint32   = 5;
+  sint64   optional_sint64   = 6;
+  fixed32  optional_fixed32  = 7;
+  fixed64  optional_fixed64  = 8;
+  sfixed32 optional_sfixed32 = 9;
+  sfixed64 optional_sfixed64 = 10;
+  float    optional_float    = 11;
+  double   optional_double   = 12;
+  bool     optional_bool     = 13;
+  string   optional_string   = 14;
+  bytes    optional_bytes    = 15;
+
+  NestedMessage  optional_nested_message  = 18;
+  ForeignMessage optional_foreign_message = 19;
+
+  NestedEnum  optional_nested_enum  = 21;
+  ForeignEnum optional_foreign_enum = 22;
+  AliasedEnum optional_aliased_enum = 23;
+
+  TestAllTypesProto3 recursive_message = 27;
+
+  // Repeated scalars (default proto3 = packed).
+  repeated int32    repeated_int32    = 31;
+  repeated int64    repeated_int64    = 32;
+  repeated uint32   repeated_uint32   = 33;
+  repeated uint64   repeated_uint64   = 34;
+  repeated sint32   repeated_sint32   = 35;
+  repeated sint64   repeated_sint64   = 36;
+  repeated fixed32  repeated_fixed32  = 37;
+  repeated fixed64  repeated_fixed64  = 38;
+  repeated sfixed32 repeated_sfixed32 = 39;
+  repeated sfixed64 repeated_sfixed64 = 40;
+  repeated float    repeated_float    = 41;
+  repeated double   repeated_double   = 42;
+  repeated bool     repeated_bool     = 43;
+  repeated string   repeated_string   = 44;
+  repeated bytes    repeated_bytes    = 45;
+
+  repeated NestedMessage  repeated_nested_message  = 48;
+  repeated ForeignMessage repeated_foreign_message = 49;
+
+  repeated NestedEnum  repeated_nested_enum  = 51;
+  repeated ForeignEnum repeated_foreign_enum = 52;
+
+  // Packed (explicit) — same wire format as proto3 default
+  // for packable scalars; exercised separately to make sure the
+  // packed encoder path is hit when the user writes [packed = true].
+  repeated int32    packed_int32    = 75 [packed = true];
+  repeated int64    packed_int64    = 76 [packed = true];
+  repeated uint32   packed_uint32   = 77 [packed = true];
+  repeated uint64   packed_uint64   = 78 [packed = true];
+  repeated sint32   packed_sint32   = 79 [packed = true];
+  repeated sint64   packed_sint64   = 80 [packed = true];
+  repeated fixed32  packed_fixed32  = 81 [packed = true];
+  repeated fixed64  packed_fixed64  = 82 [packed = true];
+  repeated sfixed32 packed_sfixed32 = 83 [packed = true];
+  repeated sfixed64 packed_sfixed64 = 84 [packed = true];
+  repeated float    packed_float    = 85 [packed = true];
+  repeated double   packed_double   = 86 [packed = true];
+  repeated bool     packed_bool     = 87 [packed = true];
+  repeated NestedEnum packed_nested_enum = 88 [packed = true];
+
+  // Unpacked (explicit).
+  repeated int32    unpacked_int32    = 89  [packed = false];
+  repeated int64    unpacked_int64    = 90  [packed = false];
+  repeated uint32   unpacked_uint32   = 91  [packed = false];
+  repeated uint64   unpacked_uint64   = 92  [packed = false];
+  repeated sint32   unpacked_sint32   = 93  [packed = false];
+  repeated sint64   unpacked_sint64   = 94  [packed = false];
+  repeated fixed32  unpacked_fixed32  = 95  [packed = false];
+  repeated fixed64  unpacked_fixed64  = 96  [packed = false];
+  repeated sfixed32 unpacked_sfixed32 = 97  [packed = false];
+  repeated sfixed64 unpacked_sfixed64 = 98  [packed = false];
+  repeated float    unpacked_float    = 99  [packed = false];
+  repeated double   unpacked_double   = 100 [packed = false];
+  repeated bool     unpacked_bool     = 101 [packed = false];
+  repeated NestedEnum unpacked_nested_enum = 102 [packed = false];
+
+  // Maps. Submessage / enum-valued maps come through as proto
+  // sub-messages whose value type is one of the FTNamed shapes;
+  // loadProto currently treats those as opaque submessages on
+  // the value side, which is enough for wire-format round-trip.
+  map<int32,    int32>          map_int32_int32          = 56;
+  map<int64,    int64>          map_int64_int64          = 57;
+  map<uint32,   uint32>         map_uint32_uint32        = 58;
+  map<uint64,   uint64>         map_uint64_uint64        = 59;
+  map<sint32,   sint32>         map_sint32_sint32        = 60;
+  map<sint64,   sint64>         map_sint64_sint64        = 61;
+  map<fixed32,  fixed32>        map_fixed32_fixed32      = 62;
+  map<fixed64,  fixed64>        map_fixed64_fixed64      = 63;
+  map<sfixed32, sfixed32>       map_sfixed32_sfixed32    = 64;
+  map<sfixed64, sfixed64>       map_sfixed64_sfixed64    = 65;
+  map<int32,    float>          map_int32_float          = 66;
+  map<int32,    double>         map_int32_double         = 67;
+  map<bool,     bool>           map_bool_bool            = 68;
+  map<string,   string>         map_string_string        = 69;
+  map<string,   bytes>          map_string_bytes         = 70;
+  // Submessage / enum-valued maps. The wire form is a repeated
+  // MapEntry message; the JSON form is a JSON object whose
+  // values are recursively the inner message / enum's JSON
+  // shape. Conformance MessageMapField.* exercises the message
+  // case; IgnoreUnknownEnumStringValueInMap*, etc. exercise the
+  // enum case.
+  map<string,   NestedMessage>  map_string_nested_message = 71;
+  map<string,   NestedEnum>     map_string_nested_enum    = 73;
+
+  // Oneof.
+  oneof oneof_field {
+    uint32        oneof_uint32         = 111;
+    NestedMessage oneof_nested_message = 112;
+    string        oneof_string         = 113;
+    bytes         oneof_bytes          = 114;
+    bool          oneof_bool           = 115;
+    uint64        oneof_uint64         = 116;
+    float         oneof_float          = 117;
+    double        oneof_double         = 118;
+    NestedEnum    oneof_enum           = 119;
+    google.protobuf.NullValue oneof_null_value = 120;
+  }
+
+  // FieldName1..18 stress test: exercises proto3's spec for
+  // converting between snake_case proto names and the JSON
+  // canonical camelCase form. The bridge handles both keys (the
+  // 'parseBindStmt' fallback in 'Proto.TH.Metadata') and the
+  // proto3-canonical-JSON output writes the camelCase form.
+  int32 fieldname1     = 401;
+  int32 field_name2    = 402;
+  int32 _field_name3   = 403;
+  int32 field__name4_  = 404;
+  int32 field0name5    = 405;
+  int32 field_0_name6  = 406;
+  int32 fieldName7     = 407;
+  int32 FieldName8     = 408;
+  int32 field_Name9    = 409;
+  int32 Field_Name10   = 410;
+  int32 FIELD_NAME11   = 411;
+  int32 FIELD_name12   = 412;
+  int32 __field_name13 = 413;
+  int32 __Field_name14 = 414;
+  int32 field__name15  = 415;
+  int32 field__Name16  = 416;
+  int32 field_name17__ = 417;
+  int32 Field_name18__ = 418;
+
+  // Well-known-types: routed through the WKT registry in
+  // 'Proto.TH'. The proto AST sees these as FTNamed types; the
+  // bridge resolves each FQN to the corresponding pre-generated
+  // 'Proto.Google.Protobuf.X' Haskell type.
+  google.protobuf.BoolValue   optional_bool_wrapper   = 201;
+  google.protobuf.Int32Value  optional_int32_wrapper  = 202;
+  google.protobuf.Int64Value  optional_int64_wrapper  = 203;
+  google.protobuf.UInt32Value optional_uint32_wrapper = 204;
+  google.protobuf.UInt64Value optional_uint64_wrapper = 205;
+  google.protobuf.FloatValue  optional_float_wrapper  = 206;
+  google.protobuf.DoubleValue optional_double_wrapper = 207;
+  google.protobuf.StringValue optional_string_wrapper = 208;
+  google.protobuf.BytesValue  optional_bytes_wrapper  = 209;
+
+  repeated google.protobuf.BoolValue   repeated_bool_wrapper   = 211;
+  repeated google.protobuf.Int32Value  repeated_int32_wrapper  = 212;
+  repeated google.protobuf.Int64Value  repeated_int64_wrapper  = 213;
+  repeated google.protobuf.UInt32Value repeated_uint32_wrapper = 214;
+  repeated google.protobuf.UInt64Value repeated_uint64_wrapper = 215;
+  repeated google.protobuf.FloatValue  repeated_float_wrapper  = 216;
+  repeated google.protobuf.DoubleValue repeated_double_wrapper = 217;
+  repeated google.protobuf.StringValue repeated_string_wrapper = 218;
+  repeated google.protobuf.BytesValue  repeated_bytes_wrapper  = 219;
+
+  google.protobuf.Duration   optional_duration   = 301;
+  google.protobuf.Timestamp  optional_timestamp  = 302;
+  google.protobuf.FieldMask  optional_field_mask = 303;
+  google.protobuf.Struct     optional_struct     = 304;
+  google.protobuf.Any        optional_any        = 305;
+  google.protobuf.Value      optional_value      = 306;
+  google.protobuf.NullValue  optional_null_value = 307;
+  google.protobuf.Empty      optional_empty      = 308;
+
+  repeated google.protobuf.Duration  repeated_duration  = 311;
+  repeated google.protobuf.Timestamp repeated_timestamp = 312;
+  repeated google.protobuf.FieldMask repeated_fieldmask = 313;
+  repeated google.protobuf.Struct    repeated_struct    = 324;
+  repeated google.protobuf.Any       repeated_any       = 315;
+  repeated google.protobuf.Value     repeated_value     = 316;
+  repeated google.protobuf.ListValue repeated_list_value = 317;
+  repeated google.protobuf.Empty     repeated_empty     = 318;
+
+  reserved 501 to 510;
+  reserved 999999;
+}
+
+message ForeignMessage {
+  int32 c = 1;
+}
+
+enum ForeignEnum {
+  FOREIGN_FOO = 0;
+  FOREIGN_BAR = 1;
+  FOREIGN_BAZ = 2;
+}
diff --git a/test-integration/Main.hs b/test-integration/Main.hs
--- a/test-integration/Main.hs
+++ b/test-integration/Main.hs
@@ -1,5 +1,6 @@
 module Main (main) where
 
+import Hedgehog.Internal.Config (resolveSkip)
 import Test.CodeGen (codeGenTests)
 import Test.Collect (collectTests)
 import Test.Compat (compatTests)
@@ -10,6 +11,7 @@
 import Test.Parser (parserTests)
 import Test.PrintInspect (printInspectTests)
 import Test.Resolver (resolverTests)
+import Test.RingSink (ringSinkTests)
 import Test.Roundtrip (roundtripTests)
 import Test.Schema (schemaTests)
 import Test.StreamCodec (streamCodecTests)
@@ -21,13 +23,19 @@
 
 
 main :: IO ()
-main =
+main = do
+  -- Force Hedgehog's "HEDGEHOG_SKIP" CAF before sydtest starts its workers.
+  -- Every property forces it first; when many capabilities hit it at once,
+  -- GHC 9.8.4's RTS can lose the wakeup of a thread blocked on it, leaving
+  -- one property hung until the per-test timeout.
+  _ <- resolveSkip Nothing
   sydTest
     $ describe
       "wireform-proto"
     $ sequence_
       [ parserTests
       , wireTests
+      , ringSinkTests
       , roundtripTests
       , codeGenTests
       , wellKnownTests
diff --git a/test-integration/Test/RingSink.hs b/test-integration/Test/RingSink.hs
new file mode 100644
--- /dev/null
+++ b/test-integration/Test/RingSink.hs
@@ -0,0 +1,146 @@
+{-# LANGUAGE BangPatterns #-}
+
+{- | Proto encoders through the ring-direct send sink.
+
+'putText' reserves a string field's whole UTF-8 length with one
+'ensureBytes', so a field longer than the send ring is a request
+no contiguous ring region can satisfy.  These properties drive
+such fields through 'sendBuilderDirect' and check the bytes match
+the reference ('B.toStrictByteString') encoding.
+-}
+module Test.RingSink (ringSinkTests) where
+
+import Data.Bits (shiftR, (.&.))
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Data.Char (chr)
+import Data.IORef
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Word (Word64)
+import Foreign.Ptr (castPtr, plusPtr)
+import Hedgehog
+import Hedgehog.Gen qualified as Gen
+import Hedgehog.Range qualified as Range
+import Proto.Encode (buildMessage, encodeFieldString)
+import Proto.Google.Protobuf.Wrappers (StringValue (..))
+import Test.Syd
+import Test.Syd.Hedgehog ()
+import Wireform.Builder qualified as B
+import Wireform.Ring.Internal (ringBase, ringMask, ringSize, withMagicRing)
+import Wireform.Transport.Send
+
+
+ringSinkTests :: Spec
+ringSinkTests = describe "RingSink (sendBuilderDirect)" $ do
+  it "string field at ring boundaries matches the reference encoding" $
+    property $ do
+      ringSz <- genRingSize
+      n <- forAll (Gen.element (boundaryLens ringSz))
+      corked <- forAll Gen.bool
+      let txt = asciiText n
+      sent <- evalIO (sendVia ringSz corked (encodeFieldString 1 txt))
+      sent === B.toStrictByteString (encodeFieldString 1 txt)
+
+  it "generated StringValue with mixed-width UTF-8 matches the reference encoding" $
+    property $ do
+      ringSz <- genRingSize
+      n <-
+        forAll $
+          Gen.frequency
+            [ (3, Gen.element (boundaryLens ringSz))
+            , (1, Gen.int (Range.linear 0 (3 * ringSz)))
+            ]
+      seed <- forAll (Gen.word64 Range.constantBounded)
+      corked <- forAll Gen.bool
+      let txt = mixedText n seed
+      let msg = StringValue {stringValueValue = txt, stringValueUnknownFields = []}
+      sent <- evalIO (sendVia ringSz corked (buildMessage msg))
+      sent === B.toStrictByteString (buildMessage msg)
+
+
+-- | A ring of 1x, 2x or 4x the host page size.
+genRingSize :: PropertyT IO Int
+genRingSize = do
+  page <- evalIO (withMagicRing 1 (pure . ringSize))
+  mult <- forAll (Gen.element [1, 2, 4])
+  ringSz <- evalIO (withMagicRing (mult * page) (pure . ringSize))
+  annotateShow ringSz
+  pure ringSz
+
+
+-- | The ring size, its doubles and the sink's 32 KiB chunk, each ±1.
+boundaryLens :: Int -> [Int]
+boundaryLens ringSz =
+  filter (>= 0) $
+    concatMap (\b -> fmap (b +) [-1, 0, 1]) [ringSz, 2 * ringSz, 32768]
+
+
+{- | Text of exactly @n@ UTF-8 bytes mixing 1- to 4-byte code
+points, drawn from @seed@.  Built directly rather than via
+'Gen.text': generating and shrink-tracking tens of thousands of
+characters costs ~70 MB per case, while this costs one 'Text'.
+-}
+mixedText :: Int -> Word64 -> Text
+mixedText n seed0 = T.unfoldr step (n, seed0)
+  where
+    step (!left, !s)
+      | left <= 0 = Nothing
+      | otherwise =
+          let !s' = s * 6364136223846793005 + 1442695040888963407
+              !r = fromIntegral (s' `shiftR` 33) :: Int
+              !w = 1 + r `rem` min 4 left
+              !c = case w of
+                1 -> chr (0x61 + r `rem` 26) -- a-z
+                2 -> chr (0xE0 + r `rem` 0x100) -- U+00E0..U+01DF
+                3 -> chr (0x4E00 + r `rem` 0x1000) -- CJK, no surrogates
+                _ -> chr (0x1F600 + r `rem` 0x40) -- emoji
+          in Just (c, (left - w, s'))
+
+
+-- | @n@ ASCII characters, so the UTF-8 length is exactly @n@.
+asciiText :: Int -> Text
+asciiText n = T.unfoldrN n (\k -> Just (chr (97 + k `rem` 26), k + 1)) (0 :: Int)
+
+
+{- | Run a builder through 'sendBuilderDirect' over an in-memory
+ring whose consumer drains inline on every publish, and return
+everything it drained.
+-}
+sendVia :: Int -> Bool -> B.Builder -> IO ByteString
+sendVia requested corked b = withMagicRing requested $ \ring -> do
+  let !base = ringBase ring
+      !sz = ringSize ring
+      !msk = ringMask ring
+  chunksRef <- newIORef ([] :: [ByteString])
+  headRef <- newIORef (0 :: Word64)
+  tailRef <- newIORef (0 :: Word64)
+  let drainTo !hd = readIORef tailRef >>= go
+        where
+          go !cur
+            | cur >= hd = pure ()
+            | otherwise = do
+                let !off = fromIntegral cur .&. msk
+                    !want = min (fromIntegral (hd - cur)) (sz - off)
+                chunk <- BS.packCStringLen (castPtr (base `plusPtr` off), want)
+                modifyIORef' chunksRef (chunk :)
+                let !next = cur + fromIntegral want
+                writeIORef tailRef next
+                go next
+      t =
+        SendTransport
+          { sendRingBase = base
+          , sendRingSize = sz
+          , sendRingMask = msk
+          , sendLoadTail = readIORef tailRef
+          , sendLoadHead = readIORef headRef
+          , sendPublishHead = \h -> writeIORef headRef h >> drainTo h
+          , sendWaitSpace = \_ -> SendSpaceAvailable <$> readIORef tailRef
+          , sendFlush = readIORef headRef >>= drainTo
+          , sendShutdownWrite = pure ()
+          , sendClose = pure ()
+          }
+  if corked
+    then withSendCork t (`sendBuilderDirect` b)
+    else sendBuilderDirect t b
+  BS.concat . reverse <$> readIORef chunksRef
diff --git a/test-regression/Generate.hs b/test-regression/Generate.hs
new file mode 100644
--- /dev/null
+++ b/test-regression/Generate.hs
@@ -0,0 +1,28 @@
+{-# LANGUAGE ImportQualifiedPost #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- Run from wireform-proto: cabal exec -- runghc test-regression/Generate.hs
+module Main where
+
+import Data.Map.Strict qualified as Map
+import Data.Text qualified as T
+import Data.Text.IO qualified as TIO
+import Proto.CodeGen
+import Proto.IDL.Parser
+import Proto.IDL.Parser.Resolver (ResolvedProto (..))
+import System.Directory (createDirectoryIfMissing)
+import System.FilePath (takeDirectory, (</>))
+
+
+main :: IO ()
+main = mapM_ generate ["edition2023", "edition2024", "integerMaps"]
+  where
+    generate name = do
+      let file = name <> ".proto"
+      source <- TIO.readFile ("test-regression/proto" </> file)
+      pf <- either (fail . show) pure (parseProtoFile file source)
+      let opts = defaultGenerateOpts {genModulePrefix = "Generated"}
+          registry = buildTypeRegistry opts [(file, ResolvedProto pf file Map.empty)]
+          output = "test-regression" </> T.unpack (T.replace "." "/" (moduleNameForProto opts file pf)) <> ".hs"
+      createDirectoryIfMissing True (takeDirectory output)
+      TIO.writeFile output (generateModuleText opts registry file pf)
diff --git a/test-regression/Generated/Edition2023.hs b/test-regression/Generated/Edition2023.hs
new file mode 100644
--- /dev/null
+++ b/test-regression/Generated/Edition2023.hs
@@ -0,0 +1,522 @@
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE OverloadedRecordDot #-}
+{-# OPTIONS_GHC -Wno-unused-imports -Wno-unused-matches -Wno-unused-top-binds #-}
+-- | Auto-generated protobuf types.
+--
+-- __THIS FILE IS AUTO-GENERATED BY wireform. DO NOT EDIT.__
+--
+-- Any manual changes will be overwritten the next time code
+-- generation is run.  To modify the types or instances, edit the
+-- @.proto@ source file and re-run the code generator.
+module Generated.Edition2023 where
+
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import qualified Wireform.Builder as B
+import Data.Int (Int32, Int64)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Word (Word32, Word64)
+import qualified Data.Map.Strict as Map
+import qualified Data.Vector as V
+import qualified Data.Vector.Unboxed as VU
+import GHC.Generics (Generic)
+import Control.DeepSeq (NFData(..))
+import Data.Hashable (Hashable(..))
+import Proto.Encode
+import Proto.Decode
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Types as Aeson
+import qualified Data.Aeson.Key as AesonKey
+import qualified Data.Aeson.KeyMap as AesonKM
+import Proto.Internal.JSON (jsonObject, (.=:), parseFieldMaybe, bytesFieldToJSON, parseBytesFieldMaybe, bytesMapFieldToJSON, parseBytesMapFieldMaybe, protoBytesToJSON)
+import Data.Proxy (Proxy(..))
+import Data.Reflection (Given(..), given)
+import Proto.Internal.JSON.Extension qualified as PJExt
+import Proto.Schema (ProtoMessage(..), SomeFieldDescriptor(..), FieldDescriptor(..), FieldTypeDescriptor(..), ScalarFieldType(..), FieldLabel'(..))
+import Proto.Registry (IsMessage)
+import Proto.Registry qualified
+import qualified Proto.Extension
+import Proto.Internal.Wire (Tag(..), WireType(..))
+import Proto.Internal.Wire.Encode (putTag, putVarint, putFixed32, putFixed64,
+  putFloat, putDouble, putText, putByteString, putLengthDelimited,
+  putSVarint32, putSVarint64, putVarintSigned,
+  varintSize, tagSize, fieldMessageSize,
+  fieldVarintSize, fieldFixed32Size, fieldFixed64Size,
+  fieldBoolSize, fieldFloatSize, fieldDoubleSize,
+  fieldTextSize, fieldBytesSize,
+  fieldSVarint32Size, fieldSVarint64Size,
+  varintSize32, zigZag32, zigZag64)
+import Proto.Internal.Encode.Archetype (archVarint, archSVarint32, archSVarint64,
+  archFixed32, archFixed64, archFloat, archDouble, archBool,
+  archString, archBytes, archSubmessage,
+  archVarintSize, archStringSize, archBytesSize, archBoolSize,
+  archFixed32Size, archFixed64Size, archSubmessageSize)
+
+-- | Serialized FileDescriptorProto for this .proto file.
+-- Decode with @Proto.Google.Protobuf.Descriptor.decodeMessage@.
+fileDescriptorProtoBytes :: ByteString
+fileDescriptorProtoBytes = "\x0a\x11\x65\x64\x69\x74\x69\x6f\x6e\x32\x30\x32\x33\x2e\x70\x72\x6f\x74\x6f\x22\xbe\x02\x0a\x08\x46\x69\x65\x6c\x64\x73\x32\x33\x12\x0d\x0a\x05\x63\x6f\x75\x6e\x74\x18\x01\x20\x01\x28\x05\x12\x0c\x0a\x04\x6e\x61\x6d\x65\x18\x02\x20\x01\x28\x09\x12\x0f\x0a\x07\x70\x61\x79\x6c\x6f\x61\x64\x18\x03\x20\x01\x28\x0c\x12\x0f\x0a\x07\x65\x6e\x61\x62\x6c\x65\x64\x18\x04\x20\x01\x28\x08\x12\x18\x0a\x06\x73\x74\x61\x74\x75\x73\x18\x05\x20\x01\x28\x0b\x32\x08\x53\x74\x61\x74\x75\x73\x32\x33\x12\x11\x0a\x09\x6d\x61\x67\x6e\x69\x74\x75\x64\x65\x18\x06\x20\x01\x28\x01\x12\x10\x0a\x08\x66\x72\x61\x63\x74\x69\x6f\x6e\x18\x07\x20\x01\x28\x02\x12\x16\x0a\x0e\x69\x6d\x70\x6c\x69\x63\x69\x74\x5f\x63\x6f\x75\x6e\x74\x18\x08\x20\x01\x28\x05\x12\x0e\x0a\x06\x76\x61\x6c\x75\x65\x73\x18\x09\x20\x03\x28\x05\x12\x14\x0a\x05\x69\x6e\x6e\x65\x72\x18\x0a\x20\x01\x28\x0b\x32\x05\x49\x6e\x6e\x65\x72\x12\x19\x0a\x05\x61\x74\x74\x72\x73\x18\x0b\x20\x03\x28\x0b\x32\x0a\x61\x74\x74\x72\x73\x45\x6e\x74\x72\x79\x12\x0e\x0a\x06\x70\x69\x63\x6b\x65\x64\x18\x0c\x20\x01\x28\x05\x12\x0d\x0a\x05\x6c\x61\x62\x65\x6c\x18\x0d\x20\x01\x28\x09\x12\x1a\x0a\x12\x61\x67\x67\x72\x65\x67\x61\x74\x65\x5f\x69\x6d\x70\x6c\x69\x63\x69\x74\x18\x0e\x20\x01\x28\x05\x1a\x16\x0a\x05\x49\x6e\x6e\x65\x72\x12\x0d\x0a\x05\x63\x6f\x75\x6e\x74\x18\x01\x20\x01\x28\x05\x42\x08\x0a\x06\x63\x68\x6f\x69\x63\x65\x2a\x36\x0a\x08\x53\x74\x61\x74\x75\x73\x32\x33\x12\x16\x0a\x14\x53\x54\x41\x54\x55\x53\x32\x33\x5f\x55\x4e\x53\x50\x45\x43\x49\x46\x49\x45\x44\x12\x12\x0a\x0e\x53\x54\x41\x54\x55\x53\x32\x33\x5f\x52\x45\x41\x44\x59\x10\x01\x62\x08\x65\x64\x69\x74\x69\x6f\x6e\x73\x72\x04\x32\x30\x32\x33"
+
+
+data Status23
+  = Status23'Status23Unspecified
+  | Status23'Status23Ready
+  deriving stock (Show, Eq, Ord, Enum, Bounded, Generic)
+  deriving anyclass NFData
+
+toProtoEnumStatus23 :: Status23 -> Int
+toProtoEnumStatus23 Status23'Status23Unspecified = 0
+toProtoEnumStatus23 Status23'Status23Ready = 1
+
+fromProtoEnumStatus23 :: Int -> Maybe Status23
+fromProtoEnumStatus23 0 = Just Status23'Status23Unspecified
+fromProtoEnumStatus23 1 = Just Status23'Status23Ready
+fromProtoEnumStatus23 _ = Nothing
+
+instance MessageEncode Status23 where
+  buildMessage _ = mempty
+instance MessageSize Status23 where
+  messageSize _ = 0
+instance MessageDecode Status23 where
+  messageDecoder = pure (toEnum 0)
+
+instance Aeson.ToJSON Status23 where
+  toJSON Status23'Status23Unspecified = Aeson.String "STATUS23_UNSPECIFIED"
+  toJSON Status23'Status23Ready = Aeson.String "STATUS23_READY"
+
+instance Aeson.FromJSON Status23 where
+  parseJSON = \case
+    Aeson.String "STATUS23_UNSPECIFIED" -> pure Status23'Status23Unspecified
+    Aeson.String "STATUS23_READY" -> pure Status23'Status23Ready
+    Aeson.Number n -> pure (toEnum (round n))
+    _ -> fail "Invalid enum value for Status23"
+
+instance Hashable Status23 where
+  hashWithSalt salt x = hashWithSalt salt (toProtoEnumStatus23 x)
+
+data Fields23 = Fields23
+  { fields23Count :: !(Maybe Int32)
+  , fields23Name :: !(Maybe Text)
+  , fields23Payload :: !(Maybe ByteString)
+  , fields23Enabled :: !(Maybe Bool)
+  , fields23Status :: !(Maybe Status23)
+  , fields23Magnitude :: !(Maybe Double)
+  , fields23Fraction :: !(Maybe Float)
+  , fields23ImplicitCount :: {-# UNPACK #-} !Int32
+  , fields23Values :: !(VU.Vector Int32)
+  , fields23Inner :: !(Maybe Fields23'Inner)
+  , fields23Attrs :: !(Map.Map Text Text)
+  , fields23Choice :: !(Maybe Fields23'Choice)
+  , fields23AggregateImplicit :: {-# UNPACK #-} !Int32
+  , fields23UnknownFields :: ![UnknownField]
+  }
+  deriving stock (Show, Eq, Generic)
+  deriving anyclass NFData
+
+data Fields23'Inner = Fields23'Inner
+  { fields23InnerCount :: !(Maybe Int32)
+  , fields23InnerUnknownFields :: ![UnknownField]
+  }
+  deriving stock (Show, Eq, Generic)
+  deriving anyclass NFData
+
+defaultFields23'Inner :: Fields23'Inner
+defaultFields23'Inner = Fields23'Inner
+  { fields23InnerCount = Nothing
+  , fields23InnerUnknownFields = []
+  }
+
+instance MessageEncode Fields23'Inner where
+  buildMessage msg =
+    (maybe mempty (\v -> archVarint 8 (fromIntegral v)) msg.fields23InnerCount)
+    <> encodeUnknownFields msg.fields23InnerUnknownFields
+
+instance MessageSize Fields23'Inner where
+  messageSize msg =
+    (maybe 0 (\v -> archVarintSize (fromIntegral v)) msg.fields23InnerCount)
+    + unknownFieldsSize msg.fields23InnerUnknownFields
+
+instance MessageDecode Fields23'Inner where
+  {-# INLINE messageDecoder #-}
+  messageDecoder = loop Nothing []
+    where
+      loop acc_0 acc_unknown_ = withTagM
+        (pure (Fields23'Inner {fields23InnerCount = acc_0, fields23InnerUnknownFields = reverse acc_unknown_}))
+        (\fn wt -> case fn of
+          1 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop (Just v) acc_unknown_
+          _ -> do
+            uf <- captureUnknownField fn (toEnum wt)
+            loop acc_0 (uf : acc_unknown_))
+
+instance ProtoMessage Fields23'Inner where
+  protoMessageName _ = "Fields23.Inner"
+  protoPackageName _ = ""
+  protoDefaultValue = defaultFields23'Inner
+  protoFileDescriptorBytes _ = fileDescriptorProtoBytes
+  protoFieldDescriptors _ = Map.fromList
+    [ (1, SomeField FieldDescriptor
+        { fdName = "count"
+        , fdNumber = 1
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelOptional
+        , fdGet = fields23InnerCount
+        , fdSet = \v m -> m { fields23InnerCount = v }
+        })
+    ]
+
+instance IsMessage Fields23'Inner
+
+instance Aeson.ToJSON Fields23'Inner where
+  toJSON msg = jsonObject
+      [ "count" .=: msg.fields23InnerCount
+
+      ]
+
+instance Aeson.FromJSON Fields23'Inner where
+  parseJSON = Aeson.withObject "Fields23'Inner" $ \obj -> do
+    fld_fields23InnerCount <- parseFieldMaybe obj "count"
+    pure defaultFields23'Inner
+      { fields23InnerCount = maybe (fields23InnerCount defaultFields23'Inner) id fld_fields23InnerCount
+      , fields23InnerUnknownFields = []
+      }
+
+instance Hashable Fields23'Inner where
+  hashWithSalt salt msg = hashWithSalt (salt) msg.fields23InnerCount
+
+instance Proto.Extension.HasExtensions Fields23'Inner where
+  messageUnknownFields = fields23InnerUnknownFields
+  setMessageUnknownFields !ufs msg = msg { fields23InnerUnknownFields = ufs }
+
+instance Semigroup Fields23'Inner where
+  a <> b = Fields23'Inner
+    { fields23InnerCount = case b.fields23InnerCount of { Nothing -> a.fields23InnerCount; x -> x }
+    , fields23InnerUnknownFields = a.fields23InnerUnknownFields <> b.fields23InnerUnknownFields
+    }
+
+instance Monoid Fields23'Inner where
+  mempty = defaultFields23'Inner
+data Fields23'Choice
+  = Fields23'Choice'Picked {-# UNPACK #-} !Int32
+  | Fields23'Choice'Label !Text
+  deriving stock (Show, Eq, Generic)
+  deriving anyclass NFData
+instance Aeson.ToJSON Fields23'Choice where
+  toJSON _ = Aeson.Null
+instance Aeson.FromJSON Fields23'Choice where
+  parseJSON _ = fail "Cannot parse oneof from JSON"
+instance Hashable Fields23'Choice where
+  hashWithSalt salt (Fields23'Choice'Picked v) = salt `hashWithSalt` (0 :: Int) `hashWithSalt` v
+  hashWithSalt salt (Fields23'Choice'Label v) = salt `hashWithSalt` (1 :: Int) `hashWithSalt` v
+
+defaultFields23 :: Fields23
+defaultFields23 = Fields23
+  { fields23Count = Nothing
+  , fields23Name = Nothing
+  , fields23Payload = Nothing
+  , fields23Enabled = Nothing
+  , fields23Status = Nothing
+  , fields23Magnitude = Nothing
+  , fields23Fraction = Nothing
+  , fields23ImplicitCount = 0
+  , fields23Values = VU.empty
+  , fields23Inner = Nothing
+  , fields23Attrs = Map.empty
+  , fields23Choice = Nothing
+  , fields23AggregateImplicit = 0
+  , fields23UnknownFields = []
+  }
+
+instance MessageEncode Fields23 where
+  buildMessage msg =
+    (maybe mempty (\v -> archVarint 8 (fromIntegral v)) msg.fields23Count)
+    <> (maybe mempty (\v -> archString 18 v) msg.fields23Name)
+    <> (maybe mempty (\v -> archBytes 26 v) msg.fields23Payload)
+    <> (maybe mempty (\v -> archBool 32 v) msg.fields23Enabled)
+    <> (maybe mempty (\v -> archVarint 40 (fromIntegral (fromEnum v))) msg.fields23Status)
+    <> (maybe mempty (\v -> archDouble 49 v) msg.fields23Magnitude)
+    <> (maybe mempty (\v -> archFloat 61 v) msg.fields23Fraction)
+    <> (if msg.fields23ImplicitCount == 0 then mempty else archVarint 64 (fromIntegral msg.fields23ImplicitCount))
+    <> encodePackedInt32 9 msg.fields23Values
+    <> (maybe mempty (\v -> let sz = messageSize v in archSubmessage 82 sz (buildMessage v)) msg.fields23Inner)
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 11 (encodeFieldString 1 k) (encodeFieldString 2 v)) mempty msg.fields23Attrs
+    <> (case msg.fields23Choice of
+      Nothing -> mempty
+      Just (Fields23'Choice'Picked v) -> archVarint 96 (fromIntegral v)
+      Just (Fields23'Choice'Label v) -> archString 106 v)
+    <> (if msg.fields23AggregateImplicit == 0 then mempty else archVarint 112 (fromIntegral msg.fields23AggregateImplicit))
+    <> encodeUnknownFields msg.fields23UnknownFields
+
+instance MessageSize Fields23 where
+  messageSize msg =
+    (maybe 0 (\v -> archVarintSize (fromIntegral v)) msg.fields23Count)
+    + (maybe 0 (\v -> archStringSize v) msg.fields23Name)
+    + (maybe 0 (\v -> archBytesSize v) msg.fields23Payload)
+    + (maybe 0 (\v -> archBoolSize) msg.fields23Enabled)
+    + (maybe 0 (\v -> archVarintSize (fromIntegral (fromEnum v))) msg.fields23Status)
+    + (maybe 0 (\v -> archFixed64Size) msg.fields23Magnitude)
+    + (maybe 0 (\v -> archFixed32Size) msg.fields23Fraction)
+    + (if msg.fields23ImplicitCount == 0 then 0 else archVarintSize (fromIntegral msg.fields23ImplicitCount))
+    + (let pl = VU.foldl' (\a v -> a + varintSize (fromIntegral v)) 0 msg.fields23Values in if pl == 0 then 0 else tagSize 9 + varintSize (fromIntegral pl) + pl)
+    + (maybe 0 (\v -> archSubmessageSize (messageSize v)) msg.fields23Inner)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldTextSize 2 v in acc + tagSize 11 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.fields23Attrs)
+    + (case msg.fields23Choice of { Nothing -> 0; Just (Fields23'Choice'Picked v) -> archVarintSize (fromIntegral v)
+    ; Just (Fields23'Choice'Label v) -> archStringSize v })
+    + (if msg.fields23AggregateImplicit == 0 then 0 else archVarintSize (fromIntegral msg.fields23AggregateImplicit))
+    + unknownFieldsSize msg.fields23UnknownFields
+
+instance MessageDecode Fields23 where
+  {-# INLINE messageDecoder #-}
+  messageDecoder = loop Nothing Nothing Nothing Nothing Nothing Nothing Nothing 0 VU.empty Nothing Map.empty Nothing 0 []
+    where
+      loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_ = withTagM
+        (pure (Fields23 {fields23Count = acc_0, fields23Name = acc_1, fields23Payload = acc_2, fields23Enabled = acc_3, fields23Status = acc_4, fields23Magnitude = acc_5, fields23Fraction = acc_6, fields23ImplicitCount = acc_7, fields23Values = acc_8, fields23Inner = acc_9, fields23Attrs = acc_10, fields23Choice = acc_11, fields23AggregateImplicit = acc_12, fields23UnknownFields = reverse acc_unknown_}))
+        (\fn wt -> case fn of
+          1 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop (Just v) acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          2 -> do
+            v <- decodeFieldString
+            loop acc_0 (Just v) acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          3 -> do
+            v <- decodeFieldBytes
+            loop acc_0 acc_1 (Just v) acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          4 -> do
+            v <- decodeFieldBool
+            loop acc_0 acc_1 acc_2 (Just v) acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          5 -> do
+            v <- decodeFieldEnum
+            loop acc_0 acc_1 acc_2 acc_3 (Just v) acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          6 -> do
+            v <- decodeFieldDouble
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 (Just v) acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          7 -> do
+            v <- decodeFieldFloat
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 (Just v) acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          8 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 v acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          9 -> case wt of
+            2 -> do
+              vs <- (VU.map fromIntegral <$> decodePackedVarint)
+              loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 (acc_8 <> vs) acc_9 acc_10 acc_11 acc_12 acc_unknown_
+            _ -> do
+              v <- (fromIntegral <$> decodeFieldVarint)
+              loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 (acc_8 <> VU.singleton v) acc_9 acc_10 acc_11 acc_12 acc_unknown_
+          10 -> do
+            v <- decodeFieldMessage
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 (Just v) acc_10 acc_11 acc_12 acc_unknown_
+          11 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString decodeFieldString "" "") bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 (Map.union acc_10 (Map.singleton mk' mv')) acc_11 acc_12 acc_unknown_
+          12 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 (Just (Fields23'Choice'Picked v)) acc_12 acc_unknown_
+          13 -> do
+            v <- decodeFieldString
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 (Just (Fields23'Choice'Label v)) acc_12 acc_unknown_
+          14 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 v acc_unknown_
+          _ -> do
+            uf <- captureUnknownField fn (toEnum wt)
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_12 (uf : acc_unknown_))
+
+instance ProtoMessage Fields23 where
+  protoMessageName _ = "Fields23"
+  protoPackageName _ = ""
+  protoDefaultValue = defaultFields23
+  protoFileDescriptorBytes _ = fileDescriptorProtoBytes
+  protoFieldDescriptors _ = Map.fromList
+    [ (1, SomeField FieldDescriptor
+        { fdName = "count"
+        , fdNumber = 1
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelOptional
+        , fdGet = fields23Count
+        , fdSet = \v m -> m { fields23Count = v }
+        }), (2, SomeField FieldDescriptor
+        { fdName = "name"
+        , fdNumber = 2
+        , fdTypeDesc = ScalarType StringField
+        , fdLabel = LabelOptional
+        , fdGet = fields23Name
+        , fdSet = \v m -> m { fields23Name = v }
+        })
+    , (3, SomeField FieldDescriptor
+        { fdName = "payload"
+        , fdNumber = 3
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelOptional
+        , fdGet = fields23Payload
+        , fdSet = \v m -> m { fields23Payload = v }
+        })
+    , (4, SomeField FieldDescriptor
+        { fdName = "enabled"
+        , fdNumber = 4
+        , fdTypeDesc = ScalarType BoolField
+        , fdLabel = LabelOptional
+        , fdGet = fields23Enabled
+        , fdSet = \v m -> m { fields23Enabled = v }
+        })
+    , (5, SomeField FieldDescriptor
+        { fdName = "status"
+        , fdNumber = 5
+        , fdTypeDesc = MessageType "Status23"
+        , fdLabel = LabelOptional
+        , fdGet = fields23Status
+        , fdSet = \v m -> m { fields23Status = v }
+        })
+    , (6, SomeField FieldDescriptor
+        { fdName = "magnitude"
+        , fdNumber = 6
+        , fdTypeDesc = ScalarType DoubleField
+        , fdLabel = LabelOptional
+        , fdGet = fields23Magnitude
+        , fdSet = \v m -> m { fields23Magnitude = v }
+        })
+    , (7, SomeField FieldDescriptor
+        { fdName = "fraction"
+        , fdNumber = 7
+        , fdTypeDesc = ScalarType FloatField
+        , fdLabel = LabelOptional
+        , fdGet = fields23Fraction
+        , fdSet = \v m -> m { fields23Fraction = v }
+        })
+    , (8, SomeField FieldDescriptor
+        { fdName = "implicit_count"
+        , fdNumber = 8
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelOptional
+        , fdGet = fields23ImplicitCount
+        , fdSet = \v m -> m { fields23ImplicitCount = v }
+        })
+    , (9, SomeField FieldDescriptor
+        { fdName = "values"
+        , fdNumber = 9
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelRepeated
+        , fdGet = fields23Values
+        , fdSet = \v m -> m { fields23Values = v }
+        })
+    , (10, SomeField FieldDescriptor
+        { fdName = "inner"
+        , fdNumber = 10
+        , fdTypeDesc = MessageType "Inner"
+        , fdLabel = LabelOptional
+        , fdGet = fields23Inner
+        , fdSet = \v m -> m { fields23Inner = v }
+        })
+    , (11, SomeField FieldDescriptor
+        { fdName = "attrs"
+        , fdNumber = 11
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = fields23Attrs
+        , fdSet = \v m -> m { fields23Attrs = v }
+        })
+    , (12, SomeField FieldDescriptor
+        { fdName = "choice"
+        , fdNumber = 12
+        , fdTypeDesc = MessageType "choice"
+        , fdLabel = LabelOptional
+        , fdGet = fields23Choice
+        , fdSet = \v m -> m { fields23Choice = v }
+        })
+    , (14, SomeField FieldDescriptor
+        { fdName = "aggregate_implicit"
+        , fdNumber = 14
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelOptional
+        , fdGet = fields23AggregateImplicit
+        , fdSet = \v m -> m { fields23AggregateImplicit = v }
+        })
+    ]
+
+instance IsMessage Fields23
+
+instance Aeson.ToJSON Fields23 where
+  toJSON msg = jsonObject
+      [ "count" .=: msg.fields23Count
+      , "name" .=: msg.fields23Name
+      , "payload" .=: (fmap protoBytesToJSON msg.fields23Payload)
+      , "enabled" .=: msg.fields23Enabled
+      , "status" .=: msg.fields23Status
+      , "magnitude" .=: msg.fields23Magnitude
+      , "fraction" .=: msg.fields23Fraction
+      , "implicitCount" .=: msg.fields23ImplicitCount
+      , "values" .=: msg.fields23Values
+      , "inner" .=: msg.fields23Inner
+      , "attrs" .=: msg.fields23Attrs
+      , "choice" .=: msg.fields23Choice
+      , "aggregateImplicit" .=: msg.fields23AggregateImplicit
+      ]
+
+instance Aeson.FromJSON Fields23 where
+  parseJSON = Aeson.withObject "Fields23" $ \obj -> do
+    fld_fields23Count <- parseFieldMaybe obj "count"
+    fld_fields23Name <- parseFieldMaybe obj "name"
+    fld_fields23Payload <- parseBytesFieldMaybe obj "payload"
+    fld_fields23Enabled <- parseFieldMaybe obj "enabled"
+    fld_fields23Status <- parseFieldMaybe obj "status"
+    fld_fields23Magnitude <- parseFieldMaybe obj "magnitude"
+    fld_fields23Fraction <- parseFieldMaybe obj "fraction"
+    fld_fields23ImplicitCount <- parseFieldMaybe obj "implicitCount"
+    fld_fields23Values <- parseFieldMaybe obj "values"
+    fld_fields23Inner <- parseFieldMaybe obj "inner"
+    fld_fields23Attrs <- parseFieldMaybe obj "attrs"
+    fld_fields23Choice <- parseFieldMaybe obj "choice"
+    fld_fields23AggregateImplicit <- parseFieldMaybe obj "aggregateImplicit"
+    pure defaultFields23
+      { fields23Count = maybe (fields23Count defaultFields23) id fld_fields23Count
+      , fields23Name = maybe (fields23Name defaultFields23) id fld_fields23Name
+      , fields23Payload = fld_fields23Payload
+      , fields23Enabled = maybe (fields23Enabled defaultFields23) id fld_fields23Enabled
+      , fields23Status = maybe (fields23Status defaultFields23) id fld_fields23Status
+      , fields23Magnitude = maybe (fields23Magnitude defaultFields23) id fld_fields23Magnitude
+      , fields23Fraction = maybe (fields23Fraction defaultFields23) id fld_fields23Fraction
+      , fields23ImplicitCount = maybe (fields23ImplicitCount defaultFields23) id fld_fields23ImplicitCount
+      , fields23Values = maybe (fields23Values defaultFields23) id fld_fields23Values
+      , fields23Inner = maybe (fields23Inner defaultFields23) id fld_fields23Inner
+      , fields23Attrs = maybe (fields23Attrs defaultFields23) id fld_fields23Attrs
+      , fields23Choice = maybe (fields23Choice defaultFields23) id fld_fields23Choice
+      , fields23AggregateImplicit = maybe (fields23AggregateImplicit defaultFields23) id fld_fields23AggregateImplicit
+      , fields23UnknownFields = []
+      }
+
+instance Hashable Fields23 where
+  hashWithSalt salt msg = hashWithSalt (hashWithSalt (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (hashWithSalt (VU.foldl' hashWithSalt (hashWithSalt (hashWithSalt (hashWithSalt (hashWithSalt (hashWithSalt (hashWithSalt (hashWithSalt (hashWithSalt (salt) msg.fields23Count) msg.fields23Name) msg.fields23Payload) msg.fields23Enabled) msg.fields23Status) msg.fields23Magnitude) msg.fields23Fraction) msg.fields23ImplicitCount) msg.fields23Values) msg.fields23Inner) msg.fields23Attrs) msg.fields23Choice) msg.fields23AggregateImplicit
+
+instance Proto.Extension.HasExtensions Fields23 where
+  messageUnknownFields = fields23UnknownFields
+  setMessageUnknownFields !ufs msg = msg { fields23UnknownFields = ufs }
+
+instance Semigroup Fields23 where
+  a <> b = Fields23
+    { fields23Count = case b.fields23Count of { Nothing -> a.fields23Count; x -> x }
+    , fields23Name = case b.fields23Name of { Nothing -> a.fields23Name; x -> x }
+    , fields23Payload = case b.fields23Payload of { Nothing -> a.fields23Payload; x -> x }
+    , fields23Enabled = case b.fields23Enabled of { Nothing -> a.fields23Enabled; x -> x }
+    , fields23Status = case b.fields23Status of { Nothing -> a.fields23Status; x -> x }
+    , fields23Magnitude = case b.fields23Magnitude of { Nothing -> a.fields23Magnitude; x -> x }
+    , fields23Fraction = case b.fields23Fraction of { Nothing -> a.fields23Fraction; x -> x }
+    , fields23ImplicitCount = if b.fields23ImplicitCount == 0 then a.fields23ImplicitCount else b.fields23ImplicitCount
+    , fields23Values = a.fields23Values <> b.fields23Values
+    , fields23Inner = case b.fields23Inner of { Nothing -> a.fields23Inner; x -> x }
+    , fields23Attrs = a.fields23Attrs <> b.fields23Attrs
+    , fields23Choice = case b.fields23Choice of { Nothing -> a.fields23Choice; x -> x }
+    , fields23AggregateImplicit = if b.fields23AggregateImplicit == 0 then a.fields23AggregateImplicit else b.fields23AggregateImplicit
+    , fields23UnknownFields = a.fields23UnknownFields <> b.fields23UnknownFields
+    }
+
+instance Monoid Fields23 where
+  mempty = defaultFields23
diff --git a/test-regression/Generated/Edition2024.hs b/test-regression/Generated/Edition2024.hs
new file mode 100644
--- /dev/null
+++ b/test-regression/Generated/Edition2024.hs
@@ -0,0 +1,287 @@
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE OverloadedRecordDot #-}
+{-# OPTIONS_GHC -Wno-unused-imports -Wno-unused-matches -Wno-unused-top-binds #-}
+-- | Auto-generated protobuf types.
+--
+-- __THIS FILE IS AUTO-GENERATED BY wireform. DO NOT EDIT.__
+--
+-- Any manual changes will be overwritten the next time code
+-- generation is run.  To modify the types or instances, edit the
+-- @.proto@ source file and re-run the code generator.
+module Generated.Edition2024 where
+
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import qualified Wireform.Builder as B
+import Data.Int (Int32, Int64)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Word (Word32, Word64)
+import qualified Data.Map.Strict as Map
+import qualified Data.Vector as V
+import qualified Data.Vector.Unboxed as VU
+import GHC.Generics (Generic)
+import Control.DeepSeq (NFData(..))
+import Data.Hashable (Hashable(..))
+import Proto.Encode
+import Proto.Decode
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Types as Aeson
+import qualified Data.Aeson.Key as AesonKey
+import qualified Data.Aeson.KeyMap as AesonKM
+import Proto.Internal.JSON (jsonObject, (.=:), parseFieldMaybe, bytesFieldToJSON, parseBytesFieldMaybe, bytesMapFieldToJSON, parseBytesMapFieldMaybe, protoBytesToJSON)
+import Data.Proxy (Proxy(..))
+import Data.Reflection (Given(..), given)
+import Proto.Internal.JSON.Extension qualified as PJExt
+import Proto.Schema (ProtoMessage(..), SomeFieldDescriptor(..), FieldDescriptor(..), FieldTypeDescriptor(..), ScalarFieldType(..), FieldLabel'(..))
+import Proto.Registry (IsMessage)
+import Proto.Registry qualified
+import qualified Proto.Extension
+import Proto.Internal.Wire (Tag(..), WireType(..))
+import Proto.Internal.Wire.Encode (putTag, putVarint, putFixed32, putFixed64,
+  putFloat, putDouble, putText, putByteString, putLengthDelimited,
+  putSVarint32, putSVarint64, putVarintSigned,
+  varintSize, tagSize, fieldMessageSize,
+  fieldVarintSize, fieldFixed32Size, fieldFixed64Size,
+  fieldBoolSize, fieldFloatSize, fieldDoubleSize,
+  fieldTextSize, fieldBytesSize,
+  fieldSVarint32Size, fieldSVarint64Size,
+  varintSize32, zigZag32, zigZag64)
+import Proto.Internal.Encode.Archetype (archVarint, archSVarint32, archSVarint64,
+  archFixed32, archFixed64, archFloat, archDouble, archBool,
+  archString, archBytes, archSubmessage,
+  archVarintSize, archStringSize, archBytesSize, archBoolSize,
+  archFixed32Size, archFixed64Size, archSubmessageSize)
+
+-- | Serialized FileDescriptorProto for this .proto file.
+-- Decode with @Proto.Google.Protobuf.Descriptor.decodeMessage@.
+fileDescriptorProtoBytes :: ByteString
+fileDescriptorProtoBytes = "\x0a\x11\x65\x64\x69\x74\x69\x6f\x6e\x32\x30\x32\x34\x2e\x70\x72\x6f\x74\x6f\x22\x7b\x0a\x08\x46\x69\x65\x6c\x64\x73\x32\x34\x12\x0d\x0a\x05\x63\x6f\x75\x6e\x74\x18\x01\x20\x01\x28\x05\x12\x16\x0a\x0e\x65\x78\x70\x6c\x69\x63\x69\x74\x5f\x63\x6f\x75\x6e\x74\x18\x02\x20\x01\x28\x05\x12\x14\x0a\x05\x69\x6e\x6e\x65\x72\x18\x03\x20\x01\x28\x0b\x32\x05\x49\x6e\x6e\x65\x72\x1a\x32\x0a\x05\x49\x6e\x6e\x65\x72\x12\x11\x0a\x09\x69\x6e\x68\x65\x72\x69\x74\x65\x64\x18\x01\x20\x01\x28\x05\x12\x16\x0a\x0e\x65\x78\x70\x6c\x69\x63\x69\x74\x5f\x76\x61\x6c\x75\x65\x18\x02\x20\x01\x28\x05\x62\x08\x65\x64\x69\x74\x69\x6f\x6e\x73\x72\x04\x32\x30\x32\x34"
+
+
+data Fields24 = Fields24
+  { fields24Count :: {-# UNPACK #-} !Int32
+  , fields24ExplicitCount :: !(Maybe Int32)
+  , fields24Inner :: !(Maybe Fields24'Inner)
+  , fields24UnknownFields :: ![UnknownField]
+  }
+  deriving stock (Show, Eq, Generic)
+  deriving anyclass NFData
+
+data Fields24'Inner = Fields24'Inner
+  { fields24InnerInherited :: {-# UNPACK #-} !Int32
+  , fields24InnerExplicitValue :: !(Maybe Int32)
+  , fields24InnerUnknownFields :: ![UnknownField]
+  }
+  deriving stock (Show, Eq, Generic)
+  deriving anyclass NFData
+
+defaultFields24'Inner :: Fields24'Inner
+defaultFields24'Inner = Fields24'Inner
+  { fields24InnerInherited = 0
+  , fields24InnerExplicitValue = Nothing
+  , fields24InnerUnknownFields = []
+  }
+
+instance MessageEncode Fields24'Inner where
+  buildMessage msg =
+    (if msg.fields24InnerInherited == 0 then mempty else archVarint 8 (fromIntegral msg.fields24InnerInherited))
+    <> (maybe mempty (\v -> archVarint 16 (fromIntegral v)) msg.fields24InnerExplicitValue)
+    <> encodeUnknownFields msg.fields24InnerUnknownFields
+
+instance MessageSize Fields24'Inner where
+  messageSize msg =
+    (if msg.fields24InnerInherited == 0 then 0 else archVarintSize (fromIntegral msg.fields24InnerInherited))
+    + (maybe 0 (\v -> archVarintSize (fromIntegral v)) msg.fields24InnerExplicitValue)
+    + unknownFieldsSize msg.fields24InnerUnknownFields
+
+instance MessageDecode Fields24'Inner where
+  {-# INLINE messageDecoder #-}
+  messageDecoder = loop 0 Nothing []
+    where
+      loop acc_0 acc_1 acc_unknown_ = withTagM
+        (pure (Fields24'Inner {fields24InnerInherited = acc_0, fields24InnerExplicitValue = acc_1, fields24InnerUnknownFields = reverse acc_unknown_}))
+        (\fn wt -> case fn of
+          1 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop v acc_1 acc_unknown_
+          2 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop acc_0 (Just v) acc_unknown_
+          _ -> do
+            uf <- captureUnknownField fn (toEnum wt)
+            loop acc_0 acc_1 (uf : acc_unknown_))
+
+instance ProtoMessage Fields24'Inner where
+  protoMessageName _ = "Fields24.Inner"
+  protoPackageName _ = ""
+  protoDefaultValue = defaultFields24'Inner
+  protoFileDescriptorBytes _ = fileDescriptorProtoBytes
+  protoFieldDescriptors _ = Map.fromList
+    [ (1, SomeField FieldDescriptor
+        { fdName = "inherited"
+        , fdNumber = 1
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelOptional
+        , fdGet = fields24InnerInherited
+        , fdSet = \v m -> m { fields24InnerInherited = v }
+        }), (2, SomeField FieldDescriptor
+        { fdName = "explicit_value"
+        , fdNumber = 2
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelOptional
+        , fdGet = fields24InnerExplicitValue
+        , fdSet = \v m -> m { fields24InnerExplicitValue = v }
+        })
+    ]
+
+instance IsMessage Fields24'Inner
+
+instance Aeson.ToJSON Fields24'Inner where
+  toJSON msg = jsonObject
+      [ "inherited" .=: msg.fields24InnerInherited
+      , "explicitValue" .=: msg.fields24InnerExplicitValue
+      ]
+
+instance Aeson.FromJSON Fields24'Inner where
+  parseJSON = Aeson.withObject "Fields24'Inner" $ \obj -> do
+    fld_fields24InnerInherited <- parseFieldMaybe obj "inherited"
+    fld_fields24InnerExplicitValue <- parseFieldMaybe obj "explicitValue"
+    pure defaultFields24'Inner
+      { fields24InnerInherited = maybe (fields24InnerInherited defaultFields24'Inner) id fld_fields24InnerInherited
+      , fields24InnerExplicitValue = maybe (fields24InnerExplicitValue defaultFields24'Inner) id fld_fields24InnerExplicitValue
+      , fields24InnerUnknownFields = []
+      }
+
+instance Hashable Fields24'Inner where
+  hashWithSalt salt msg = hashWithSalt (hashWithSalt (salt) msg.fields24InnerInherited) msg.fields24InnerExplicitValue
+
+instance Proto.Extension.HasExtensions Fields24'Inner where
+  messageUnknownFields = fields24InnerUnknownFields
+  setMessageUnknownFields !ufs msg = msg { fields24InnerUnknownFields = ufs }
+
+instance Semigroup Fields24'Inner where
+  a <> b = Fields24'Inner
+    { fields24InnerInherited = if b.fields24InnerInherited == 0 then a.fields24InnerInherited else b.fields24InnerInherited
+    , fields24InnerExplicitValue = case b.fields24InnerExplicitValue of { Nothing -> a.fields24InnerExplicitValue; x -> x }
+    , fields24InnerUnknownFields = a.fields24InnerUnknownFields <> b.fields24InnerUnknownFields
+    }
+
+instance Monoid Fields24'Inner where
+  mempty = defaultFields24'Inner
+
+defaultFields24 :: Fields24
+defaultFields24 = Fields24
+  { fields24Count = 0
+  , fields24ExplicitCount = Nothing
+  , fields24Inner = Nothing
+  , fields24UnknownFields = []
+  }
+
+instance MessageEncode Fields24 where
+  buildMessage msg =
+    (if msg.fields24Count == 0 then mempty else archVarint 8 (fromIntegral msg.fields24Count))
+    <> (maybe mempty (\v -> archVarint 16 (fromIntegral v)) msg.fields24ExplicitCount)
+    <> (maybe mempty (\v -> let sz = messageSize v in archSubmessage 26 sz (buildMessage v)) msg.fields24Inner)
+    <> encodeUnknownFields msg.fields24UnknownFields
+
+instance MessageSize Fields24 where
+  messageSize msg =
+    (if msg.fields24Count == 0 then 0 else archVarintSize (fromIntegral msg.fields24Count))
+    + (maybe 0 (\v -> archVarintSize (fromIntegral v)) msg.fields24ExplicitCount)
+    + (maybe 0 (\v -> archSubmessageSize (messageSize v)) msg.fields24Inner)
+    + unknownFieldsSize msg.fields24UnknownFields
+
+instance MessageDecode Fields24 where
+  {-# INLINE messageDecoder #-}
+  messageDecoder = loop 0 Nothing Nothing []
+    where
+      loop acc_0 acc_1 acc_2 acc_unknown_ = withTagM
+        (pure (Fields24 {fields24Count = acc_0, fields24ExplicitCount = acc_1, fields24Inner = acc_2, fields24UnknownFields = reverse acc_unknown_}))
+        (\fn wt -> case fn of
+          1 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop v acc_1 acc_2 acc_unknown_
+          2 -> do
+            v <- (fromIntegral <$> decodeFieldVarint)
+            loop acc_0 (Just v) acc_2 acc_unknown_
+          3 -> do
+            v <- decodeFieldMessage
+            loop acc_0 acc_1 (Just v) acc_unknown_
+          _ -> do
+            uf <- captureUnknownField fn (toEnum wt)
+            loop acc_0 acc_1 acc_2 (uf : acc_unknown_))
+
+instance ProtoMessage Fields24 where
+  protoMessageName _ = "Fields24"
+  protoPackageName _ = ""
+  protoDefaultValue = defaultFields24
+  protoFileDescriptorBytes _ = fileDescriptorProtoBytes
+  protoFieldDescriptors _ = Map.fromList
+    [ (1, SomeField FieldDescriptor
+        { fdName = "count"
+        , fdNumber = 1
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelOptional
+        , fdGet = fields24Count
+        , fdSet = \v m -> m { fields24Count = v }
+        }), (2, SomeField FieldDescriptor
+        { fdName = "explicit_count"
+        , fdNumber = 2
+        , fdTypeDesc = ScalarType Int32Field
+        , fdLabel = LabelOptional
+        , fdGet = fields24ExplicitCount
+        , fdSet = \v m -> m { fields24ExplicitCount = v }
+        })
+    , (3, SomeField FieldDescriptor
+        { fdName = "inner"
+        , fdNumber = 3
+        , fdTypeDesc = MessageType "Inner"
+        , fdLabel = LabelOptional
+        , fdGet = fields24Inner
+        , fdSet = \v m -> m { fields24Inner = v }
+        })
+    ]
+
+instance IsMessage Fields24
+
+instance Aeson.ToJSON Fields24 where
+  toJSON msg = jsonObject
+      [ "count" .=: msg.fields24Count
+      , "explicitCount" .=: msg.fields24ExplicitCount
+      , "inner" .=: msg.fields24Inner
+      ]
+
+instance Aeson.FromJSON Fields24 where
+  parseJSON = Aeson.withObject "Fields24" $ \obj -> do
+    fld_fields24Count <- parseFieldMaybe obj "count"
+    fld_fields24ExplicitCount <- parseFieldMaybe obj "explicitCount"
+    fld_fields24Inner <- parseFieldMaybe obj "inner"
+    pure defaultFields24
+      { fields24Count = maybe (fields24Count defaultFields24) id fld_fields24Count
+      , fields24ExplicitCount = maybe (fields24ExplicitCount defaultFields24) id fld_fields24ExplicitCount
+      , fields24Inner = maybe (fields24Inner defaultFields24) id fld_fields24Inner
+      , fields24UnknownFields = []
+      }
+
+instance Hashable Fields24 where
+  hashWithSalt salt msg = hashWithSalt (hashWithSalt (hashWithSalt (salt) msg.fields24Count) msg.fields24ExplicitCount) msg.fields24Inner
+
+instance Proto.Extension.HasExtensions Fields24 where
+  messageUnknownFields = fields24UnknownFields
+  setMessageUnknownFields !ufs msg = msg { fields24UnknownFields = ufs }
+
+instance Semigroup Fields24 where
+  a <> b = Fields24
+    { fields24Count = if b.fields24Count == 0 then a.fields24Count else b.fields24Count
+    , fields24ExplicitCount = case b.fields24ExplicitCount of { Nothing -> a.fields24ExplicitCount; x -> x }
+    , fields24Inner = case b.fields24Inner of { Nothing -> a.fields24Inner; x -> x }
+    , fields24UnknownFields = a.fields24UnknownFields <> b.fields24UnknownFields
+    }
+
+instance Monoid Fields24 where
+  mempty = defaultFields24
diff --git a/test-regression/Generated/Integermaps.hs b/test-regression/Generated/Integermaps.hs
new file mode 100644
--- /dev/null
+++ b/test-regression/Generated/Integermaps.hs
@@ -0,0 +1,392 @@
+{-# LANGUAGE StrictData #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE OverloadedRecordDot #-}
+{-# OPTIONS_GHC -Wno-unused-imports -Wno-unused-matches -Wno-unused-top-binds #-}
+-- | Auto-generated protobuf types.
+--
+-- __THIS FILE IS AUTO-GENERATED BY wireform. DO NOT EDIT.__
+--
+-- Any manual changes will be overwritten the next time code
+-- generation is run.  To modify the types or instances, edit the
+-- @.proto@ source file and re-run the code generator.
+module Generated.Integermaps where
+
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import qualified Wireform.Builder as B
+import Data.Int (Int32, Int64)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Word (Word32, Word64)
+import qualified Data.Map.Strict as Map
+import qualified Data.Vector as V
+import qualified Data.Vector.Unboxed as VU
+import GHC.Generics (Generic)
+import Control.DeepSeq (NFData(..))
+import Data.Hashable (Hashable(..))
+import Proto.Encode
+import Proto.Decode
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Types as Aeson
+import qualified Data.Aeson.Key as AesonKey
+import qualified Data.Aeson.KeyMap as AesonKM
+import Proto.Internal.JSON (jsonObject, (.=:), parseFieldMaybe, bytesFieldToJSON, parseBytesFieldMaybe, bytesMapFieldToJSON, parseBytesMapFieldMaybe, protoBytesToJSON)
+import Data.Proxy (Proxy(..))
+import Data.Reflection (Given(..), given)
+import Proto.Internal.JSON.Extension qualified as PJExt
+import Proto.Schema (ProtoMessage(..), SomeFieldDescriptor(..), FieldDescriptor(..), FieldTypeDescriptor(..), ScalarFieldType(..), FieldLabel'(..))
+import Proto.Registry (IsMessage)
+import Proto.Registry qualified
+import qualified Proto.Extension
+import Proto.Internal.Wire (Tag(..), WireType(..))
+import Proto.Internal.Wire.Encode (putTag, putVarint, putFixed32, putFixed64,
+  putFloat, putDouble, putText, putByteString, putLengthDelimited,
+  putSVarint32, putSVarint64, putVarintSigned,
+  varintSize, tagSize, fieldMessageSize,
+  fieldVarintSize, fieldFixed32Size, fieldFixed64Size,
+  fieldBoolSize, fieldFloatSize, fieldDoubleSize,
+  fieldTextSize, fieldBytesSize,
+  fieldSVarint32Size, fieldSVarint64Size,
+  varintSize32, zigZag32, zigZag64)
+import Proto.Internal.Encode.Archetype (archVarint, archSVarint32, archSVarint64,
+  archFixed32, archFixed64, archFloat, archDouble, archBool,
+  archString, archBytes, archSubmessage,
+  archVarintSize, archStringSize, archBytesSize, archBoolSize,
+  archFixed32Size, archFixed64Size, archSubmessageSize)
+
+-- | Serialized FileDescriptorProto for this .proto file.
+-- Decode with @Proto.Google.Protobuf.Descriptor.decodeMessage@.
+fileDescriptorProtoBytes :: ByteString
+fileDescriptorProtoBytes = "\x0a\x11\x69\x6e\x74\x65\x67\x65\x72\x4d\x61\x70\x73\x2e\x70\x72\x6f\x74\x6f\x22\x93\x04\x0a\x0b\x49\x6e\x74\x65\x67\x65\x72\x4d\x61\x70\x73\x12\x27\x0a\x0c\x69\x6e\x74\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x18\x01\x20\x03\x28\x0b\x32\x11\x69\x6e\x74\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x27\x0a\x0c\x69\x6e\x74\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x18\x02\x20\x03\x28\x0b\x32\x11\x69\x6e\x74\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x29\x0a\x0d\x75\x69\x6e\x74\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x18\x03\x20\x03\x28\x0b\x32\x12\x75\x69\x6e\x74\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x29\x0a\x0d\x75\x69\x6e\x74\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x18\x04\x20\x03\x28\x0b\x32\x12\x75\x69\x6e\x74\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x29\x0a\x0d\x73\x69\x6e\x74\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x18\x05\x20\x03\x28\x0b\x32\x12\x73\x69\x6e\x74\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x29\x0a\x0d\x73\x69\x6e\x74\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x18\x06\x20\x03\x28\x0b\x32\x12\x73\x69\x6e\x74\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x2d\x0a\x0f\x73\x66\x69\x78\x65\x64\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x18\x07\x20\x03\x28\x0b\x32\x14\x73\x66\x69\x78\x65\x64\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x2d\x0a\x0f\x73\x66\x69\x78\x65\x64\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x18\x08\x20\x03\x28\x0b\x32\x14\x73\x66\x69\x78\x65\x64\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x27\x0a\x0c\x69\x6e\x74\x65\x67\x65\x72\x5f\x6b\x65\x79\x73\x18\x09\x20\x03\x28\x0b\x32\x11\x69\x6e\x74\x65\x67\x65\x72\x5f\x6b\x65\x79\x73\x45\x6e\x74\x72\x79\x12\x2b\x0a\x0e\x66\x69\x78\x65\x64\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x18\x0a\x20\x03\x28\x0b\x32\x13\x66\x69\x78\x65\x64\x33\x32\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x2b\x0a\x0e\x66\x69\x78\x65\x64\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x18\x0b\x20\x03\x28\x0b\x32\x13\x66\x69\x78\x65\x64\x36\x34\x5f\x76\x61\x6c\x75\x65\x73\x45\x6e\x74\x72\x79\x12\x25\x0a\x0b\x7a\x69\x67\x7a\x61\x67\x5f\x6b\x65\x79\x73\x18\x0c\x20\x03\x28\x0b\x32\x10\x7a\x69\x67\x7a\x61\x67\x5f\x6b\x65\x79\x73\x45\x6e\x74\x72\x79\x62\x06\x70\x72\x6f\x74\x6f\x33"
+
+
+data IntegerMaps = IntegerMaps
+  { integerMapsInt32Values :: !(Map.Map Text Int32)
+  , integerMapsInt64Values :: !(Map.Map Text Int64)
+  , integerMapsUint32Values :: !(Map.Map Text Word32)
+  , integerMapsUint64Values :: !(Map.Map Text Word64)
+  , integerMapsSint32Values :: !(Map.Map Text Int32)
+  , integerMapsSint64Values :: !(Map.Map Text Int64)
+  , integerMapsSfixed32Values :: !(Map.Map Text Int32)
+  , integerMapsSfixed64Values :: !(Map.Map Text Int64)
+  , integerMapsIntegerKeys :: !(Map.Map Int32 Int32)
+  , integerMapsFixed32Values :: !(Map.Map Text Word32)
+  , integerMapsFixed64Values :: !(Map.Map Text Word64)
+  , integerMapsZigzagKeys :: !(Map.Map Int32 Text)
+  , integerMapsUnknownFields :: ![UnknownField]
+  }
+  deriving stock (Show, Eq, Generic)
+  deriving anyclass NFData
+
+defaultIntegerMaps :: IntegerMaps
+defaultIntegerMaps = IntegerMaps
+  { integerMapsInt32Values = Map.empty
+  , integerMapsInt64Values = Map.empty
+  , integerMapsUint32Values = Map.empty
+  , integerMapsUint64Values = Map.empty
+  , integerMapsSint32Values = Map.empty
+  , integerMapsSint64Values = Map.empty
+  , integerMapsSfixed32Values = Map.empty
+  , integerMapsSfixed64Values = Map.empty
+  , integerMapsIntegerKeys = Map.empty
+  , integerMapsFixed32Values = Map.empty
+  , integerMapsFixed64Values = Map.empty
+  , integerMapsZigzagKeys = Map.empty
+  , integerMapsUnknownFields = []
+  }
+
+instance MessageEncode IntegerMaps where
+  buildMessage msg =
+    Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 1 (encodeFieldString 1 k) ((encodeFieldVarint 2 . fromIntegral) v)) mempty msg.integerMapsInt32Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 2 (encodeFieldString 1 k) ((encodeFieldVarint 2 . fromIntegral) v)) mempty msg.integerMapsInt64Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 3 (encodeFieldString 1 k) ((encodeFieldVarint 2 . fromIntegral) v)) mempty msg.integerMapsUint32Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 4 (encodeFieldString 1 k) ((encodeFieldVarint 2 . fromIntegral) v)) mempty msg.integerMapsUint64Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 5 (encodeFieldString 1 k) (encodeFieldSVarint32 2 v)) mempty msg.integerMapsSint32Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 6 (encodeFieldString 1 k) (encodeFieldSVarint64 2 v)) mempty msg.integerMapsSint64Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 7 (encodeFieldString 1 k) ((encodeFieldFixed32 2 . fromIntegral) v)) mempty msg.integerMapsSfixed32Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 8 (encodeFieldString 1 k) ((encodeFieldFixed64 2 . fromIntegral) v)) mempty msg.integerMapsSfixed64Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 9 ((\x -> encodeFieldVarint 1 (fromIntegral x)) k) ((encodeFieldVarint 2 . fromIntegral) v)) mempty msg.integerMapsIntegerKeys
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 10 (encodeFieldString 1 k) (encodeFieldFixed32 2 v)) mempty msg.integerMapsFixed32Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 11 (encodeFieldString 1 k) (encodeFieldFixed64 2 v)) mempty msg.integerMapsFixed64Values
+    <> Map.foldlWithKey' (\acc k v -> acc <> encodeMapField 12 (encodeFieldSVarint32 1 k) (encodeFieldString 2 v)) mempty msg.integerMapsZigzagKeys
+    <> encodeUnknownFields msg.integerMapsUnknownFields
+
+instance MessageSize IntegerMaps where
+  messageSize msg =
+    (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldVarintSize 2 (fromIntegral v) in acc + tagSize 1 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsInt32Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldVarintSize 2 (fromIntegral v) in acc + tagSize 2 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsInt64Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldVarintSize 2 (fromIntegral v) in acc + tagSize 3 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsUint32Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldVarintSize 2 (fromIntegral v) in acc + tagSize 4 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsUint64Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldSVarint32Size 2 v in acc + tagSize 5 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsSint32Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldSVarint64Size 2 v in acc + tagSize 6 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsSint64Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldFixed32Size 2 in acc + tagSize 7 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsSfixed32Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldFixed64Size 2 in acc + tagSize 8 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsSfixed64Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldVarintSize 1 (fromIntegral k) + fieldVarintSize 2 (fromIntegral v) in acc + tagSize 9 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsIntegerKeys)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldFixed32Size 2 in acc + tagSize 10 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsFixed32Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldTextSize 1 k + fieldFixed64Size 2 in acc + tagSize 11 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsFixed64Values)
+    + (Map.foldlWithKey' (\acc k v -> let entrySz = fieldSVarint32Size 1 k + fieldTextSize 2 v in acc + tagSize 12 + varintSize (fromIntegral entrySz) + entrySz) 0 msg.integerMapsZigzagKeys)
+    + unknownFieldsSize msg.integerMapsUnknownFields
+
+instance MessageDecode IntegerMaps where
+  {-# INLINE messageDecoder #-}
+  messageDecoder = loop Map.empty Map.empty Map.empty Map.empty Map.empty Map.empty Map.empty Map.empty Map.empty Map.empty Map.empty Map.empty []
+    where
+      loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_ = withTagM
+        (pure (IntegerMaps {integerMapsInt32Values = acc_0, integerMapsInt64Values = acc_1, integerMapsUint32Values = acc_2, integerMapsUint64Values = acc_3, integerMapsSint32Values = acc_4, integerMapsSint64Values = acc_5, integerMapsSfixed32Values = acc_6, integerMapsSfixed64Values = acc_7, integerMapsIntegerKeys = acc_8, integerMapsFixed32Values = acc_9, integerMapsFixed64Values = acc_10, integerMapsZigzagKeys = acc_11, integerMapsUnknownFields = reverse acc_unknown_}))
+        (\fn wt -> case fn of
+          1 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString (fromIntegral <$> decodeFieldVarint) "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop (Map.union acc_0 (Map.singleton mk' mv')) acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+          2 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString (fromIntegral <$> decodeFieldVarint) "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 (Map.union acc_1 (Map.singleton mk' mv')) acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+          3 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString (fromIntegral <$> decodeFieldVarint) "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 (Map.union acc_2 (Map.singleton mk' mv')) acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+          4 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString decodeFieldVarint "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 (Map.union acc_3 (Map.singleton mk' mv')) acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+          5 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString decodeFieldSVarint32 "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 (Map.union acc_4 (Map.singleton mk' mv')) acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+          6 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString decodeFieldSVarint64 "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 acc_4 (Map.union acc_5 (Map.singleton mk' mv')) acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+          7 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString (fromIntegral <$> decodeFieldFixed32) "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 (Map.union acc_6 (Map.singleton mk' mv')) acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+          8 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString (fromIntegral <$> decodeFieldFixed64) "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 (Map.union acc_7 (Map.singleton mk' mv')) acc_8 acc_9 acc_10 acc_11 acc_unknown_
+          9 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry (fromIntegral <$> decodeFieldVarint) (fromIntegral <$> decodeFieldVarint) 0 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 (Map.union acc_8 (Map.singleton mk' mv')) acc_9 acc_10 acc_11 acc_unknown_
+          10 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString decodeFieldFixed32 "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 (Map.union acc_9 (Map.singleton mk' mv')) acc_10 acc_11 acc_unknown_
+          11 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldString decodeFieldFixed64 "" 0) bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 (Map.union acc_10 (Map.singleton mk' mv')) acc_11 acc_unknown_
+          12 -> do
+            bs' <- getLengthDelimited
+            let decodeEntry = runDecoder (decodeMapEntry decodeFieldSVarint32 decodeFieldString 0 "") bs'
+            case decodeEntry of
+              Left _ -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 acc_unknown_
+              Right (mk', mv') -> loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 (Map.union acc_11 (Map.singleton mk' mv')) acc_unknown_
+          _ -> do
+            uf <- captureUnknownField fn (toEnum wt)
+            loop acc_0 acc_1 acc_2 acc_3 acc_4 acc_5 acc_6 acc_7 acc_8 acc_9 acc_10 acc_11 (uf : acc_unknown_))
+
+instance ProtoMessage IntegerMaps where
+  protoMessageName _ = "IntegerMaps"
+  protoPackageName _ = ""
+  protoDefaultValue = defaultIntegerMaps
+  protoFileDescriptorBytes _ = fileDescriptorProtoBytes
+  protoFieldDescriptors _ = Map.fromList
+    [ (1, SomeField FieldDescriptor
+        { fdName = "int32_values"
+        , fdNumber = 1
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsInt32Values
+        , fdSet = \v m -> m { integerMapsInt32Values = v }
+        }), (2, SomeField FieldDescriptor
+        { fdName = "int64_values"
+        , fdNumber = 2
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsInt64Values
+        , fdSet = \v m -> m { integerMapsInt64Values = v }
+        })
+    , (3, SomeField FieldDescriptor
+        { fdName = "uint32_values"
+        , fdNumber = 3
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsUint32Values
+        , fdSet = \v m -> m { integerMapsUint32Values = v }
+        })
+    , (4, SomeField FieldDescriptor
+        { fdName = "uint64_values"
+        , fdNumber = 4
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsUint64Values
+        , fdSet = \v m -> m { integerMapsUint64Values = v }
+        })
+    , (5, SomeField FieldDescriptor
+        { fdName = "sint32_values"
+        , fdNumber = 5
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsSint32Values
+        , fdSet = \v m -> m { integerMapsSint32Values = v }
+        })
+    , (6, SomeField FieldDescriptor
+        { fdName = "sint64_values"
+        , fdNumber = 6
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsSint64Values
+        , fdSet = \v m -> m { integerMapsSint64Values = v }
+        })
+    , (7, SomeField FieldDescriptor
+        { fdName = "sfixed32_values"
+        , fdNumber = 7
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsSfixed32Values
+        , fdSet = \v m -> m { integerMapsSfixed32Values = v }
+        })
+    , (8, SomeField FieldDescriptor
+        { fdName = "sfixed64_values"
+        , fdNumber = 8
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsSfixed64Values
+        , fdSet = \v m -> m { integerMapsSfixed64Values = v }
+        })
+    , (9, SomeField FieldDescriptor
+        { fdName = "integer_keys"
+        , fdNumber = 9
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsIntegerKeys
+        , fdSet = \v m -> m { integerMapsIntegerKeys = v }
+        })
+    , (10, SomeField FieldDescriptor
+        { fdName = "fixed32_values"
+        , fdNumber = 10
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsFixed32Values
+        , fdSet = \v m -> m { integerMapsFixed32Values = v }
+        })
+    , (11, SomeField FieldDescriptor
+        { fdName = "fixed64_values"
+        , fdNumber = 11
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsFixed64Values
+        , fdSet = \v m -> m { integerMapsFixed64Values = v }
+        })
+    , (12, SomeField FieldDescriptor
+        { fdName = "zigzag_keys"
+        , fdNumber = 12
+        , fdTypeDesc = ScalarType BytesField
+        , fdLabel = LabelRepeated
+        , fdGet = integerMapsZigzagKeys
+        , fdSet = \v m -> m { integerMapsZigzagKeys = v }
+        })
+    ]
+
+instance IsMessage IntegerMaps
+
+instance Aeson.ToJSON IntegerMaps where
+  toJSON msg = jsonObject
+      [ "int32Values" .=: msg.integerMapsInt32Values
+      , "int64Values" .=: msg.integerMapsInt64Values
+      , "uint32Values" .=: msg.integerMapsUint32Values
+      , "uint64Values" .=: msg.integerMapsUint64Values
+      , "sint32Values" .=: msg.integerMapsSint32Values
+      , "sint64Values" .=: msg.integerMapsSint64Values
+      , "sfixed32Values" .=: msg.integerMapsSfixed32Values
+      , "sfixed64Values" .=: msg.integerMapsSfixed64Values
+      , "integerKeys" .=: msg.integerMapsIntegerKeys
+      , "fixed32Values" .=: msg.integerMapsFixed32Values
+      , "fixed64Values" .=: msg.integerMapsFixed64Values
+      , "zigzagKeys" .=: msg.integerMapsZigzagKeys
+      ]
+
+instance Aeson.FromJSON IntegerMaps where
+  parseJSON = Aeson.withObject "IntegerMaps" $ \obj -> do
+    fld_integerMapsInt32Values <- parseFieldMaybe obj "int32Values"
+    fld_integerMapsInt64Values <- parseFieldMaybe obj "int64Values"
+    fld_integerMapsUint32Values <- parseFieldMaybe obj "uint32Values"
+    fld_integerMapsUint64Values <- parseFieldMaybe obj "uint64Values"
+    fld_integerMapsSint32Values <- parseFieldMaybe obj "sint32Values"
+    fld_integerMapsSint64Values <- parseFieldMaybe obj "sint64Values"
+    fld_integerMapsSfixed32Values <- parseFieldMaybe obj "sfixed32Values"
+    fld_integerMapsSfixed64Values <- parseFieldMaybe obj "sfixed64Values"
+    fld_integerMapsIntegerKeys <- parseFieldMaybe obj "integerKeys"
+    fld_integerMapsFixed32Values <- parseFieldMaybe obj "fixed32Values"
+    fld_integerMapsFixed64Values <- parseFieldMaybe obj "fixed64Values"
+    fld_integerMapsZigzagKeys <- parseFieldMaybe obj "zigzagKeys"
+    pure defaultIntegerMaps
+      { integerMapsInt32Values = maybe (integerMapsInt32Values defaultIntegerMaps) id fld_integerMapsInt32Values
+      , integerMapsInt64Values = maybe (integerMapsInt64Values defaultIntegerMaps) id fld_integerMapsInt64Values
+      , integerMapsUint32Values = maybe (integerMapsUint32Values defaultIntegerMaps) id fld_integerMapsUint32Values
+      , integerMapsUint64Values = maybe (integerMapsUint64Values defaultIntegerMaps) id fld_integerMapsUint64Values
+      , integerMapsSint32Values = maybe (integerMapsSint32Values defaultIntegerMaps) id fld_integerMapsSint32Values
+      , integerMapsSint64Values = maybe (integerMapsSint64Values defaultIntegerMaps) id fld_integerMapsSint64Values
+      , integerMapsSfixed32Values = maybe (integerMapsSfixed32Values defaultIntegerMaps) id fld_integerMapsSfixed32Values
+      , integerMapsSfixed64Values = maybe (integerMapsSfixed64Values defaultIntegerMaps) id fld_integerMapsSfixed64Values
+      , integerMapsIntegerKeys = maybe (integerMapsIntegerKeys defaultIntegerMaps) id fld_integerMapsIntegerKeys
+      , integerMapsFixed32Values = maybe (integerMapsFixed32Values defaultIntegerMaps) id fld_integerMapsFixed32Values
+      , integerMapsFixed64Values = maybe (integerMapsFixed64Values defaultIntegerMaps) id fld_integerMapsFixed64Values
+      , integerMapsZigzagKeys = maybe (integerMapsZigzagKeys defaultIntegerMaps) id fld_integerMapsZigzagKeys
+      , integerMapsUnknownFields = []
+      }
+
+instance Hashable IntegerMaps where
+  hashWithSalt salt msg = Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (Map.foldlWithKey' (\s k v -> s `hashWithSalt` k `hashWithSalt` v) (salt) msg.integerMapsInt32Values) msg.integerMapsInt64Values) msg.integerMapsUint32Values) msg.integerMapsUint64Values) msg.integerMapsSint32Values) msg.integerMapsSint64Values) msg.integerMapsSfixed32Values) msg.integerMapsSfixed64Values) msg.integerMapsIntegerKeys) msg.integerMapsFixed32Values) msg.integerMapsFixed64Values) msg.integerMapsZigzagKeys
+
+instance Proto.Extension.HasExtensions IntegerMaps where
+  messageUnknownFields = integerMapsUnknownFields
+  setMessageUnknownFields !ufs msg = msg { integerMapsUnknownFields = ufs }
+
+instance Semigroup IntegerMaps where
+  a <> b = IntegerMaps
+    { integerMapsInt32Values = a.integerMapsInt32Values <> b.integerMapsInt32Values
+    , integerMapsInt64Values = a.integerMapsInt64Values <> b.integerMapsInt64Values
+    , integerMapsUint32Values = a.integerMapsUint32Values <> b.integerMapsUint32Values
+    , integerMapsUint64Values = a.integerMapsUint64Values <> b.integerMapsUint64Values
+    , integerMapsSint32Values = a.integerMapsSint32Values <> b.integerMapsSint32Values
+    , integerMapsSint64Values = a.integerMapsSint64Values <> b.integerMapsSint64Values
+    , integerMapsSfixed32Values = a.integerMapsSfixed32Values <> b.integerMapsSfixed32Values
+    , integerMapsSfixed64Values = a.integerMapsSfixed64Values <> b.integerMapsSfixed64Values
+    , integerMapsIntegerKeys = a.integerMapsIntegerKeys <> b.integerMapsIntegerKeys
+    , integerMapsFixed32Values = a.integerMapsFixed32Values <> b.integerMapsFixed32Values
+    , integerMapsFixed64Values = a.integerMapsFixed64Values <> b.integerMapsFixed64Values
+    , integerMapsZigzagKeys = a.integerMapsZigzagKeys <> b.integerMapsZigzagKeys
+    , integerMapsUnknownFields = a.integerMapsUnknownFields <> b.integerMapsUnknownFields
+    }
+
+instance Monoid IntegerMaps where
+  mempty = defaultIntegerMaps
diff --git a/test-regression/Main.hs b/test-regression/Main.hs
new file mode 100644
--- /dev/null
+++ b/test-regression/Main.hs
@@ -0,0 +1,151 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Main (main) where
+
+import Control.Exception (ErrorCall, evaluate)
+import Control.Monad (forM_)
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Types qualified as Aeson
+import Data.ByteString qualified as BS
+import Data.Either (isLeft)
+import Data.Map.Strict qualified as Map
+import Data.Text qualified as T
+import Data.Text.IO qualified as TIO
+import Generated.Edition2023 qualified as G23
+import Generated.Edition2024 qualified as G24
+import Generated.Integermaps qualified as GM
+import Proto.CodeGen
+import Proto.Decode
+import Proto.Encode
+import Proto.IDL.Parser
+import Proto.IDL.Parser.Resolver (ResolvedProto (..))
+import Proto.Internal.JSON qualified as JSON
+import THFixtures qualified as TH
+import Test.Syd
+
+
+main :: IO ()
+main = sydTest $ do
+  describe "Editions field presence" $ do
+    it "standalone codegen preserves every explicitly present scalar default" $
+      forM_ scalarDefaults $
+        roundtrip @G23.Fields23
+    it "loadProto preserves every explicitly present scalar default" $
+      forM_ scalarDefaults $
+        roundtrip @TH.Fields23
+    it "absent and explicitly zero fields remain distinct in both generators" $ do
+      fmap G23.fields23Count (decodeMessage BS.empty) `shouldBe` Right Nothing
+      fmap G23.fields23Count (decodeMessage (BS.pack [8, 0])) `shouldBe` Right (Just 0)
+      fmap TH.fields23Count (decodeMessage BS.empty) `shouldBe` Right Nothing
+      fmap TH.fields23Count (decodeMessage (BS.pack [8, 0])) `shouldBe` Right (Just 0)
+    it "file and field overrides, aggregate options and nested messages agree" $ do
+      forM_ [BS.pack [16, 0], BS.pack [26, 2, 16, 0]] $ roundtrip @G24.Fields24
+      forM_ [BS.pack [16, 0], BS.pack [26, 2, 16, 0]] $ roundtrip @TH.Fields24
+      fmap G24.fields24Count (decodeMessage (BS.pack [8, 0])) `shouldBe` Right 0
+      fmap TH.fields24Count (decodeMessage (BS.pack [8, 0])) `shouldBe` Right 0
+      fmap encodeMessage (decodeMessage (BS.pack [8, 0]) :: Either DecodeError G24.Fields24) `shouldBe` Right BS.empty
+      fmap encodeMessage (decodeMessage (BS.pack [8, 0]) :: Either DecodeError TH.Fields24) `shouldBe` Right BS.empty
+      fmap encodeMessage (decodeMessage (BS.pack [64, 0, 112, 0]) :: Either DecodeError G23.Fields23) `shouldBe` Right BS.empty
+      fmap encodeMessage (decodeMessage (BS.pack [64, 0, 112, 0]) :: Either DecodeError TH.Fields23) `shouldBe` Right BS.empty
+    it "repeated, map, oneof and nested fields retain their wire shapes" $ do
+      forM_ collectionValues $ roundtrip @G23.Fields23
+      forM_ collectionValues $ roundtrip @TH.Fields23
+    it "merge preserves an explicitly present zero over a nonzero value" $ do
+      case (decodeMessage (BS.pack [8, 7]), decodeMessage (BS.pack [8, 0])) of
+        (Right a, Right b) -> encodeMessage (a <> b :: G23.Fields23) `shouldBe` BS.pack [8, 0]
+        _ -> expectationFailure "standalone fixture decode failed"
+      case (decodeMessage (BS.pack [8, 7]), decodeMessage (BS.pack [8, 0])) of
+        (Right a, Right b) -> encodeMessage (a <> b :: TH.Fields23) `shouldBe` BS.pack [8, 0]
+        _ -> expectationFailure "TH fixture decode failed"
+    it "does not silently accept unsupported LEGACY_REQUIRED presence" $ do
+      pf <-
+        either (fail . show) pure $
+          parseProtoFile
+            "unsupported.proto"
+            "edition = \"2023\"; message M { int32 x = 1 [features.field_presence = LEGACY_REQUIRED]; }"
+      evaluate (T.length (generateModuleText defaultGenerateOpts Map.empty "unsupported.proto" pf))
+        `shouldThrow` (\(_ :: ErrorCall) -> True)
+    it "committed standalone fixtures exactly match the generator" $
+      forM_ ["edition2023", "edition2024", "integerMaps"] $ \name -> do
+        let file = name <> ".proto"
+        source <- TIO.readFile ("test-regression/proto/" <> file)
+        pf <- either (fail . show) pure (parseProtoFile file source)
+        let opts = defaultGenerateOpts {genModulePrefix = "Generated"}
+            registry = buildTypeRegistry opts [(file, ResolvedProto pf file Map.empty)]
+        actual <- TIO.readFile ("test-regression/" <> T.unpack (T.replace "." "/" (moduleNameForProto opts file pf)) <> ".hs")
+        actual `shouldBe` generateModuleText opts registry file pf
+  describe "Standalone integer maps" $ do
+    it "integer value functions compile and agree with TH for limits and zero" $
+      forM_ integerMapValues $ \message -> do
+        let wire = encodeMessage message
+        decodeMessage wire `shouldBe` Right message
+        messageSize message `shouldBe` BS.length wire
+        encodeMessageSized message `shouldBe` wire
+        fmap encodeMessage (decodeMessage wire :: Either DecodeError TH.IntegerMaps) `shouldBe` Right wire
+    it "sint32 and sint64 map values use canonical ZigZag and exact sizes" $ do
+      let small = GM.defaultIntegerMaps {GM.integerMapsSint32Values = Map.singleton "a" (-1)}
+          large = GM.defaultIntegerMaps {GM.integerMapsSint64Values = Map.singleton "a" (-1)}
+      encodeMessage small `shouldBe` BS.pack [42, 5, 10, 1, 97, 16, 1]
+      encodeMessage large `shouldBe` BS.pack [50, 5, 10, 1, 97, 16, 1]
+      messageSize small `shouldBe` 7
+      messageSize large `shouldBe` 7
+  describe "ProtoJSON quoted numbers" $ do
+    it "generated Float and Double accept e/E and signed exponents" $
+      forM_ (["1e2", "1E+2", "10000e-2", "1.0e2"] :: [T.Text]) $ \number -> do
+        let json = Aeson.object ["value" Aeson..= number, "small" Aeson..= number]
+        fmap TH.jsonNumbersValue (Aeson.parseEither Aeson.parseJSON json) `shouldBe` Right 100
+        fmap TH.jsonNumbersSmall (Aeson.parseEither Aeson.parseJSON json) `shouldBe` Right 100
+    it "optional and oneof floating fields use the same number grammar" $ do
+      let json = "{\"optionalValue\":\"1e2\",\"selected\":\"1E2\"}"
+      case Aeson.eitherDecode json of
+        Left err -> expectationFailure err
+        Right result -> do
+          TH.jsonNumbersOptionalValue result `shouldBe` Just 100
+          case TH.jsonNumbersChoice result of
+            Just (TH.JsonNumbers'Choice'Selected value) -> value `shouldBe` 100
+            _ -> expectationFailure "quoted oneof number was dropped"
+    it "runtime float/double and int64 helpers accept quoted exponents" $ do
+      Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String "1e2") `shouldBe` Right 100
+      Aeson.parseEither JSON.protoFloatFromJSON (Aeson.String "1E2") `shouldBe` Right 100
+      Aeson.parseEither JSON.protoInt64FromJSON (Aeson.String "1e2") `shouldBe` Right 100
+      Aeson.parseEither JSON.protoWord64FromJSON (Aeson.String "1E2") `shouldBe` Right 100
+    it "rejects whitespace, trailing junk, noncanonical sentinels and overflow" $
+      forM_ ["", " 1", "1 ", "1e", "1e2x", "nan", "NAN", "+nan", "-nan", " NaN", "NaN ", "inf", "INF", "+Infinity", " Infinity", "Infinity ", "-inf", "-INF", " -Infinity", "-Infinity ", "1e1000"] $ \number -> do
+        isLeft (Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String number)) `shouldBe` True
+        isLeft (Aeson.parseEither JSON.protoFloatFromJSON (Aeson.String number)) `shouldBe` True
+        isLeft (Aeson.parseEither Aeson.parseJSON (Aeson.object ["value" Aeson..= number]) :: Either String TH.JsonNumbers) `shouldBe` True
+    it "retains canonical NaN/Infinity strings" $ do
+      fmap isNaN (Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String "NaN")) `shouldBe` Right True
+      fmap isInfinite (Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String "Infinity")) `shouldBe` Right True
+      Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String "-Infinity") `shouldBe` Right (negate (1 / 0))
+
+
+scalarDefaults :: [BS.ByteString]
+scalarDefaults = fmap BS.pack [[8, 0], [18, 0], [26, 0], [32, 0], [40, 0], [49, 0, 0, 0, 0, 0, 0, 0, 0], [61, 0, 0, 0, 0]]
+
+
+collectionValues :: [BS.ByteString]
+collectionValues = fmap BS.pack [[74, 2, 1, 2], [82, 2, 8, 0], [90, 6, 10, 1, 97, 18, 1, 98], [96, 0], [106, 0]]
+
+
+roundtrip :: forall a. (MessageDecode a, MessageEncode a) => BS.ByteString -> IO ()
+roundtrip wire = fmap (encodeMessage @a) (decodeMessage wire) `shouldBe` Right wire
+
+
+integerMapValues :: [GM.IntegerMaps]
+integerMapValues =
+  [ GM.defaultIntegerMaps {GM.integerMapsInt32Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsInt64Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsUint32Values = Map.fromList [("a", 0), ("b", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsUint64Values = Map.fromList [("a", 0), ("b", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsSint32Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsSint64Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsSfixed32Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsSfixed64Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsFixed32Values = Map.fromList [("a", 0), ("b", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsFixed64Values = Map.fromList [("a", 0), ("b", maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsIntegerKeys = Map.fromList [(minBound, minBound), (0, 0), (maxBound, maxBound)]}
+  , GM.defaultIntegerMaps {GM.integerMapsZigzagKeys = Map.fromList [(minBound, "min"), (0, "zero"), (maxBound, "max")]}
+  ]
diff --git a/test-regression/THFixtures.hs b/test-regression/THFixtures.hs
new file mode 100644
--- /dev/null
+++ b/test-regression/THFixtures.hs
@@ -0,0 +1,14 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+module THFixtures where
+
+import Proto.TH (loadProto)
+
+
+$(loadProto "test-regression/proto/edition2023.proto")
+$(loadProto "test-regression/proto/edition2024.proto")
+$(loadProto "test-regression/proto/json_numbers.proto")
+
+
+$(loadProto "test-regression/proto/integerMaps.proto")
diff --git a/test-regression/proto/edition2023.proto b/test-regression/proto/edition2023.proto
new file mode 100644
--- /dev/null
+++ b/test-regression/proto/edition2023.proto
@@ -0,0 +1,18 @@
+edition = "2023";
+enum Status23 { STATUS23_UNSPECIFIED = 0; STATUS23_READY = 1; }
+message Fields23 {
+  int32 count = 1;
+  string name = 2;
+  bytes payload = 3;
+  bool enabled = 4;
+  Status23 status = 5;
+  double magnitude = 6;
+  float fraction = 7;
+  int32 implicit_count = 8 [features.field_presence = IMPLICIT];
+  repeated int32 values = 9;
+  message Inner { int32 count = 1; }
+  Inner inner = 10;
+  map<string, string> attrs = 11;
+  oneof choice { int32 picked = 12; string label = 13; }
+  int32 aggregate_implicit = 14 [features = {field_presence: IMPLICIT}];
+}
diff --git a/test-regression/proto/edition2024.proto b/test-regression/proto/edition2024.proto
new file mode 100644
--- /dev/null
+++ b/test-regression/proto/edition2024.proto
@@ -0,0 +1,11 @@
+edition = "2024";
+option features.field_presence = IMPLICIT;
+message Fields24 {
+  int32 count = 1;
+  int32 explicit_count = 2 [features.field_presence = EXPLICIT];
+  message Inner {
+    int32 inherited = 1;
+    int32 explicit_value = 2 [features = {field_presence: EXPLICIT}];
+  }
+  Inner inner = 3;
+}
diff --git a/test-regression/proto/integerMaps.proto b/test-regression/proto/integerMaps.proto
new file mode 100644
--- /dev/null
+++ b/test-regression/proto/integerMaps.proto
@@ -0,0 +1,15 @@
+syntax = "proto3";
+message IntegerMaps {
+  map<string, int32> int32_values = 1;
+  map<string, int64> int64_values = 2;
+  map<string, uint32> uint32_values = 3;
+  map<string, uint64> uint64_values = 4;
+  map<string, sint32> sint32_values = 5;
+  map<string, sint64> sint64_values = 6;
+  map<string, sfixed32> sfixed32_values = 7;
+  map<string, sfixed64> sfixed64_values = 8;
+  map<int32, int32> integer_keys = 9;
+  map<string, fixed32> fixed32_values = 10;
+  map<string, fixed64> fixed64_values = 11;
+  map<sint32, string> zigzag_keys = 12;
+}
diff --git a/test-regression/proto/json_numbers.proto b/test-regression/proto/json_numbers.proto
new file mode 100644
--- /dev/null
+++ b/test-regression/proto/json_numbers.proto
@@ -0,0 +1,7 @@
+syntax = "proto3";
+message JsonNumbers {
+ double value = 1;
+ float small = 2;
+ optional double optional_value = 3;
+ oneof choice { double selected = 4; string label = 5; }
+}
diff --git a/test/data/derive_regression.proto b/test/data/derive_regression.proto
new file mode 100644
--- /dev/null
+++ b/test/data/derive_regression.proto
@@ -0,0 +1,23 @@
+syntax = "proto3";
+
+// Regression fixture for the Proto.Derive IDL bridge: parallel types
+// here drive `loadProto` while `Test.Proto.Derive.RegressionInstances`
+// emits matching types via `deriveProtoFromTranslated`. The test
+// asserts byte-identical output for the same logical message.
+//
+// Field names are intentionally distinct between RegItem and
+// RegInventory because `loadProto` derives Haskell record selectors
+// from the proto field names, and Haskell rejects duplicate selector
+// names in the same module without DuplicateRecordFields. The
+// "reg_" prefix also prevents clashes with the rich-types fixtures
+// (`Inventory`, etc.) imported by the same test module.
+
+message RegItem {
+  string regi_name  = 1;
+  int32  regi_count = 2;
+}
+
+message RegInventory {
+  string  reginv_name  = 1;
+  repeated RegItem reginv_items = 2;
+}
diff --git a/test/data/oneof_regression.proto b/test/data/oneof_regression.proto
new file mode 100644
--- /dev/null
+++ b/test/data/oneof_regression.proto
@@ -0,0 +1,30 @@
+syntax = "proto3";
+
+// End-to-end oneof regression for `loadProto`'s bridge rewire.
+//
+// Before the rewire `loadProto` would emit a record with a
+// `Maybe Choice` field but never declare the `Choice` sum
+// type, so any proto file containing a `oneof` failed to compile.
+// This fixture exercises the new `mkOneofDataDecs` + bridge path:
+// the splice generates `Envelope'Choice` (sum) alongside the
+// `Envelope` record, and threads each variant through
+// `Proto.Derive.Internal`'s FKOneof codec emitter.
+//
+// We also include a sibling scalar field so the per-message
+// dispatch still works when the oneof isn't the only thing in
+// the record.
+
+message Envelope {
+  string envelope_label = 1;
+
+  oneof envelope_choice {
+    string choice_url   = 6;
+    bytes  choice_blob  = 7;
+    int32  choice_seed  = 8;
+    Inner  choice_inner = 9;
+  }
+}
+
+message Inner {
+  int32 inner_id = 1;
+}
diff --git a/test/data/topenum_regression.proto b/test/data/topenum_regression.proto
new file mode 100644
--- /dev/null
+++ b/test/data/topenum_regression.proto
@@ -0,0 +1,30 @@
+syntax = "proto3";
+
+// Regression for the top-level enum loadProto fix.
+//
+// Before the fix, `loadProto` translated `FTNamed` references as
+// `PFSubmessage` unconditionally, so a top-level enum field
+// silently encoded as a length-delimited submessage instead of a
+// varint — producing bytes that no other proto implementation
+// could read. The fix routes named-type lookups through the
+// file's ScopeCtx so enums are recognised as such and emitted as
+// PFEnum (varint via fromEnum/toEnum).
+
+enum Status {
+  STATUS_UNSPECIFIED = 0;
+  STATUS_ACTIVE      = 1;
+  STATUS_RETIRED     = 2;
+  STATUS_BANNED      = 3;
+}
+
+message Account {
+  string acct_name = 1;
+  Status acct_status = 2;          // top-level enum field
+}
+
+// Also cover packed scalars: proto3 default for packable scalars
+// is packed, so this should auto-emit a single length-delimited
+// block on the wire (not one record per element).
+message PackedBag {
+  repeated int32 bag_nums = 1;
+}
diff --git a/wireform-proto.cabal b/wireform-proto.cabal
--- a/wireform-proto.cabal
+++ b/wireform-proto.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: wireform-proto
-version: 0.2.0.0
+version: 0.3.0.0
 synopsis: Protocol Buffers (proto2/proto3) with IDL parser, code generation, JSON mapping
 description:
   A high-performance Protocol Buffers implementation for Haskell.
@@ -37,10 +37,16 @@
 copyright: 2026 Ian Duncan
 category: Data, Codec, Network, Serialization
 build-type: Simple
-tested-with: GHC == 9.6.4, GHC == 9.8.4
+tested-with: GHC == 9.10.3, GHC == 9.12.4, GHC == 9.14.1
 extra-doc-files:
   CHANGELOG.md
   README.md
+extra-source-files:
+  test/data/*.proto
+  test-conformance/protos/*.proto
+  test-conformance/failure_list_proto3.txt
+  test-regression/proto/*.proto
+  test-regression/Generate.hs
 data-dir: data
 data-files: proto/google/protobuf/*.proto
 
@@ -159,6 +165,8 @@
     Proto.Google.Protobuf.Wrappers.Util
 
   other-modules:
+    Proto.Internal.FieldPresence
+    Proto.Internal.JSON.Number
     -- Internal codegen sub-modules (used only by Proto.CodeGen)
     Proto.CodeGen.Decode
     Proto.CodeGen.Encode
@@ -171,7 +179,7 @@
     Proto.Internal.Either
     Proto.Internal.Maybe
   build-depends:
-    base >= 4.16 && < 5,
+    base >= 4.20 && < 5,
     wireform-core == 0.2.*,
     wireform-derive == 0.2.*,
     bytestring >= 0.11 && < 0.13,
@@ -188,7 +196,7 @@
     prettyprinter >= 1.7 && < 1.8,
     base16-bytestring >= 1.0 && < 1.1,
     base64-bytestring >= 1.2 && < 1.3,
-    aeson >= 2.1 && < 2.3,
+    aeson >= 2.1 && < 2.4,
     scientific >= 0.3 && < 0.4,
     hashable >= 1.3 && < 1.6,
     unordered-containers >= 0.2.16 && < 0.3,
@@ -209,6 +217,7 @@
   main-is: Main.hs
   other-modules:
     Test.Wire
+    Test.RingSink
     Test.Roundtrip
     Test.Collect
     Test.StreamCodec
@@ -275,7 +284,7 @@
   -- is fine here.
   ghc-options: -O2 -rtsopts -threaded
   build-depends:
-    base >= 4.16 && < 5,
+    base >= 4.20 && < 5,
     wireform-proto,
     aeson,
     bytestring,
@@ -300,7 +309,7 @@
     Test.Conformance.Schema
     Test.Conformance.Handler
   build-depends:
-    base >= 4.16 && < 5,
+    base >= 4.20 && < 5,
     wireform-proto,
     aeson,
     bytestring,
@@ -324,7 +333,7 @@
     Test.Conformance.Schema
     Test.Conformance.Handler
   build-depends:
-    base >= 4.16 && < 5,
+    base >= 4.20 && < 5,
     wireform-proto,
     aeson,
     bytestring,
@@ -410,7 +419,7 @@
     Proto.Bench
     Proto.Bench_Fields
   build-depends:
-      base               >= 4.16 && < 5
+      base               >= 4.20 && < 5
     , wireform-core
     , wireform-proto
     , bytestring
@@ -422,3 +431,28 @@
     , proto-lens-runtime >= 0.7  && < 0.8
     , lens-family        >= 2.1  && < 2.2
   hs-source-dirs: bench/compare, bench/compare/gen
+
+-- Real generated-record regressions for Editions presence and ProtoJSON numbers.
+test-suite wireform-proto-regression-test
+  import: defaults
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test-regression
+  main-is: Main.hs
+  other-modules:
+    THFixtures
+    Generated.Edition2023
+    Generated.Edition2024
+    Generated.Integermaps
+  build-depends:
+    base,
+    wireform-proto,
+    wireform-core,
+    aeson,
+    bytestring,
+    containers,
+    deepseq,
+    hashable,
+    reflection,
+    text,
+    vector,
+    sydtest
