packages feed

keiki-codec-json 0.1.0.0 → 0.2.0.0

raw patch · 26 files changed

+1394/−130 lines, 26 filesdep +scientificdep ~keikidep ~keiki-codec-jsonPVP ok

version bump matches the API change (PVP)

Dependencies added: scientific

Dependency ranges changed: keiki, keiki-codec-json

API changes (from Hackage documentation)

- Keiki.Codec.JSON: instance Keiki.Codec.JSON.RegFileWalk rs => Keiki.Codec.JSON.RegFileToJSON rs
+ Keiki.Codec.JSON: instance (Keiki.Codec.JSON.RegFileWalk rs, Keiki.Internal.Slots.DistinctNames (Keiki.Internal.Slots.Names rs)) => Keiki.Codec.JSON.RegFileToJSON rs
+ Keiki.Codec.JSON.Event: [currentVersion] :: EventCodecOptions -> Int
+ Keiki.Codec.JSON.Event: [fcOnMissing] :: FieldCodec -> Maybe Name
+ Keiki.Codec.JSON.Event: [kindOverrides] :: EventCodecOptions -> Map String String
+ Keiki.Codec.JSON.Event: [upcasters] :: EventCodecOptions -> [(Int, Name)]
+ Keiki.Codec.JSON.Event: [versionFieldName] :: EventCodecOptions -> String
+ Keiki.Codec.JSON.Event: fieldCodec :: Name -> Name -> FieldCodec
+ Keiki.Codec.JSON.Event: lookupFieldMaybe :: Key -> Object -> Maybe Value
+ Keiki.Codec.JSON.Event: lookupVersion :: Key -> Object -> Either String Int
+ Keiki.Codec.JSON.Event: migrateEnvelope :: Int -> [(Int, Value -> Either String Value)] -> Int -> Value -> Either String Value
- Keiki.Codec.JSON.Event: EventCodecOptions :: Map String FieldCodec -> Set String -> String -> OnMissingCodec -> EventCodecOptions
+ Keiki.Codec.JSON.Event: EventCodecOptions :: Map String FieldCodec -> Set String -> String -> Map String String -> String -> Int -> [(Int, Name)] -> OnMissingCodec -> EventCodecOptions
- Keiki.Codec.JSON.Event: FieldCodec :: Name -> Name -> FieldCodec
+ Keiki.Codec.JSON.Event: FieldCodec :: Name -> Name -> Maybe Name -> FieldCodec

Files

CHANGELOG.md view
@@ -9,6 +9,44 @@ ## [Unreleased]  +## [0.2.0.0] — 2026-07-13++### Added++- Event-codec schema evolution through pinned constructor wire kinds,+  default-on-missing payload decoding, and a compile-time-complete chain of+  one-envelope-to-one-envelope upcasters.+- `fieldCodec`, a strict smart constructor for `FieldCodec`, plus generated+  `<prefix>SchemaVersion` bindings.++### Changed++- **Breaking for invalid schemas:** `RegFileToJSON` now rejects duplicate slot+  names at compile time through keiki's canonical `DistinctNames` invariant,+  preventing Value-path data loss and duplicate streaming keys.+- Encoding now documents and pins the existing fully-initialized-register+  precondition. Unwritten `emptyRegFile` slots throw a slot-named `ErrorCall`;+  keiro already snapshots only fully populated register files, so it needs no+  source change.+- **Breaking:** snapshot shape hashes now use keiki's pinned built-in type names.+  Every non-empty shape hash changes once; stores keyed by an old hash ignore that+  snapshot and replay from the event log. Keiro already treats this mismatch as a+  benign cache miss.+- Container slot types now require their arguments to have+  `CanonicalTypeName`, not merely `Typeable`, so user-defined types may need to+  derive or define that class. This makes application overrides propagate through+  `Maybe`, lists, `Either`, and tuples.+- **Breaking:** `EventCodecOptions` now also carries `kindOverrides`,+  `versionFieldName`, `currentVersion`, and `upcasters`.+- **Breaking:** `FieldCodec` gains the `fcOnMissing` field. Positional+  construction should be replaced with `fieldCodec` and a record update when a+  missing-key default is required.+- **Breaking:** generated event envelopes now contain an in-band `"v"` field;+  version-absent historical objects decode as version 1.+- **Breaking:** generated `EventTypes` and `KindMap` bindings contain resolved+  wire kinds, including pinned values, rather than assuming constructor names.++ ## [0.1.0.0] — 2026-06-07  Initial Hackage release. Co-released with `keiki-0.1.0.0`; this
README.md view
@@ -90,6 +90,26 @@ (non-record-syntax) constructors, and type synonyms are rejected at splice time with a precise error message. +## Register-file wire rules++Every register slot must be written before encoding. `emptyRegFile` is an+initialization scaffold whose unwritten slots throw an `ErrorCall` beginning with+`uninit:` when read or encoded; it is not an encodable snapshot. On the streaming+path that exception can surface after earlier bytes have already been emitted, so+snapshot only fully hydrated aggregates.++Every register slot is present in the JSON object. A `Nothing` slot encodes as+explicit JSON `null`; omitting its key is an error, not another spelling of+`Nothing`. A nested optional value is lossy under aeson's standard instances:+`Just Nothing :: Maybe (Maybe a)` and outer `Nothing` both encode as `null` and+decode as outer `Nothing`. Avoid nested-`Maybe` slots when that distinction+matters, or wrap the inner optional value in a newtype with explicit JSON+instances.++The Value encoder emits aeson's object-key order, while the streaming Encoding+path emits slot-list order. Their bytes may differ, but both decode to the same+register file.+ ## Deriving an event codec skeleton  A service that stores its events as JSON usually hand-writes a@@ -109,9 +129,10 @@ import Data.Text (Text) import Keiki.Codec.JSON.Event   ( EventCodecOptions (..)-  , FieldCodec (..)+  , FieldCodec (fcOnMissing)   , defaultEventCodecOptions   , deriveEventCodecSkeleton+  , fieldCodec   )  newtype OrderId = OrderId Int deriving stock (Eq, Show)@@ -138,7 +159,7 @@ $(deriveEventCodecSkeleton     defaultEventCodecOptions       { fieldCodecOverrides =-          Map.fromList [("orderId", FieldCodec 'orderIdToJSON 'orderIdFromJSON)]+          Map.fromList [("orderId", fieldCodec 'orderIdToJSON 'orderIdFromJSON)]       , passthroughFields = Set.fromList ["qty", "trackingNo"]       }     ''OrderEvent)@@ -147,15 +168,18 @@ --   orderEventFromJSON   :: Aeson.Value -> Either String OrderEvent --   orderEventEventTypes :: [Text] --   orderEventKindMap    :: [(Text, Text)]+--   orderEventSchemaVersion :: Int ```  Each constructor encodes to an object carrying a `"kind"` discriminator-(its constructor name) plus one entry per payload field, so+(its constructor name unless pinned) and a `"v"` schema version, plus one+entry per payload field, so `orderEventToJSON (Placed (PlacedData (OrderId 7) 3))` is-`{"kind":"Placed","orderId":"ord-7","qty":3}` — note `orderId` is the-override's output, not a generic `Int`. The `orderEventEventTypes` /-`orderEventKindMap` bindings are plain `Text` (no Keiro dependency) so a-downstream can feed them to Keiro's `Codec.eventTypes`.+`{"kind":"Placed","v":1,"orderId":"ord-7","qty":3}` — note `orderId`+is the override's output, not a generic `Int`. The+`orderEventEventTypes` / `orderEventKindMap` bindings contain the resolved+wire kinds as plain `Text` (no Keiro dependency), and+`orderEventSchemaVersion` contains the configured current version.  **No silent generic fallback.** Each payload field is encoded by *name*: an override (`fieldCodecOverrides`), a passthrough using the field's own@@ -170,6 +194,73 @@ Constructors that are multi-argument, use record syntax directly, or are GADT/infix are rejected at splice time with a precise message; wrap a single record payload type instead (`Placed PlacedData`).++### Evolving an event schema++There are three common changes, each with a distinct codec move.++1. **Add a payload field without bumping the version.** For an optional+   field such as `note :: Maybe Text`, add `"note"` to+   `passthroughFields`; a missing key decodes as `Nothing`, while an+   explicit JSON `null` also follows aeson's normal `Maybe` decoder. For a+   non-`Maybe` field, provide a named default constant through the override:++   ```haskell+   defaultPriority :: Priority+   defaultPriority = NormalPriority++   priorityCodec :: FieldCodec+   priorityCodec =+     (fieldCodec 'priorityToJSON 'priorityFromJSON)+       { fcOnMissing = Just 'defaultPriority }+   ```++   Put `priorityCodec` in `fieldCodecOverrides`. Keep `currentVersion`+   unchanged: this is an additive compatibility rule, not a structural+   migration. Required fields without either form of default still fail with+   `missing field: <name>`.++2. **Rename a Haskell constructor without changing stored bytes.** Pin the+   renamed constructor to its historical discriminator:++   ```haskell+   kindOverrides = Map.fromList [("OrderPlaced", "Placed")]+   ```++   Override keys are current constructor names. The splice rejects unknown+   keys and duplicate resolved wire kinds, while encoding, decoding,+   `EventTypes`, and `KindMap` all use the pinned wire value.++3. **Restructure a payload.** Increment `currentVersion` and register one+   whole-envelope upcaster for every historical step:++   ```haskell+   upcastOrderV1 :: Aeson.Value -> Either String Aeson.Value+   upcastOrderV1 = ... -- rewrite a version-1 object into version-2 shape++   currentVersion = 2+   upcasters = [(1, 'upcastOrderV1)]+   ```++   An absent `"v"` is version 1. Before constructor dispatch, the decoder+   runs every rung from the stored version to the current version. For+   `currentVersion = n`, the splice requires exactly the source versions+   `[1 .. n - 1]`; gaps, duplicates, and out-of-range entries fail at compile+   time. A rung is one-envelope-to-one-envelope. If one historical event must+   split into several current events, do that in the application's event-store+   adapter as described in+   [`docs/research/schema-evolution.md`](../docs/research/schema-evolution.md).++Unknown object keys are intentionally ignored by the event decoder so additive+deployments can overlap. This differs from the RegFile snapshot decoder, which+rejects extra keys because a snapshot must match one exact register shape.++The in-band `"v"` is opt-in version ownership for applications that have no+outer event envelope. If an application already owns out-of-band metadata — for+example, a keiro-style `schemaVersion` beside the payload — keep this codec's+`currentVersion = 1` and evolve at that outer layer. Running both schemes with+different version numbers is a configuration error; neither layer detects the+disagreement for the other.  ## When to use the streaming encoder 
keiki-codec-json.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               keiki-codec-json-version:            0.1.0.0+version:            0.2.0.0 synopsis:           Optional JSON codec for keiki's RegFile. description:   Sibling package to keiki providing a JSON encoder, decoder, and@@ -43,8 +43,16 @@   CONTRIBUTING.md   README.md -extra-source-files: bench/baseline.csv+extra-source-files:+  bench/baseline.csv+  test/golden/*.json+  test/golden/event/*.json +source-repository head+  type:     git+  location: https://github.com/shinzui/keiki.git+  subdir:   keiki-codec-json+ common warnings   ghc-options:     -Wall -Wcompat -Widentities -Wincomplete-record-updates@@ -73,7 +81,8 @@     , aeson             ^>=2.2     , base              ^>=4.21     , containers        ^>=0.7-    , keiki             ^>=0.1+    , keiki             ^>=0.2+    , scientific        ^>=0.3     , template-haskell  ^>=2.23     , text              ^>=2.1 @@ -87,8 +96,8 @@     , base              ^>=4.21     , bytestring        ^>=0.12     , deepseq           ^>=1.5-    , keiki             ^>=0.1-    , keiki-codec-json  ^>=0.1+    , keiki             ^>=0.2+    , keiki-codec-json  ^>=0.2     , tasty-bench       ^>=0.4  || ^>=0.5     , text              ^>=2.1 @@ -101,9 +110,11 @@   main-is:        Spec.hs   other-modules:     Keiki.Codec.JSON.Fixtures+    Keiki.Codec.JSON.GoldenFileSpec     Keiki.Codec.JSON.GoldenSpec     Keiki.Codec.JSON.PropSpec     Keiki.Codec.JSON.SensitivitySpec+    Keiki.Codec.JSON.THEventEvolutionSpec     Keiki.Codec.JSON.THEventSpec     Keiki.Codec.JSON.THSpec @@ -113,8 +124,8 @@     , bytestring            ^>=0.12     , containers            ^>=0.7     , hspec                 ^>=2.11-    , keiki                 ^>=0.1-    , keiki-codec-json      ^>=0.1+    , keiki                 ^>=0.2+    , keiki-codec-json      ^>=0.2     , QuickCheck            ^>=2.15     , quickcheck-instances  ^>=0.3     , text                  ^>=2.1
src/Keiki/Codec/JSON.hs view
@@ -20,6 +20,31 @@ -- snapshots (catches structural drift, EP-36 §4 cases #1–9); the -- codec serialises the eligible ones. --+-- == Wire-format rules+--+-- Every slot must have been written before encoding. Encoding a register file+-- seeded by @Keiki.Generics.emptyRegFile@ but not fully initialized throws an+-- 'Control.Exception.ErrorCall' whose message starts with @uninit:@. On the+-- streaming path the exception can surface after an earlier part of the object+-- has already been emitted, so snapshot only fully hydrated aggregates.+--+-- Slot names must also be pairwise distinct. 'RegFileToJSON' applies the+-- canonical @Keiki.Internal.Slots.DistinctNames (Keiki.Internal.Slots.Names+-- rs)@ invariant at compile time. Without it, the Value path would silently+-- retain only one duplicate key, the streaming path would emit the key twice,+-- and decoding could never populate the second slot.+--+-- A @Nothing@ slot is present as explicit JSON @null@. Omitting the key is+-- not equivalent: 'regFileFromJSON' rejects every absent slot. A nested+-- @Maybe@ is not faithfully representable by aeson's standard instances:+-- @Just Nothing@ and @Nothing@ both encode as @null@ and decode as outer+-- @Nothing@. Avoid @Maybe (Maybe a)@ slots when that distinction matters, or+-- wrap the inner optional value in a newtype with explicit JSON instances.+--+-- The 'Aeson.Value' path serializes object keys in aeson's KeyMap order+-- (alphabetical with aeson 2.2), while 'regFileToEncoding' preserves slot-list+-- order. The byte streams may differ; both decode to the same register file.+-- -- == Slot-value size guidance (P11) -- -- keiki's per-slot dispatch overhead is microseconds at any realistic@@ -57,6 +82,7 @@ import Data.Proxy (Proxy (..)) import GHC.TypeLits (KnownSymbol, symbolVal) import Keiki.Core (RegFile (..), Slot)+import Keiki.Internal.Slots (DistinctNames, Names)  -- * Internal walker ----------------------------------------------------------- @@ -134,6 +160,10 @@ class (RegFileWalk rs) => RegFileToJSON (rs :: [Slot]) where   -- | Encode a slot list as a JSON object whose keys are the slot   -- symbols, in slot-list order.+  --+  -- Precondition: every slot must have been written. An unwritten+  -- @Keiki.Generics.emptyRegFile@ slot throws an+  -- 'Control.Exception.ErrorCall' whose message starts with @uninit:@.   regFileToJSON :: RegFile rs -> Aeson.Value   regFileToJSON = Aeson.object . regFilePairs @@ -142,6 +172,11 @@   -- 'Aeson.Value' intermediate. Recommended for RegFiles with   -- multi-MB slot values; see the module header's "Slot-value size   -- guidance".+  --+  -- Precondition: every slot must have been written. An unwritten+  -- @Keiki.Generics.emptyRegFile@ slot throws an+  -- 'Control.Exception.ErrorCall' whose message starts with @uninit:@,+  -- potentially after earlier bytes have been emitted downstream.   regFileToEncoding :: RegFile rs -> Aeson.Encoding   regFileToEncoding = Aeson.pairs . regFileSeries @@ -165,5 +200,17 @@ -- coverage is a 'RegFileToJSON'. Users do not write instances of this -- class themselves; the inductive 'RegFileWalk' instances (one for -- @'[]@, one for @\'(s, t) \': rs@) cover every slot list whose--- component types carry 'Aeson.ToJSON' and 'Aeson.FromJSON'.-instance (RegFileWalk rs) => RegFileToJSON rs+-- component types carry 'Aeson.ToJSON' and 'Aeson.FromJSON'. The+-- 'DistinctNames' constraint rejects duplicate slot symbols before either+-- encoder can lose data or emit ambiguous JSON.+--+-- == Negative-test procedure (manual)+--+-- With @DataKinds@ and @TypeApplications@ enabled, this definition must fail+-- to compile with @register file declares slot "x" more than once@:+--+-- @+-- duplicateCodec :: RegFile '[ '("x", Int), '("x", Bool)] -> Aeson.Value+-- duplicateCodec = regFileToJSON+-- @+instance (RegFileWalk rs, DistinctNames (Names rs)) => RegFileToJSON rs
src/Keiki/Codec/JSON/Event.hs view
@@ -16,28 +16,59 @@ -- >   | Cancelled -- >   deriving stock (Eq, Show) ----- the splice @$(deriveEventCodecSkeleton opts ''OrderEvent)@ emits four+-- the splice @$(deriveEventCodecSkeleton opts ''OrderEvent)@ emits five -- top-level bindings (prefix derived by lower-casing the first letter of the -- type name): -- -- > orderEventToJSON     :: OrderEvent -> Aeson.Value -- > orderEventFromJSON   :: Aeson.Value -> Either String OrderEvent--- > orderEventEventTypes :: [Data.Text.Text]                    -- ctor names, in order+-- > orderEventEventTypes :: [Data.Text.Text]                    -- wire kinds, in order -- > orderEventKindMap    :: [(Data.Text.Text, Data.Text.Text)]  -- (ctor, kind)+-- > orderEventSchemaVersion :: Int -- -- Each constructor encodes to a JSON object carrying a @"kind"@--- discriminator (the constructor name) plus one entry per payload field.--- The decoder reads @"kind"@, branches, and reassembles the payload field--- by field.+-- discriminator (the pinned wire kind, or the constructor name by default)+-- and an in-band @"v"@ schema version, plus one entry per payload field. --+-- Decoding follows a fixed pipeline: validate the stored version (an absent+-- @"v"@ means version 1), run every required whole-object upcaster in order,+-- dispatch on the migrated @"kind"@, then decode payload fields and apply any+-- missing-key defaults. Upcasters run before dispatch, so a structural change+-- may rewrite the discriminator as well as payload keys.+--+-- == Evolving an event schema+--+-- For an additive field, keep 'currentVersion' unchanged and either make the+-- field a syntactic @Maybe t@ passthrough or give its 'FieldCodec' an+-- 'fcOnMissing' constant. For a structural payload change, increment+-- 'currentVersion' and add the complete next rung to 'upcasters'. For a Haskell+-- constructor rename, preserve its old wire discriminator with 'kindOverrides'.+--+-- Each upcaster maps one envelope to one envelope. Splitting one historical+-- event into several current events remains an application-boundary concern;+-- see @docs/research/schema-evolution.md@ for that wider contract. The pure+-- @keiki@ core remains version-agnostic.+--+-- == Unknown object keys+--+-- Event decoding intentionally ignores keys that are not current payload or+-- envelope fields. This permits old and new readers to coexist during additive+-- deployments. The snapshot-oriented @RegFileToJSON@ decoder instead rejects+-- extra keys because a register snapshot must match one exact shape; the two+-- codecs serve different compatibility contracts.+-- -- == No silent generic fallback (the anti-drift property) -- -- A payload field is encoded one of three ways, chosen by /field name/: -- --   * if its name is a key of 'fieldCodecOverrides', the author-supplied---     'FieldCodec' functions are spliced in;+--     'FieldCodec' functions are spliced in; an absent key uses+--     'fcOnMissing' when supplied and otherwise remains an error; --   * else if its name is in 'passthroughFields', the field's own---     'Aeson.ToJSON' \/ 'Aeson.FromJSON' instances are used;+--     'Aeson.ToJSON' \/ 'Aeson.FromJSON' instances are used. A field whose+--     reified type is syntactically @Maybe t@ decodes an absent key as+--     'Nothing'; type synonyms are not expanded, and all other passthrough+--     fields remain strict; --   * otherwise the field is /unhandled/, and 'onMissingCodec' decides: --     'FailAtCompileTime' (the default) aborts the splice listing every --     unhandled field, while 'EmitTodoBindings' emits a clearly-named@@ -57,12 +88,13 @@ -- == Negative-test procedure (manual) -- -- See @Keiki.Codec.JSON.THEventSpec@ for the documented procedure that--- exercises the 'FailAtCompileTime' and 'EmitTodoBindings' behaviours; the--- two cases cannot live as a passing unit test because one is a compile--- failure.+-- exercises compile-time configuration failures and the 'EmitTodoBindings'+-- behaviour. Those checks cannot all live as passing unit tests because their+-- expected result is a compile failure. module Keiki.Codec.JSON.Event   ( -- * Options     FieldCodec (..),+    fieldCodec,     OnMissingCodec (..),     EventCodecOptions (..),     defaultEventCodecOptions,@@ -73,7 +105,10 @@      -- * Runtime helpers (referenced by generated code; not usually called directly)     lookupField,+    lookupFieldMaybe,     lookupText,+    lookupVersion,+    migrateEnvelope,     aesonResultToEither,   ) where@@ -82,9 +117,10 @@ import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap import Data.Char (toLower)-import Data.List (intercalate)+import Data.List (group, intercalate, sort, sortOn, (\\)) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map+import Data.Scientific qualified as Scientific import Data.Set (Set) import Data.Set qualified as Set import Data.Text (Text)@@ -98,12 +134,24 @@ -- --   * 'fcEncode' names a function @fieldType -> Aeson.Value@ (e.g. @idText@); --   * 'fcDecode' names a function @Aeson.Value -> Either String fieldType@---     (e.g. @parseIdText@).+--     (e.g. @parseIdText@);+--   * 'fcOnMissing' optionally names a top-level /constant/ of the field type,+--     used only when the JSON key is absent. data FieldCodec = FieldCodec   { fcEncode :: Name,-    fcDecode :: Name+    fcDecode :: Name,+    fcOnMissing :: Maybe Name   } +-- | Construct a strict field codec with no missing-key default.+fieldCodec :: Name -> Name -> FieldCodec+fieldCodec encodeName decodeName =+  FieldCodec+    { fcEncode = encodeName,+      fcDecode = decodeName,+      fcOnMissing = Nothing+    }+ -- | What to do for a payload field whose name appears in neither -- 'fieldCodecOverrides' nor 'passthroughFields'. data OnMissingCodec@@ -123,11 +171,25 @@     passthroughFields :: Set String,     -- | the discriminator key; default @"kind"@     kindFieldName :: String,+    -- | constructor base name to stable wire kind. Constructors omitted from+    --     this map use their Haskell name. Override keys and resolved wire kinds+    --     are validated when the splice runs.+    kindOverrides :: Map String String,+    -- | the in-band schema-version key; default @"v"@+    versionFieldName :: String,+    -- | current schema version stamped by the encoder; must be at least @1@+    currentVersion :: Int,+    -- | one whole-envelope migration per historical from-version. A version+    --     @n@ function upgrades to version @n + 1@; the splice requires exact+    --     coverage of @[1 .. currentVersion - 1]@.+    upcasters :: [(Int, Name)],     -- | behaviour for unhandled fields; default 'FailAtCompileTime'     onMissingCodec :: OnMissingCodec   } --- | Empty overrides, empty passthrough, @kindFieldName = "kind"@,+-- | Empty field and kind overrides, empty passthrough,+-- @kindFieldName = "kind"@, @versionFieldName = "v"@,+-- @currentVersion = 1@, no upcasters, and -- @onMissingCodec = 'FailAtCompileTime'@. defaultEventCodecOptions :: EventCodecOptions defaultEventCodecOptions =@@ -135,6 +197,10 @@     { fieldCodecOverrides = Map.empty,       passthroughFields = Set.empty,       kindFieldName = "kind",+      kindOverrides = Map.empty,+      versionFieldName = "v",+      currentVersion = 1,+      upcasters = [],       onMissingCodec = FailAtCompileTime     } @@ -146,6 +212,10 @@   Just v -> Right v   Nothing -> Left ("missing field: " <> Key.toString k) +-- | Total object lookup: 'Nothing' when the key is absent.+lookupFieldMaybe :: Key.Key -> Aeson.Object -> Maybe Aeson.Value+lookupFieldMaybe = KeyMap.lookup+ -- | Look a key up and require its value to be a JSON string. lookupText :: Key.Key -> Aeson.Object -> Either String Text lookupText k o = do@@ -154,6 +224,47 @@     Aeson.String t -> Right t     _ -> Left ("field " <> Key.toString k <> ": expected a string") +-- | Read an in-band schema version. An absent key means version @1@; a+-- present value must be an integral JSON number representable as an 'Int'.+lookupVersion :: Key.Key -> Aeson.Object -> Either String Int+lookupVersion k o = case KeyMap.lookup k o of+  Nothing -> Right 1+  Just (Aeson.Number number) ->+    maybe+      (Left expectedInteger)+      Right+      (Scientific.toBoundedInteger number)+  Just _ -> Left expectedInteger+  where+    expectedInteger =+      "field " <> Key.toString k <> ": expected an integer schema version"++-- | Replay a compile-time-complete chain from the stored version to the+-- current version. Rungs run in ascending source-version order; the only+-- expected runtime failure is a rung rejecting its input.+migrateEnvelope ::+  Int ->+  [(Int, Aeson.Value -> Either String Aeson.Value)] ->+  Int ->+  Aeson.Value ->+  Either String Aeson.Value+migrateEnvelope targetVersion chain storedVersion =+  go applicableRungs+  where+    applicableRungs =+      [ rung+      | rung@(fromVersion, _) <- sortOn fst chain,+        fromVersion >= storedVersion,+        fromVersion < targetVersion+      ]++    go [] value = Right value+    go ((fromVersion, upcast) : rest) value =+      case upcast value of+        Left message ->+          Left ("upcaster from version " <> show fromVersion <> ": " <> message)+        Right nextValue -> go rest nextValue+ -- | Adapt aeson's 'Aeson.Result' to @Either String@. aesonResultToEither :: Aeson.Result a -> Either String a aesonResultToEither (Aeson.Success a) = Right a@@ -180,6 +291,10 @@           <> " has no constructors; pass an event sum type."     _ -> pure () +  validateWireKinds opts tyName ctors+  validateVersionEnvelope opts ctors+  validateUpcasters opts+   -- No-silent-fallback safety net: classify each payload field; an   -- unhandled field is one in neither overrides nor passthrough.   let unhandled =@@ -211,6 +326,7 @@       fromJSONN = mkName (prefix <> "FromJSON")       eventTypesN = mkName (prefix <> "EventTypes")       kindMapN = mkName (prefix <> "KindMap")+      schemaVersionN = mkName (prefix <> "SchemaVersion")       tyT = conT tyName    -- Encoder: one clause per constructor.@@ -221,13 +337,14 @@   vVar <- newName "v"   oVar <- newName "o"   kVar <- newName "kind"+  versionVar <- newName "version"   fromSig <- sigD fromJSONN [t|Aeson.Value -> Either String $tyT|]   fromDef <-     funD       fromJSONN       [ clause           [varP vVar]-          (normalB (decoderBody opts prefix vVar oVar kVar ctors))+          (normalB (decoderBody opts prefix vVar oVar kVar versionVar ctors))           []       ] @@ -240,7 +357,7 @@           []           ( normalB               ( listE-                  [ [|T.pack $(stringE (nameBase (ecCtorName ec)))|]+                  [ [|T.pack $(stringE (wireKindOf opts (ecCtorName ec)))|]                   | ec <- ctors                   ]               )@@ -255,14 +372,22 @@           []           ( normalB               ( listE-                  [ [|(T.pack $(stringE nm), T.pack $(stringE nm))|]-                  | ec <- ctors,-                    let nm = nameBase (ecCtorName ec)+                  [ [|+                      ( T.pack $(stringE (nameBase (ecCtorName ec))),+                        T.pack $(stringE (wireKindOf opts (ecCtorName ec)))+                      )+                      |]+                  | ec <- ctors                   ]               )           )           []       ]+  svSig <- sigD schemaVersionN [t|Int|]+  svDef <-+    funD+      schemaVersionN+      [clause [] (normalB (litE (IntegerL (fromIntegral (currentVersion opts))))) []]    pure $     todoBindings@@ -273,7 +398,9 @@            etSig,            etDef,            kmSig,-           kmDef+           kmDef,+           svSig,+           svDef          ]  -- * Encoder generation -------------------------------------------------------@@ -284,11 +411,14 @@   Nothing ->     clause       [conP (ecCtorName ec) []]-      (normalB [|Aeson.object [$(kindPair)]|])+      (normalB [|Aeson.object [$(kindPair), $(versionPair)]|])       []   Just (_pc, fields) -> do     pVar <- newName "p"-    let pairs = kindPair : map (fieldPair opts (ecCtorName ec) pVar) fields+    let pairs =+          kindPair+            : versionPair+            : map (fieldPair opts (ecCtorName ec) pVar) fields     clause       [conP (ecCtorName ec) [varP pVar]]       (normalB [|Aeson.object $(listE pairs)|])@@ -297,8 +427,13 @@     kindPair =       [|         $(keyE (kindFieldName opts))-          Aeson..= (T.pack $(stringE (nameBase (ecCtorName ec))) :: Text)+          Aeson..= (T.pack $(stringE (wireKindOf opts (ecCtorName ec))) :: Text)         |]+    versionPair =+      [|+        $(keyE (versionFieldName opts))+          Aeson..= ($(litE (IntegerL (fromIntegral (currentVersion opts)))) :: Int)+        |]  -- | One @"field" .= <encoded>@ pair. fieldPair :: EventCodecOptions -> Name -> Name -> (String, Name, Type) -> Q Exp@@ -318,17 +453,17 @@  -- | The full @fromJSON@ body: @case v of Object o -> ...; _ -> Left ...@. decoderBody ::-  EventCodecOptions -> String -> Name -> Name -> Name -> [EvCtor] -> Q Exp-decoderBody opts prefix vVar oVar kVar ctors =+  EventCodecOptions -> String -> Name -> Name -> Name -> Name -> [EvCtor] -> Q Exp+decoderBody opts prefix vVar oVar kVar versionVar ctors =   caseE     (varE vVar)     [ match         (conP 'Aeson.Object [varP oVar])         ( normalB             ( infixE-                (Just [|lookupText $(keyE (kindFieldName opts)) $(varE oVar)|])+                (Just [|lookupVersion $(keyE (versionFieldName opts)) $(varE oVar)|])                 (varE '(>>=))-                (Just (lamE [varP kVar] (dispatch opts oVar kVar ctors)))+                (Just (lamE [varP versionVar] versionGuard))             )         )         [],@@ -337,20 +472,104 @@         (normalB [|Left $(stringE (prefix <> ": expected a JSON object"))|])         []     ]+  where+    currentVersionE = litE (IntegerL (fromIntegral (currentVersion opts)))+    dispatchMigrated migratedObjectVar =+      infixE+        ( Just+            [|+              lookupText+                $(keyE (kindFieldName opts))+                $(varE migratedObjectVar)+              |]+        )+        (varE '(>>=))+        ( Just+            ( lamE+                [varP kVar]+                (dispatch opts migratedObjectVar kVar ctors)+            )+        )+    migratedObjectCase migratedValueVar migratedObjectVar =+      caseE+        (varE migratedValueVar)+        [ match+            (conP 'Aeson.Object [varP migratedObjectVar])+            (normalB (dispatchMigrated migratedObjectVar))+            [],+          match+            wildP+            (normalB [|Left $(stringE (prefix <> ": expected a JSON object"))|])+            []+        ]+    migrateCurrent migratedValueVar migratedObjectVar =+      infixE+        ( Just+            [|+              migrateEnvelope+                ($currentVersionE :: Int)+                $(upcasterChainE opts)+                $(varE versionVar)+                (Aeson.Object $(varE oVar))+              |]+        )+        (varE '(>>=))+        ( Just+            ( lamE+                [varP migratedValueVar]+                (migratedObjectCase migratedValueVar migratedObjectVar)+            )+        )+    versionGuard = do+      migratedValueVar <- newName "migratedValue"+      migratedObjectVar <- newName "migratedObject"+      [|+        if $(varE versionVar) < 1+          then Left ("invalid event schema version: " <> show $(varE versionVar))+          else+            if $(varE versionVar) > ($currentVersionE :: Int)+              then+                Left+                  ( "event schema version "+                      <> show $(varE versionVar)+                      <> " is ahead of codec version "+                      <> show ($currentVersionE :: Int)+                  )+              else $(migrateCurrent migratedValueVar migratedObjectVar)+        |] +upcasterChainE :: EventCodecOptions -> Q Exp+upcasterChainE opts =+  listE+    [ [|($(litE (IntegerL (fromIntegral fromVersion))) :: Int, $(varE upcastName))|]+    | (fromVersion, upcastName) <- sortOn fst (upcasters opts)+    ]+ -- | Nested @if kind == "C" then <build C> else ...@ ending in an -- unknown-kind 'Left'. dispatch :: EventCodecOptions -> Name -> Name -> [EvCtor] -> Q Exp-dispatch opts oVar kVar =+dispatch opts oVar kVar ctors =   foldr     ( \ec elseQ ->         [|-          if $(varE kVar) == T.pack $(stringE (nameBase (ecCtorName ec)))+          if $(varE kVar) == T.pack $(stringE (wireKindOf opts (ecCtorName ec)))             then $(buildCtorDecode opts oVar ec)             else $elseQ           |]     )-    [|Left ("unknown event kind: " <> T.unpack $(varE kVar))|]+    [|+      Left+        ( "unknown event kind: "+            <> T.unpack $(varE kVar)+            <> $(stringE expectedKinds)+        )+      |]+    ctors+  where+    expectedKinds =+      " (expected one of: "+        <> intercalate ", " (map (wireKindOf opts . ecCtorName) ctors)+        <> ")"  -- | Decode one constructor's payload: @C \<$> (PayloadCtor \<$> d1 \<*> ...)@. buildCtorDecode :: EventCodecOptions -> Name -> EvCtor -> Q Exp@@ -369,13 +588,33 @@  -- | @Either String fieldType@ decoder for one field. decodeFieldExpr :: EventCodecOptions -> Name -> Name -> (String, Name, Type) -> Q Exp-decodeFieldExpr opts oVar ctorName (fn, _sel, _ft) =+decodeFieldExpr opts oVar ctorName (fn, _sel, ft) =   let getV = [|lookupField $(keyE fn) $(varE oVar)|]    in case classify opts fn of-        Override fc -> [|$(varE (fcDecode fc)) =<< $getV|]-        Passthrough -> [|(aesonResultToEither . Aeson.fromJSON) =<< $getV|]+        Override fc -> case fcOnMissing fc of+          Nothing -> [|$(varE (fcDecode fc)) =<< $getV|]+          Just missingDefault ->+            [|+              case lookupFieldMaybe $(keyE fn) $(varE oVar) of+                Nothing -> Right $(varE missingDefault)+                Just fieldValue -> $(varE (fcDecode fc)) fieldValue+              |]+        Passthrough+          | isSyntacticMaybe ft ->+              [|+                case lookupFieldMaybe $(keyE fn) $(varE oVar) of+                  Nothing -> Right Nothing+                  Just fieldValue -> aesonResultToEither (Aeson.fromJSON fieldValue)+                |]+          | otherwise -> [|(aesonResultToEither . Aeson.fromJSON) =<< $getV|]         Unhandled -> [|$(varE (todoName ctorName fn)) =<< $getV|] +isSyntacticMaybe :: Type -> Bool+isSyntacticMaybe (SigT ty _) = isSyntacticMaybe ty+isSyntacticMaybe (ParensT ty) = isSyntacticMaybe ty+isSyntacticMaybe (AppT (ConT maybeName) _) = maybeName == ''Maybe+isSyntacticMaybe _ = False+ -- * TODO bindings ------------------------------------------------------------  -- | The placeholder binding name for an unhandled field.@@ -418,6 +657,159 @@ isUnhandled opts fn =   not (fn `Map.member` fieldCodecOverrides opts)     && not (fn `Set.member` passthroughFields opts)++-- * Wire-kind validation ----------------------------------------------------++wireKindOf :: EventCodecOptions -> Name -> String+wireKindOf opts ctorName =+  Map.findWithDefault ctorBase ctorBase (kindOverrides opts)+  where+    ctorBase = nameBase ctorName++validateWireKinds :: EventCodecOptions -> Name -> [EvCtor] -> Q ()+validateWireKinds opts tyName ctors = do+  case unknownOverrideKeys of+    [] -> pure ()+    unknownKeys ->+      fail . intercalate "\n" $+        [ "deriveEventCodecSkeleton: kindOverrides key "+            <> show key+            <> " is not a constructor of "+            <> nameBase tyName+            <> "."+        | key <- unknownKeys+        ]++  case duplicateWireKinds of+    [] -> pure ()+    duplicates ->+      fail . intercalate "\n" $+        [ "deriveEventCodecSkeleton: wire kind "+            <> show wireKind+            <> " is claimed by more than one constructor: "+            <> intercalate ", " owners+            <> ". Wire kinds must be unique per event type."+        | (wireKind, owners) <- duplicates+        ]++  case discriminatorCollisions of+    [] -> pure ()+    collisions ->+      fail . intercalate "\n" $+        [ "deriveEventCodecSkeleton: payload field "+            <> nameBase ctorName+            <> "."+            <> fieldName+            <> " collides with kindFieldName "+            <> show (kindFieldName opts)+            <> "; rename the field or choose a kindFieldName no payload uses."+        | (ctorName, fieldName) <- collisions+        ]+  where+    ctorBaseNames = map (nameBase . ecCtorName) ctors+    knownConstructors = Set.fromList ctorBaseNames+    unknownOverrideKeys =+      Set.toList (Map.keysSet (kindOverrides opts) `Set.difference` knownConstructors)+    wireKindOwners =+      Map.fromListWith+        (flip (<>))+        [ (wireKindOf opts (ecCtorName ec), [nameBase (ecCtorName ec)])+        | ec <- ctors+        ]+    duplicateWireKinds =+      [ (wireKind, owners)+      | (wireKind, owners) <- Map.toList wireKindOwners,+        length owners > 1+      ]+    discriminatorCollisions =+      [ (ecCtorName ec, fieldName)+      | ec <- ctors,+        (_, fields) <- maybe [] (: []) (ecPayload ec),+        (fieldName, _, _) <- fields,+        fieldName == kindFieldName opts+      ]++validateVersionEnvelope :: EventCodecOptions -> [EvCtor] -> Q ()+validateVersionEnvelope opts ctors = do+  if currentVersion opts < 1+    then+      fail $+        "deriveEventCodecSkeleton: currentVersion must be >= 1, got "+          <> show (currentVersion opts)+          <> "."+    else pure ()++  if kindFieldName opts == versionFieldName opts+    then+      fail $+        "deriveEventCodecSkeleton: kindFieldName "+          <> show (kindFieldName opts)+          <> " collides with versionFieldName "+          <> show (versionFieldName opts)+          <> "; choose distinct envelope keys."+    else pure ()++  case versionCollisions of+    [] -> pure ()+    collisions ->+      fail . intercalate "\n" $+        [ "deriveEventCodecSkeleton: payload field "+            <> nameBase ctorName+            <> "."+            <> fieldName+            <> " collides with versionFieldName "+            <> show (versionFieldName opts)+            <> "; rename the field or choose a versionFieldName no payload uses."+        | (ctorName, fieldName) <- collisions+        ]+  where+    versionCollisions =+      [ (ecCtorName ec, fieldName)+      | ec <- ctors,+        (_, fields) <- maybe [] (: []) (ecPayload ec),+        (fieldName, _, _) <- fields,+        fieldName == versionFieldName opts+      ]++validateUpcasters :: EventCodecOptions -> Q ()+validateUpcasters opts = do+  case duplicateSources of+    [] -> pure ()+    duplicates ->+      fail $+        "deriveEventCodecSkeleton: duplicate upcaster from-versions: "+          <> show duplicates++  case outOfRangeSources of+    [] -> pure ()+    invalidSources ->+      fail $+        "deriveEventCodecSkeleton: upcaster from-versions must be >= 1 and < currentVersion "+          <> show (currentVersion opts)+          <> "; out of range: "+          <> show invalidSources++  case missingSources of+    [] -> pure ()+    missing ->+      fail $+        "deriveEventCodecSkeleton: upcasters must cover from-versions [1.."+          <> show (currentVersion opts - 1)+          <> "] exactly; missing: "+          <> show missing+  where+    sources = map fst (upcasters opts)+    expectedSources = [1 .. currentVersion opts - 1]+    duplicateSources =+      [ source+      | source : _ : _ <- group (sort sources)+      ]+    outOfRangeSources =+      [ source+      | source <- sort sources,+        source < 1 || source >= currentVersion opts+      ]+    missingSources = expectedSources \\ sources  -- * Reflection --------------------------------------------------------------- 
src/Keiki/Codec/JSON/TH.hs view
@@ -1,35 +1,5 @@ {-# LANGUAGE TemplateHaskell #-} --- \$('deriveRegFileCodec' \'\'MySnapshot)--- @------ to emit three top-level functions------ @--- mySnapshotToJSON     :: MySnapshot -> Aeson.Value--- mySnapshotToEncoding :: MySnapshot -> Aeson.Encoding--- mySnapshotFromJSON   :: Aeson.Value -> Either String MySnapshot--- @------ Each function routes through the existing--- 'Keiki.Codec.JSON.RegFileToJSON' class against the slot list--- @'Keiki.Generics.RegFieldsOf' MySnapshot@. The record's field names--- become the JSON object's keys; the record's field types must each--- carry 'Aeson.ToJSON' and 'Aeson.FromJSON' or compilation fails with a--- precise per-field error.------ This splice lives in @keiki-codec-json@ (not in @keiki@'s--- @Keiki.Generics.TH@). The class @RegFileToJSON@ is defined here, and--- moving the splice to @keiki@ would force an @aeson@ dependency on--- @keiki@ core — violating the load-bearing--- /keiki MUST NOT gain @aeson@/ requirement (EP-36 §3 R8; MP-11--- Decision Log 2026-05-10). The splice does reuse the structural--- machinery in @keiki@'s @Keiki.Generics@ ('Keiki.Generics.RegFieldsOf',--- 'Keiki.Generics.gToRegFile', 'Keiki.Generics.gFromRegFile') so the--- composition with the existing 'Keiki.Generics.TH' ergonomics--- (@mkInCtorVia@, @mkWireCtorVia@, @deriveAggregateCtors@, etc.) is--- preserved.- -- | -- Module      : Keiki.Codec.JSON.TH -- Description : Template Haskell helpers that emit @RegFile@-routed JSON@@ -43,12 +13,34 @@ --   , correlationId :: Text --   , dispatchedAt  :: UTCTime --   }---   deriving stock ('Eq', 'Show', GHC.Generics.'GHC.Generics.Generic')+--   deriving stock (Eq, Show, Generic) -- @ -- -- invokes -- -- @+-- \$('deriveRegFileCodec' \'\'MySnapshot)+--+-- mySnapshotToJSON     :: MySnapshot -> Aeson.Value+-- mySnapshotToEncoding :: MySnapshot -> Aeson.Encoding+-- mySnapshotFromJSON   :: Aeson.Value -> Either String MySnapshot+-- @+--+-- to emit those three top-level functions. Each function routes through the+-- existing 'Keiki.Codec.JSON.RegFileToJSON' class against the slot list+-- @'Keiki.Generics.RegFieldsOf' MySnapshot@. The record's field names become+-- the JSON object's keys; the record's field types must each carry+-- 'Aeson.ToJSON' and 'Aeson.FromJSON' or compilation fails with a precise+-- per-field error.+--+-- This splice lives in @keiki-codec-json@ (not in @keiki@'s+-- @Keiki.Generics.TH@). The class @RegFileToJSON@ is defined here, and moving+-- the splice to @keiki@ would force an @aeson@ dependency on @keiki@ core —+-- violating the load-bearing /keiki MUST NOT gain @aeson@/ requirement (EP-36+-- §3 R8; MP-11 Decision Log 2026-05-10). The splice does reuse the structural+-- machinery in @keiki@'s @Keiki.Generics@ ('Keiki.Generics.RegFieldsOf',+-- 'Keiki.Generics.gToRegFile', 'Keiki.Generics.gFromRegFile') so composition+-- with the existing 'Keiki.Generics.TH' ergonomics is preserved. module Keiki.Codec.JSON.TH   ( deriveRegFileCodec,     deriveRegFileCodecAs,
test/Keiki/Codec/JSON/Fixtures.hs view
@@ -8,6 +8,10 @@   ( -- * Baseline slot list     ExemplarSlots, +    -- * Maybe wire-semantics slot lists+    MaybeSlots,+    NestedMaybeSlots,+     -- * Schema-evolution mutations (EP-36 §4 cases #1–9)     AddSlots,     RemoveSlots,@@ -26,6 +30,9 @@      -- * Arbitrary generator     ArbitraryRegFile (..),++    -- * Value equality+    EqRegFile (..),   ) where @@ -53,6 +60,17 @@      '("correlationId", Text)    ] +-- | Optional fields whose absent values are encoded as explicit JSON @null@.+type MaybeSlots =+  '[ '("lastError", Maybe Text),+     '("approvedAt", Maybe UTCTime),+     '("shippingAddress", Maybe Address)+   ]++-- | A deliberately lossy aeson shape: @Just Nothing@ and @Nothing@ both+-- encode as JSON @null@.+type NestedMaybeSlots = '[ '("nested", Maybe (Maybe Int))]+ -- * Schema-evolution mutations (EP-36 §4) ------------------------------------  -- | §4 case #1 — Add slot. A fourth slot is appended to the baseline.@@ -186,3 +204,16 @@   ArbitraryRegFile ('(s, t) ': rs)   where   arbRegFile = RCons (Proxy @s) <$> arbitrary <*> arbRegFile++-- * Value equality for 'RegFile rs' -----------------------------------------++-- | Inductive heterogeneous equality. Each slot value is compared with its+-- own 'Eq' instance.+class EqRegFile (rs :: [Slot]) where+  eqRegFile :: RegFile rs -> RegFile rs -> Bool++instance EqRegFile '[] where+  eqRegFile _ _ = True++instance (Eq t, EqRegFile rs) => EqRegFile ('(s, t) ': rs) where+  eqRegFile (RCons _ x xs) (RCons _ y ys) = x == y && eqRegFile xs ys
+ test/Keiki/Codec/JSON/GoldenFileSpec.hs view
@@ -0,0 +1,266 @@+-- | Checked-in persistence fixtures for whole register files and their shape.+--+-- Each register fixture is checked in both directions: current code decodes+-- historical bytes to a fixed heterogeneous value, and current encoding must+-- remain byte-identical. The Value and Encoding paths have separate files+-- because their object-key orders deliberately differ.+--+-- To regenerate only for an intentional wire-format change:+--+-- > KEIKI_UPDATE_GOLDENS=1 cabal test keiki-codec-json:keiki-codec-json-test+-- > cabal test keiki-codec-json:keiki-codec-json-test+-- > git diff keiki-codec-json/test/golden/+--+-- The first command rewrites the files and fails deliberately. The second must+-- be green after the environment variable is unset and the diff reviewed.+module Keiki.Codec.JSON.GoldenFileSpec (spec) where++import Data.Aeson qualified as Aeson+import Data.Aeson.Encoding qualified as AesonEnc+import Data.ByteString.Lazy qualified as LBS+import Data.Proxy (Proxy (..))+import Data.Text qualified as T+import Data.Time.Clock (UTCTime)+import Keiki.Codec.JSON (RegFileToJSON, regFileFromJSON, regFileToEncoding, regFileToJSON)+import Keiki.Codec.JSON.Fixtures+  ( Address (..),+    EqRegFile (eqRegFile),+    ExemplarSlots,+    MaybeSlots,+  )+import Keiki.Codec.JSON.THEventEvolutionSpec qualified as Evolution+import Keiki.Codec.JSON.THEventSpec qualified as Event+import Keiki.Core (RegFile (..))+import Keiki.Shape (regFileShapeCanonical, regFileShapeHash)+import System.Environment (lookupEnv)+import Test.Hspec (Spec, describe, expectationFailure, it, runIO, shouldBe)++data EncoderPath = ValuePath | EncodingPath++spec :: Spec+spec = do+  describe "checked-in RegFile bytes" $ do+    regFileGoldenSpec+      "exemplar Value path"+      "test/golden/exemplar-regfile.value.json"+      ValuePath+      exemplarRegFile+    regFileGoldenSpec+      "exemplar Encoding path"+      "test/golden/exemplar-regfile.encoding.json"+      EncodingPath+      exemplarRegFile+    regFileGoldenSpec+      "all-Just Maybe slots, Value path"+      "test/golden/maybe-regfile-just.value.json"+      ValuePath+      maybeJustRegFile+    regFileGoldenSpec+      "all-Just Maybe slots, Encoding path"+      "test/golden/maybe-regfile-just.encoding.json"+      EncodingPath+      maybeJustRegFile+    regFileGoldenSpec+      "all-Nothing Maybe slots, Value path"+      "test/golden/maybe-regfile-nothing.value.json"+      ValuePath+      maybeNothingRegFile+    regFileGoldenSpec+      "all-Nothing Maybe slots, Encoding path"+      "test/golden/maybe-regfile-nothing.encoding.json"+      EncodingPath+      maybeNothingRegFile++  shapeGoldenSpec+  eventGoldenSpecs++regFileGoldenSpec ::+  forall rs.+  (RegFileToJSON rs, EqRegFile rs) =>+  String ->+  FilePath ->+  EncoderPath ->+  RegFile rs ->+  Spec+regFileGoldenSpec label path encoder expected = describe label $ do+  update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")+  case update of+    Just _ -> do+      runIO (LBS.writeFile path currentBytes)+      it "regenerates and requires a clean review run" $+        expectationFailure regenerationMessage+    Nothing -> do+      historicalBytes <- runIO (LBS.readFile path)+      it "decodes checked-in bytes to the fixed value" $+        case Aeson.decode historicalBytes of+          Nothing -> expectationFailure "golden file is not valid JSON"+          Just value -> case regFileFromJSON @rs value of+            Left message -> expectationFailure ("golden decode failed: " <> message)+            Right actual -> eqRegFile actual expected `shouldBe` True+      it "freezes current encoder bytes" $+        currentBytes `shouldBe` historicalBytes+  where+    currentBytes = case encoder of+      ValuePath -> Aeson.encode (regFileToJSON expected)+      EncodingPath -> AesonEnc.encodingToLazyByteString (regFileToEncoding expected)++shapeGoldenSpec :: Spec+shapeGoldenSpec = describe "checked-in ExemplarSlots shape" $ do+  update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")+  case update of+    Just _ -> do+      runIO (LBS.writeFile shapePath currentBytes)+      it "regenerates and requires a clean review run" $+        expectationFailure regenerationMessage+    Nothing -> do+      historicalBytes <- runIO (LBS.readFile shapePath)+      it "pins the canonical text and hash" $+        Aeson.decode historicalBytes `shouldBe` Just currentValue+      it "freezes the shape fixture bytes" $+        currentBytes `shouldBe` historicalBytes+  where+    shapePath = "test/golden/exemplar-shape.json"+    currentValue =+      Aeson.object+        [ "canonical" Aeson..= regFileShapeCanonical (Proxy @ExemplarSlots),+          "hash" Aeson..= regFileShapeHash (Proxy @ExemplarSlots)+        ]+    currentBytes = Aeson.encode currentValue++eventGoldenSpecs :: Spec+eventGoldenSpecs = describe "checked-in versioned event envelopes" $ do+  eventGoldenSpec+    "record payload with pinned kind and version"+    "test/golden/event/order-placed.v1.json"+    placedEvent+    Event.orderEventToJSON+    Event.orderEventFromJSON+  eventGoldenSpec+    "no-payload singleton with version"+    "test/golden/event/order-cancelled.v1.json"+    Event.Cancelled+    Event.orderEventToJSON+    Event.orderEventFromJSON+  eventGoldenSpec+    "current additive payload with explicit defaults"+    "test/golden/event/item-added.v1.json"+    additiveEvent+    Evolution.additiveEventToJSON+    Evolution.additiveEventFromJSON+  historicalEventGoldenSpec+    "pre-addition payload fills missing fields and absent version"+    "test/golden/event/item-added.pre-defaults.json"+    additiveEvent+    historicalAdditiveBytes+    Evolution.additiveEventFromJSON+  eventGoldenSpec+    "current structural payload after upcasting era"+    "test/golden/event/quantity-recorded.v2.json"+    structuralEvent+    Evolution.structuralEventToJSON+    Evolution.structuralEventFromJSON+  historicalEventGoldenSpec+    "version-1 payload runs the quantity upcaster"+    "test/golden/event/quantity-recorded.v1.json"+    structuralEvent+    historicalStructuralBytes+    Evolution.structuralEventFromJSON++eventGoldenSpec ::+  (Eq event, Show event) =>+  String ->+  FilePath ->+  event ->+  (event -> Aeson.Value) ->+  (Aeson.Value -> Either String event) ->+  Spec+eventGoldenSpec label path expected encoder decoder = describe label $ do+  update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")+  case update of+    Just _ -> do+      runIO (LBS.writeFile path currentBytes)+      it "regenerates and requires a clean review run" $+        expectationFailure regenerationMessage+    Nothing -> do+      historicalBytes <- runIO (LBS.readFile path)+      it "decodes checked-in bytes to the fixed event" $+        case Aeson.eitherDecode historicalBytes of+          Left message -> expectationFailure ("golden file is not valid JSON: " <> message)+          Right value -> decoder value `shouldBe` Right expected+      it "freezes current event encoder bytes" $+        currentBytes `shouldBe` historicalBytes+  where+    currentBytes = Aeson.encode (encoder expected)++historicalEventGoldenSpec ::+  (Eq event, Show event) =>+  String ->+  FilePath ->+  event ->+  LBS.ByteString ->+  (Aeson.Value -> Either String event) ->+  Spec+historicalEventGoldenSpec label path expected frozenBytes decoder = describe label $ do+  update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")+  case update of+    Just _ -> do+      runIO (LBS.writeFile path frozenBytes)+      it "regenerates and requires a clean review run" $+        expectationFailure regenerationMessage+    Nothing -> do+      historicalBytes <- runIO (LBS.readFile path)+      it "decodes the historical document to the current event" $+        case Aeson.eitherDecode historicalBytes of+          Left message -> expectationFailure ("golden file is not valid JSON: " <> message)+          Right value -> decoder value `shouldBe` Right expected+      it "freezes the historical document bytes" $+        historicalBytes `shouldBe` frozenBytes++exemplarRegFile :: RegFile ExemplarSlots+exemplarRegFile =+  RCons (Proxy @"retryCount") (3 :: Int) $+    RCons (Proxy @"cooldownUntil") exemplarTime $+      RCons (Proxy @"correlationId") (T.pack "order-123") RNil++maybeJustRegFile :: RegFile MaybeSlots+maybeJustRegFile =+  RCons (Proxy @"lastError") (Just (T.pack "address invalid")) $+    RCons (Proxy @"approvedAt") (Just exemplarTime) $+      RCons+        (Proxy @"shippingAddress")+        (Just (Address (T.pack "1 Main St") (T.pack "Portland") (T.pack "97201")))+        RNil++maybeNothingRegFile :: RegFile MaybeSlots+maybeNothingRegFile =+  RCons (Proxy @"lastError") Nothing $+    RCons (Proxy @"approvedAt") Nothing $+      RCons (Proxy @"shippingAddress") Nothing RNil++exemplarTime :: UTCTime+exemplarTime = read "2026-01-02 03:04:05 UTC"++placedEvent :: Event.OrderEvent+placedEvent = Event.Placed (Event.PlacedData (Event.OrderId 7) 3)++additiveEvent :: Evolution.AdditiveEvent+additiveEvent =+  Evolution.ItemAdded+    Evolution.ItemAddedData+      { Evolution.orderId = T.pack "ord-7",+        Evolution.discount = Evolution.Discount 0,+        Evolution.note = Nothing+      }++structuralEvent :: Evolution.StructuralEvent+structuralEvent = Evolution.QuantityRecorded (Evolution.QuantityRecordedData 3)++historicalAdditiveBytes :: LBS.ByteString+historicalAdditiveBytes = "{\"kind\":\"ItemAdded\",\"orderId\":\"ord-7\"}"++historicalStructuralBytes :: LBS.ByteString+historicalStructuralBytes = "{\"kind\":\"QuantityRecorded\",\"v\":1,\"qty\":3}"++regenerationMessage :: String+regenerationMessage =+  "golden regenerated; unset KEIKI_UPDATE_GOLDENS, re-run, and review git diff"
test/Keiki/Codec/JSON/GoldenSpec.hs view
@@ -1,24 +1,22 @@ -- | EP-36 M3 golden hash file. ----- Pins the shape hash of 'ExemplarSlots' for the current GHC version.--- The cross-GHC CI gate (M5) compares this value across every GHC in--- @tested-with@; a divergence is a release-blocking bug per EP-36 §8.+-- Pins the canonical shape string and hash of 'ExemplarSlots'. Built-in+-- canonical names are module-independent, so these values are shared across+-- supported GHC versions. module Keiki.Codec.JSON.GoldenSpec (spec) where  import Data.Proxy (Proxy (..)) import Data.Text qualified as T import Keiki.Codec.JSON.Fixtures (ExemplarSlots)-import Keiki.Shape (regFileShapeHash)+import Keiki.Shape (regFileShapeCanonical, regFileShapeHash) import Test.Hspec (Spec, describe, it, shouldBe)  spec :: Spec spec = describe "Golden hash for ExemplarSlots" $ do-  -- This value is pinned for GHC 9.12.*. Drift here means either-  -- GHC's `tyConModule` / `tyConName` semantics changed for one of-  -- the slot types (Int / UTCTime / Text), or `renderStableTypeRep`-  -- inadvertently picked up a non-stable accessor. Per EP-36 §8 the-  -- release is blocked until the cause is understood; the fix may be a-  -- `CanonicalTypeName` override for the affected slot type.-  it "matches the pinned GHC-9.12.* value" $+  it "matches the pinned canonical string" $+    regFileShapeCanonical (Proxy @ExemplarSlots)+      `shouldBe` T.pack "retryCount:Int;cooldownUntil:UTCTime;correlationId:Text;regfile:0"++  it "matches the pinned GHC-independent value" $     regFileShapeHash (Proxy @ExemplarSlots)-      `shouldBe` T.pack "a37b2b77042a635f394a082765f3410ea23a0b89745b0c77242b925a03aa172b"+      `shouldBe` T.pack "d920c3660d5b2a7bda082cdedb08fa493acd3f74a663434a4cead475096866f9"
test/Keiki/Codec/JSON/PropSpec.hs view
@@ -11,70 +11,117 @@  import Data.Aeson qualified as Aeson import Data.Aeson.Encoding qualified as AesonEnc-import Keiki.Codec.JSON (regFileFromJSON, regFileToEncoding, regFileToJSON)-import Keiki.Codec.JSON.Fixtures (ExemplarSlots, arbRegFile)+import Data.Proxy (Proxy (..))+import Keiki.Codec.JSON (RegFileToJSON, regFileFromJSON, regFileToEncoding, regFileToJSON)+import Keiki.Codec.JSON.Fixtures+  ( ArbitraryRegFile (arbRegFile),+    EqRegFile (eqRegFile),+    ExemplarSlots,+    MaybeSlots,+    NestedMaybeSlots,+  ) import Keiki.Core (RegFile)-import Test.Hspec (Spec, describe, it)+import Keiki.Core qualified as Core+import Test.Hspec (Spec, describe, it, shouldBe) import Test.QuickCheck (Property, forAllShow, (===))  -- | RegFile rs has no Show instance (the slot list is heterogeneous); -- use the JSON encoding as the renderer for QuickCheck counterexamples.-showRegFile :: RegFile ExemplarSlots -> String+showRegFile :: (RegFileToJSON rs) => RegFile rs -> String showRegFile = show . regFileToJSON  spec :: Spec spec = do+  describe "ExemplarSlots" (codecProps @ExemplarSlots)+  describe "MaybeSlots" (codecProps @MaybeSlots)+  nestedMaybeSpec++codecProps ::+  forall rs.+  (RegFileToJSON rs, ArbitraryRegFile rs, EqRegFile rs) =>+  Spec+codecProps = do   describe "Roundtrip" $ do     it "Value path round-trips" $-      forAllShow arbRegFile showRegFile valueRoundTrip+      forAllShow (arbRegFile @rs) showRegFile (valueRoundTrip @rs)     it "Encoding path round-trips" $-      forAllShow arbRegFile showRegFile encodingRoundTrip+      forAllShow (arbRegFile @rs) showRegFile (encodingRoundTrip @rs)    describe "Determinism (R9 within-path)" $ do     it "Value path is deterministic" $-      forAllShow arbRegFile showRegFile valueDeterministic+      forAllShow (arbRegFile @rs) showRegFile valueDeterministic     it "Encoding path is deterministic" $-      forAllShow arbRegFile showRegFile encodingDeterministic+      forAllShow (arbRegFile @rs) showRegFile encodingDeterministic --- | @regFileFromJSON . regFileToJSON ≡ Right rf@. We use the encoded--- bytes as the canonical comparison point: re-encoding the parsed--- RegFile must yield the same bytes (proving structural equality--- without requiring a 'Eq' or 'Show' instance on 'RegFile').-valueRoundTrip :: RegFile ExemplarSlots -> Property+-- | @regFileFromJSON . regFileToJSON ≡ Right rf@. We compare decoded+-- slot values with the inductive 'EqRegFile' walker, so a lossy decode+-- cannot hide behind identical re-encoded bytes.+valueRoundTrip :: forall rs. (RegFileToJSON rs, EqRegFile rs) => RegFile rs -> Property valueRoundTrip rf =   let bytes = Aeson.encode (regFileToJSON rf)    in case Aeson.decode bytes of         Nothing ->           False === error "Aeson.decode failed on our own encoder output"-        Just v -> case regFileFromJSON @ExemplarSlots v of+        Just v -> case regFileFromJSON @rs v of           Left msg ->             False === error ("regFileFromJSON failed: " <> msg)-          Right rf' ->-            Aeson.encode (regFileToJSON rf') === bytes+          Right rf' -> eqRegFile rf' rf === True  -- | Encoding path round-trip via -- @regFileFromJSON . fromJust . Aeson.decode . AesonEnc.encodingToLazyByteString . regFileToEncoding@.-encodingRoundTrip :: RegFile ExemplarSlots -> Property+encodingRoundTrip :: forall rs. (RegFileToJSON rs, EqRegFile rs) => RegFile rs -> Property encodingRoundTrip rf =   let bytes = AesonEnc.encodingToLazyByteString (regFileToEncoding rf)    in case Aeson.decode bytes of         Nothing ->           False === error "Aeson.decode failed on streaming-encoder output"-        Just v -> case regFileFromJSON @ExemplarSlots v of+        Just v -> case regFileFromJSON @rs v of           Left msg ->             False === error ("regFileFromJSON failed: " <> msg)-          Right rf' ->-            AesonEnc.encodingToLazyByteString (regFileToEncoding rf') === bytes+          Right rf' -> eqRegFile rf' rf === True  -- | Re-encoding the same RegFile via the Value path produces byte- -- equal output.-valueDeterministic :: RegFile ExemplarSlots -> Property+valueDeterministic :: (RegFileToJSON rs) => RegFile rs -> Property valueDeterministic rf =   Aeson.encode (regFileToJSON rf)     === Aeson.encode (regFileToJSON rf)  -- | Re-encoding via the Encoding path produces byte-equal output.-encodingDeterministic :: RegFile ExemplarSlots -> Property+encodingDeterministic :: (RegFileToJSON rs) => RegFile rs -> Property encodingDeterministic rf =   AesonEnc.encodingToLazyByteString (regFileToEncoding rf)     === AesonEnc.encodingToLazyByteString (regFileToEncoding rf)++nestedMaybeSpec :: Spec+nestedMaybeSpec = describe "Nested Maybe wire semantics" $ do+  it "encodes Just Nothing as null and decodes it as outer Nothing" $ do+    let rf = nestedRegFile (Just Nothing)+    regFileToJSON rf+      `shouldBe` Aeson.object ["nested" Aeson..= Aeson.Null]+    assertNestedDecode rf Nothing++  it "round-trips Just (Just 42)" $+    assertNestedDecode (nestedRegFile (Just (Just 42))) (Just (Just 42))++  it "round-trips Nothing" $+    assertNestedDecode (nestedRegFile Nothing) Nothing++  it "rejects an absent Maybe slot rather than treating it as Nothing" $+    case regFileFromJSON @MaybeSlots+      ( Aeson.object+          [ "approvedAt" Aeson..= Aeson.Null,+            "shippingAddress" Aeson..= Aeson.Null+          ]+      ) of+      Left message -> message `shouldBe` "lastError: missing slot"+      Right _ -> error "expected an absent-slot failure"++nestedRegFile :: Maybe (Maybe Int) -> RegFile NestedMaybeSlots+nestedRegFile value = Core.RCons (Proxy @"nested") value Core.RNil++assertNestedDecode :: RegFile NestedMaybeSlots -> Maybe (Maybe Int) -> IO ()+assertNestedDecode rf expected =+  case regFileFromJSON @NestedMaybeSlots (regFileToJSON rf) of+    Left message -> error ("nested Maybe decode failed: " <> message)+    Right (Core.RCons _ actual Core.RNil) -> actual `shouldBe` expected
+ test/Keiki/Codec/JSON/THEventEvolutionSpec.hs view
@@ -0,0 +1,227 @@+{-# LANGUAGE TemplateHaskell #-}++-- | EP-77 evolution regressions for additive event fields.+module Keiki.Codec.JSON.THEventEvolutionSpec+  ( spec,+    Discount (..),+    ItemAddedData (..),+    AdditiveEvent (..),+    additiveEventToJSON,+    additiveEventFromJSON,+    QuantityRecordedData (..),+    StructuralEvent (..),+    structuralEventToJSON,+    structuralEventFromJSON,+  )+where++import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy (ByteString)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Keiki.Codec.JSON.Event+  ( EventCodecOptions (..),+    FieldCodec (fcOnMissing),+    defaultEventCodecOptions,+    deriveEventCodecSkeleton,+    fieldCodec,+  )+import Test.Hspec (Spec, describe, it, shouldBe)+import Test.QuickCheck+  ( Gen,+    Property,+    chooseInt,+    elements,+    forAllShow,+    listOf1,+    oneof,+    (===),+  )++newtype Discount = Discount Int+  deriving stock (Eq, Show)++discountToJSON :: Discount -> Aeson.Value+discountToJSON (Discount amount) = Aeson.toJSON amount++discountFromJSON :: Aeson.Value -> Either String Discount+discountFromJSON value = case Aeson.fromJSON value of+  Aeson.Success amount -> Right (Discount amount)+  Aeson.Error message -> Left message++missingDiscount :: Discount+missingDiscount = Discount 0++data ItemAddedData = ItemAddedData+  { orderId :: Text,+    discount :: Discount,+    note :: Maybe Text+  }+  deriving stock (Eq, Show)++data AdditiveEvent = ItemAdded ItemAddedData+  deriving stock (Eq, Show)++$( deriveEventCodecSkeleton+     defaultEventCodecOptions+       { fieldCodecOverrides =+           Map.fromList+             [ ( "discount",+                 (fieldCodec 'discountToJSON 'discountFromJSON)+                   { fcOnMissing = Just 'missingDiscount+                   }+               )+             ],+         passthroughFields = Set.fromList ["orderId", "note"]+       }+     ''AdditiveEvent+ )++data QuantityRecordedData = QuantityRecordedData+  { quantity :: Int+  }+  deriving stock (Eq, Show)++data StructuralEvent = QuantityRecorded QuantityRecordedData+  deriving stock (Eq, Show)++upcastQuantityV1 :: Aeson.Value -> Either String Aeson.Value+upcastQuantityV1 value = case value of+  Aeson.Object object -> case KeyMap.lookup (Key.fromString "qty") object of+    Nothing -> Left "missing qty"+    Just quantityValue ->+      Right+        ( Aeson.Object+            (KeyMap.insert (Key.fromString "quantity") quantityValue object)+        )+  _ -> Left "expected an object"++$( deriveEventCodecSkeleton+     defaultEventCodecOptions+       { passthroughFields = Set.fromList ["quantity"],+         currentVersion = 2,+         upcasters = [(1, 'upcastQuantityV1)]+       }+     ''StructuralEvent+ )++-- These bytes predate the note and discount fields; do not regenerate them.+oldAdditiveBytes :: ByteString+oldAdditiveBytes = "{\"kind\":\"ItemAdded\",\"orderId\":\"ord-7\"}"++decodeAdditiveBytes :: ByteString -> Either String AdditiveEvent+decodeAdditiveBytes bytes = do+  value <- Aeson.eitherDecode bytes+  additiveEventFromJSON value++decodeStructuralBytes :: ByteString -> Either String StructuralEvent+decodeStructuralBytes bytes = do+  value <- Aeson.eitherDecode bytes+  structuralEventFromJSON value++arbAdditiveEvent :: Gen AdditiveEvent+arbAdditiveEvent = do+  orderNumber <- chooseInt (0, 100000)+  discountAmount <- chooseInt (-1000, 1000)+  generatedNote <-+    oneof+      [ pure Nothing,+        Just . T.pack <$> listOf1 (elements ['a' .. 'z'])+      ]+  pure+    ( ItemAdded+        ItemAddedData+          { orderId = T.pack ("ord-" <> show orderNumber),+            discount = Discount discountAmount,+            note = generatedNote+          }+    )++roundTripProperty :: AdditiveEvent -> Property+roundTripProperty event =+  additiveEventFromJSON (additiveEventToJSON event) === Right event++spec :: Spec+spec = describe "default-on-missing event fields" $ do+  it "exports the additive fixture's wire metadata" $ do+    additiveEventSchemaVersion `shouldBe` 1+    additiveEventEventTypes `shouldBe` [T.pack "ItemAdded"]+    additiveEventKindMap+      `shouldBe` [(T.pack "ItemAdded", T.pack "ItemAdded")]++  it "decodes bytes written before the additive fields existed" $+    decodeAdditiveBytes oldAdditiveBytes+      `shouldBe` Right+        ( ItemAdded+            ItemAddedData+              { orderId = T.pack "ord-7",+                discount = missingDiscount,+                note = Nothing+              }+        )++  it "decodes a present null Maybe field as Nothing" $+    decodeAdditiveBytes+      "{\"kind\":\"ItemAdded\",\"orderId\":\"ord-7\",\"discount\":12,\"note\":null}"+      `shouldBe` Right+        ( ItemAdded+            ItemAddedData+              { orderId = T.pack "ord-7",+                discount = Discount 12,+                note = Nothing+              }+        )++  it "round-trips a fully populated value" $+    let event =+          ItemAdded+            ItemAddedData+              { orderId = T.pack "ord-8",+                discount = Discount 15,+                note = Just (T.pack "priority")+              }+     in additiveEventFromJSON (additiveEventToJSON event) `shouldBe` Right event++  it "round-trips generated Nothing and Just values" $+    forAllShow arbAdditiveEvent show roundTripProperty++  it "keeps required fields strict" $+    decodeAdditiveBytes "{\"kind\":\"ItemAdded\"}"+      `shouldBe` (Left "missing field: orderId" :: Either String AdditiveEvent)++  describe "upcaster chain" $ do+    let expected = QuantityRecorded (QuantityRecordedData 3)++    it "exports the structural fixture's current wire metadata" $ do+      structuralEventSchemaVersion `shouldBe` 2+      structuralEventEventTypes `shouldBe` [T.pack "QuantityRecorded"]+      structuralEventKindMap+        `shouldBe` [(T.pack "QuantityRecorded", T.pack "QuantityRecorded")]++    it "decodes version-1 bytes after renaming qty to quantity" $+      decodeStructuralBytes+        "{\"kind\":\"QuantityRecorded\",\"v\":1,\"qty\":3}"+        `shouldBe` Right expected++    it "runs the version-1 upcaster for an absent version stamp" $+      decodeStructuralBytes+        "{\"kind\":\"QuantityRecorded\",\"qty\":3}"+        `shouldBe` Right expected++    it "decodes current-version bytes without running an old rung" $ do+      decodeStructuralBytes+        "{\"kind\":\"QuantityRecorded\",\"v\":2,\"quantity\":3}"+        `shouldBe` Right expected+      structuralEventFromJSON (structuralEventToJSON expected)+        `shouldBe` Right expected++    it "surfaces a rung failure with its source version" $+      decodeStructuralBytes+        "{\"kind\":\"QuantityRecorded\",\"v\":1}"+        `shouldBe` ( Left "upcaster from version 1: missing qty" ::+                       Either String StructuralEvent+                   )
test/Keiki/Codec/JSON/THEventSpec.hs view
@@ -45,9 +45,52 @@ --    @_todo_Placed_discount@ in @cabal repl@ shows the binding exists. --    Revert afterwards. ----- Both behaviours were verified by hand on 2026-06-06; the observed--- compile-fail text matched (1) verbatim.-module Keiki.Codec.JSON.THEventSpec (spec) where+-- 3. Duplicate wire kinds. Add+--    @kindOverrides = Map.fromList [("Placed", "order.event"),+--    ("Shipped", "order.event")]@, build the test target, and expect:+--+--    @+--    deriveEventCodecSkeleton: wire kind "order.event" is claimed by more+--    than one constructor: Placed, Shipped. Wire kinds must be unique per+--    event type.+--    @+--+--    Revert the override.+--+-- 4. Discriminator collision. Add @kind :: Text@ to @PlacedData@ and list+--    @"kind"@ in 'passthroughFields', build the test target, and expect:+--+--    @+--    deriveEventCodecSkeleton: payload field Placed.kind collides with+--    kindFieldName "kind"; rename the field or choose a kindFieldName no+--    payload uses.+--    @+--+--    Revert the field and passthrough entry.+--+-- 5. Incomplete upcaster chain. In @THEventEvolutionSpec@, set the+--    @StructuralEvent@ codec's @currentVersion = 3@ while retaining only+--    @upcasters = [(1, 'upcastQuantityV1)]@. Build the test target and expect:+--+--    @+--    deriveEventCodecSkeleton: upcasters must cover from-versions [1..2]+--    exactly; missing: [2]+--    @+--+--    Restore @currentVersion = 2@.+--+-- Checks (1) and (2) were verified by hand on 2026-06-06; the observed+-- compile-fail text matched (1) verbatim. Checks (3), (4), and (5) were+-- verified by hand on 2026-07-12 with the messages above.+module Keiki.Codec.JSON.THEventSpec+  ( spec,+    OrderId (..),+    PlacedData (..),+    OrderEvent (..),+    orderEventToJSON,+    orderEventFromJSON,+  )+where  import Data.Aeson qualified as Aeson import Data.Map.Strict qualified as Map@@ -56,9 +99,9 @@ import Data.Text qualified as T import Keiki.Codec.JSON.Event   ( EventCodecOptions (..),-    FieldCodec (..),     defaultEventCodecOptions,     deriveEventCodecSkeleton,+    fieldCodec,   ) import Test.Hspec (Spec, describe, it, shouldBe) import Text.Read (readMaybe)@@ -101,8 +144,9 @@ $( deriveEventCodecSkeleton      defaultEventCodecOptions        { fieldCodecOverrides =-           Map.fromList [("orderId", FieldCodec 'orderIdToJSON 'orderIdFromJSON)],-         passthroughFields = Set.fromList ["qty", "trackingNo"]+           Map.fromList [("orderId", fieldCodec 'orderIdToJSON 'orderIdFromJSON)],+         passthroughFields = Set.fromList ["qty", "trackingNo"],+         kindOverrides = Map.fromList [("Placed", "order.placed")]        }      ''OrderEvent  )@@ -127,48 +171,100 @@    describe "kind discriminator + override usage" $ do     -- orderEventToJSON (Placed (PlacedData (OrderId 7) 3))-    -- == {"kind":"Placed","orderId":"ord-7","qty":3}+    -- == {"kind":"order.placed","v":1,"orderId":"ord-7","qty":3}     it "Placed carries the kind key, the override output, and the passthrough field" $       orderEventToJSON placed         `shouldBe` Aeson.object-          [ "kind" Aeson..= (T.pack "Placed" :: Text),+          [ "kind" Aeson..= (T.pack "order.placed" :: Text),+            "v" Aeson..= (1 :: Int),             "orderId" Aeson..= (T.pack "ord-7" :: Text),             "qty" Aeson..= (3 :: Int)           ] -    it "the orderId override ran (string \"ord-7\", not the integer 7)" $+    it "a pinned wire kind decodes independently of the Haskell constructor name" $       -- If a generic Int codec had been silently used, this would be 7.       orderEventFromJSON         ( Aeson.object-            [ "kind" Aeson..= (T.pack "Placed" :: Text),+            [ "kind" Aeson..= (T.pack "order.placed" :: Text),               "orderId" Aeson..= (T.pack "ord-7" :: Text),               "qty" Aeson..= (3 :: Int)             ]         )         `shouldBe` Right placed -    it "Cancelled encodes to just the kind object" $+    it "Cancelled encodes to just the envelope keys" $       orderEventToJSON cancelled-        `shouldBe` Aeson.object ["kind" Aeson..= (T.pack "Cancelled" :: Text)]+        `shouldBe` Aeson.object+          [ "kind" Aeson..= (T.pack "Cancelled" :: Text),+            "v" Aeson..= (1 :: Int)+          ] +  describe "schema version envelope" $ do+    it "exports the current schema version" $+      orderEventSchemaVersion `shouldBe` 1++    it "treats a missing version key as version 1" $+      orderEventFromJSON+        ( Aeson.object+            [ "kind" Aeson..= (T.pack "order.placed" :: Text),+              "orderId" Aeson..= (T.pack "ord-7" :: Text),+              "qty" Aeson..= (3 :: Int)+            ]+        )+        `shouldBe` Right placed++    it "rejects an event written by a newer codec" $+      orderEventFromJSON+        ( Aeson.object+            [ "kind" Aeson..= (T.pack "Cancelled" :: Text),+              "v" Aeson..= (2 :: Int)+            ]+        )+        `shouldBe` ( Left "event schema version 2 is ahead of codec version 1" ::+                       Either String OrderEvent+                   )++    it "rejects a schema version below 1" $+      orderEventFromJSON+        ( Aeson.object+            [ "kind" Aeson..= (T.pack "Cancelled" :: Text),+              "v" Aeson..= (0 :: Int)+            ]+        )+        `shouldBe` (Left "invalid event schema version: 0" :: Either String OrderEvent)++    it "rejects a non-integral schema-version value" $+      orderEventFromJSON+        ( Aeson.object+            [ "kind" Aeson..= (T.pack "Cancelled" :: Text),+              "v" Aeson..= (T.pack "one" :: Text)+            ]+        )+        `shouldBe` ( Left "field v: expected an integer schema version" ::+                       Either String OrderEvent+                   )+   describe "decoder error paths" $ do     it "an unknown kind is reported" $       orderEventFromJSON         (Aeson.object ["kind" Aeson..= (T.pack "Nope" :: Text)])-        `shouldBe` (Left "unknown event kind: Nope" :: Either String OrderEvent)+        `shouldBe` ( Left+                       "unknown event kind: Nope (expected one of: order.placed, Shipped, Cancelled)" ::+                       Either String OrderEvent+                   )      it "a non-object is reported" $       orderEventFromJSON (Aeson.toJSON (5 :: Int))         `shouldBe` (Left "orderEvent: expected a JSON object" :: Either String OrderEvent)    describe "Keiro-feeding surfaces" $ do-    it "EventTypes lists the constructors in declaration order" $+    it "EventTypes lists resolved wire kinds in declaration order" $       orderEventEventTypes-        `shouldBe` map T.pack ["Placed", "Shipped", "Cancelled"]+        `shouldBe` map T.pack ["order.placed", "Shipped", "Cancelled"]      it "KindMap pairs each constructor name with its kind string" $       orderEventKindMap-        `shouldBe` [ (T.pack "Placed", T.pack "Placed"),+        `shouldBe` [ (T.pack "Placed", T.pack "order.placed"),                      (T.pack "Shipped", T.pack "Shipped"),                      (T.pack "Cancelled", T.pack "Cancelled")                    ]
test/Spec.hs view
@@ -7,28 +7,34 @@ -- on missing slot, extra slot, type mismatch. module Main (main) where +import Control.Exception (ErrorCall (..), evaluate) import Data.Aeson qualified as Aeson import Data.Aeson.Encoding qualified as AesonEnc import Data.ByteString.Lazy qualified as LBS import Data.Kind (Type)+import Data.List (isPrefixOf) import Data.Maybe (fromJust) import Data.Proxy (Proxy (..)) import Data.Text (Text) import Data.Text qualified as T import GHC.TypeLits (Symbol) import Keiki.Codec.JSON (regFileFromJSON, regFileToEncoding, regFileToJSON)+import Keiki.Codec.JSON.GoldenFileSpec qualified import Keiki.Codec.JSON.GoldenSpec qualified import Keiki.Codec.JSON.PropSpec qualified import Keiki.Codec.JSON.SensitivitySpec qualified+import Keiki.Codec.JSON.THEventEvolutionSpec qualified import Keiki.Codec.JSON.THEventSpec qualified import Keiki.Codec.JSON.THSpec qualified import Keiki.Core (RegFile (..))+import Keiki.Generics (emptyRegFile) import Test.Hspec   ( Spec,     describe,     hspec,     it,     shouldBe,+    shouldThrow,   )  main :: IO ()@@ -37,8 +43,10 @@   describe "M3 properties" Keiki.Codec.JSON.PropSpec.spec   describe "M3 sensitivity" Keiki.Codec.JSON.SensitivitySpec.spec   describe "M3 golden hash" Keiki.Codec.JSON.GoldenSpec.spec+  describe "EP-78 checked-in persistence goldens" Keiki.Codec.JSON.GoldenFileSpec.spec   describe "EP-38 deriveRegFileCodec" Keiki.Codec.JSON.THSpec.spec   describe "EP-59 deriveEventCodecSkeleton" Keiki.Codec.JSON.THEventSpec.spec+  describe "EP-77 event schema evolution" Keiki.Codec.JSON.THEventEvolutionSpec.spec  spec :: Spec spec = do@@ -112,6 +120,13 @@                   ]               )       isExtraFieldsLeft r `shouldBe` True++    it "throws a slot-named error when encoding an unwritten slot" $ do+      let uninitialized =+            emptyRegFile :: RegFile '[ '("retryCount", Int)]+      evaluate (LBS.length (Aeson.encode (regFileToJSON uninitialized)))+        `shouldThrow` \(ErrorCall message) ->+          "uninit: retryCount" `isPrefixOf` message    describe "Multi-slot RegFile (Int + Text)" $ do     let rf :: RegFile '[ '("retryCount", Int), '("note", Text)]
+ test/golden/event/item-added.pre-defaults.json view
@@ -0,0 +1,1 @@+{"kind":"ItemAdded","orderId":"ord-7"}
+ test/golden/event/item-added.v1.json view
@@ -0,0 +1,1 @@+{"discount":0,"kind":"ItemAdded","note":null,"orderId":"ord-7","v":1}
+ test/golden/event/order-cancelled.v1.json view
@@ -0,0 +1,1 @@+{"kind":"Cancelled","v":1}
+ test/golden/event/order-placed.v1.json view
@@ -0,0 +1,1 @@+{"kind":"order.placed","orderId":"ord-7","qty":3,"v":1}
+ test/golden/event/quantity-recorded.v1.json view
@@ -0,0 +1,1 @@+{"kind":"QuantityRecorded","v":1,"qty":3}
+ test/golden/event/quantity-recorded.v2.json view
@@ -0,0 +1,1 @@+{"kind":"QuantityRecorded","quantity":3,"v":2}
+ test/golden/exemplar-regfile.encoding.json view
@@ -0,0 +1,1 @@+{"retryCount":3,"cooldownUntil":"2026-01-02T03:04:05Z","correlationId":"order-123"}
+ test/golden/exemplar-regfile.value.json view
@@ -0,0 +1,1 @@+{"cooldownUntil":"2026-01-02T03:04:05Z","correlationId":"order-123","retryCount":3}
+ test/golden/exemplar-shape.json view
@@ -0,0 +1,1 @@+{"canonical":"retryCount:Int;cooldownUntil:UTCTime;correlationId:Text;regfile:0","hash":"d920c3660d5b2a7bda082cdedb08fa493acd3f74a663434a4cead475096866f9"}
+ test/golden/maybe-regfile-just.encoding.json view
@@ -0,0 +1,1 @@+{"lastError":"address invalid","approvedAt":"2026-01-02T03:04:05Z","shippingAddress":{"city":"Portland","line":"1 Main St","postcode":"97201"}}
+ test/golden/maybe-regfile-just.value.json view
@@ -0,0 +1,1 @@+{"approvedAt":"2026-01-02T03:04:05Z","lastError":"address invalid","shippingAddress":{"city":"Portland","line":"1 Main St","postcode":"97201"}}
+ test/golden/maybe-regfile-nothing.encoding.json view
@@ -0,0 +1,1 @@+{"lastError":null,"approvedAt":null,"shippingAddress":null}
+ test/golden/maybe-regfile-nothing.value.json view
@@ -0,0 +1,1 @@+{"approvedAt":null,"lastError":null,"shippingAddress":null}