packages feed

keiki-codec-json (empty) → 0.1.0.0

raw patch · 16 files changed

+2470/−0 lines, 16 filesdep +QuickCheckdep +aesondep +base

Dependencies added: QuickCheck, aeson, base, bytestring, containers, deepseq, hspec, keiki, keiki-codec-json, quickcheck-instances, tasty-bench, template-haskell, text, time

Files

+ CHANGELOG.md view
@@ -0,0 +1,50 @@+# Changelog++All notable changes to this package are documented in this file.+The format follows [Keep a Changelog](https://keepachangelog.com/),+and this project adheres to the+[Haskell PVP](https://pvp.haskell.org).+++## [Unreleased]+++## [0.1.0.0] — 2026-06-07++Initial Hackage release. Co-released with `keiki-0.1.0.0`; this+package depends on `keiki ^>= 0.1`.++### Added++- `Keiki.Codec.JSON` — three-method codec class `RegFileToJSON+  (rs :: [Slot])`:+  - `regFileToJSON :: RegFile rs -> Aeson.Value` (strict Value-path encoder).+  - `regFileFromJSON :: Aeson.Value -> Either String (RegFile rs)`+    (strict decoder; rejects missing / extra / type-mismatched+    fields with per-slot error messages).+  - `regFileToEncoding :: RegFile rs -> Aeson.Encoding` (streaming+    encoder over `Aeson.Series` that avoids the O(output-size)+    intermediate `Aeson.Value` allocation).+  - A single auto-derived instance for every slot list whose slot+    types have `Aeson.ToJSON` + `Aeson.FromJSON`; users do not+    write instances by hand.+- `Keiki.Codec.JSON.TH` — Template Haskell helpers+  `deriveRegFileCodec` and `deriveRegFileCodecAs` that emit+  three top-level codec functions (`<prefix>ToJSON`,+  `<prefix>ToEncoding`, `<prefix>FromJSON`) for a record type+  with `deriving (Generic)`.++### Validated against++- GHC 9.12.2 locally on macOS aarch64 and in CI on Ubuntu Linux+  x86_64 (see `.github/workflows/ci.yml`).+- 40 hspec assertions, including 4 QuickCheck properties (100+  samples each), 9 schema-evolution sensitivity assertions+  (EP-36 §4 cases #1–9), a pinned golden shape hash, and 10+  `Keiki.Codec.JSON.TH` derivation tests.+- A `tasty-bench` baseline (`bench/baseline.csv`) for four+  representative `RegFile` size scenarios (multi-party signing,+  batch reconciliation, ticket aggregate, auction); the+  Encoding-path is ~1.5× faster than the Value-path on the+  streaming-motivating case (5,000-entry list) with 33 % less+  allocation.
+ CONTRIBUTING.md view
@@ -0,0 +1,76 @@+# Contributing to keiki-codec-json++## GHC upgrade procedure (release-blocking)++The shape hash that powers snapshot discrimination+(`Keiki.Shape.regFileShapeHash`) uses `tyConModule + tyConName ++splitApps` from `Type.Reflection`. These accessors are stable across+GHC patch and minor versions historically, but the contract is not+covenanted forever. When bumping `tested-with` in `keiki.cabal`:++1. Add the new GHC to CI (`.github/workflows/ci.yml`'s `ghc` matrix+   entry).+2. Run the cross-GHC golden hash test:++       cabal test keiki-codec-json:keiki-codec-json-test \+         --test-options="--match 'M3 golden hash'"++   This asserts `regFileShapeHash (Proxy @ExemplarSlots)` matches the+   pinned value+   `a37b2b77042a635f394a082765f3410ea23a0b89745b0c77242b925a03aa172b`.++3. **If the test fails: stop. Block the release.** The cause is one of:+   - The new GHC changed `tyConModule` / `tyConName` semantics for one+     of the slot types (`Int` / `UTCTime` / `Text`). File an upstream+     GHC bug AND ship a `CanonicalTypeName` migration path for affected+     users (override the canonical name to the old value via+     `instance CanonicalTypeName Int where canonicalTypeName _ = "GHC.Types.Int"`,+     and bump the package's major version because the hash that+     in-flight snapshots carry is no longer derivable).+   - `renderStableTypeRep` has acquired an unintentional dependency on+     a non-stable accessor. Fix the keiki-side code; the hash for any+     stably-named type must continue to match the pinned golden.++4. Update `tested-with` in `keiki.cabal` (and `keiki-codec-json.cabal`+   for symmetry).++5. Add a release note explicitly flagging GHC X.Y.Z as validated+   against the golden hash.++The cross-GHC golden hash test is a **release-blocking gate**, not a+guideline. The whole point of the design is that drift cannot occur+silently; treating the test as advisory defeats the design (see EP-36+§8 in `docs/plans/36-regfile-json-codec-and-shape-hash-for-snapshot-persistence.md`).++## Performance bench drift++`bench/baseline.csv` is committed alongside the source. CI runs the+bench on every PR but does NOT block merges on drift. Reviewers should+look at the bench job output for unexpected slowdowns; > 20 % drift on+any single fixture × path pair is worth a comment. The GHC-9.12 golden+hash gate is the release blocker, not the bench, because (a) bench+numbers are noisier than hash determinism, and (b) the meaningful unit+of latency budget belongs to the consumer (keiro), not to keiki itself.++## Releasing++The full release procedure lives at+[`../docs/research/release-procedure.md`](../docs/research/release-procedure.md).+That document covers the coordinated push of `keiki`,+`keiki-codec-json`, and `keiki-codec-json-test`, the pre-publish+checklist, and the candidate-upload runbook. The EP-37 ExecPlan at+[`../docs/plans/37-coordinated-hackage-release-of-keiki-and-keiki-codec-json-v0-1.md`](../docs/plans/37-coordinated-hackage-release-of-keiki-and-keiki-codec-json-v0-1.md)+is the per-milestone narrative for the v0.1 first release.++## Adding a slot type to the test fixtures++If you add a new slot type to `Keiki.Codec.JSON.Fixtures.ExemplarSlots`,+the golden hash will change. Steps:++1. Update the fixture in `keiki-codec-json/test/Keiki/Codec/JSON/Fixtures.hs`.+2. Run `cabal test keiki-codec-json:keiki-codec-json-test`; capture the+   reported new hash from `GoldenSpec.hs`.+3. Update `keiki-codec-json/test/Keiki/Codec/JSON/GoldenSpec.hs` with the+   new pinned value.+4. Note the fixture change in the commit message; the cross-GHC gate's+   new baseline is implicitly established.
+ README.md view
@@ -0,0 +1,228 @@+# keiki-codec-json++Optional JSON codec support for+[`keiki`](https://hackage.haskell.org/package/keiki)'s type-level+register file, `RegFile rs`.++This package is separate by design: the `keiki` core remains+`aeson`-free, while applications that persist snapshots as JSON can opt+in here. The structural shape hash+(`Keiki.Shape.regFileShapeHash`) stays in `keiki` so consumers can+discriminate snapshot shapes without pulling in a JSON dependency.++This package ships:++- `Keiki.Codec.JSON.RegFileToJSON` — three-method class providing+  - `regFileToJSON :: RegFile rs -> Aeson.Value` (strict object encoder)+  - `regFileFromJSON :: Aeson.Value -> Either String (RegFile rs)`+    (strict decoder; missing / extra / type-mismatched fields are+    rejected with a per-slot error message)+  - `regFileToEncoding :: RegFile rs -> Aeson.Encoding` — streaming+    encoder over `Aeson.Series`, avoiding the O(output-size)+    intermediate `Aeson.Value` allocation for users with multi-MB slot+    values+- `Keiki.Codec.JSON.TH` — Template Haskell helpers for deriving record+  codecs through the same `RegFileToJSON` path+- `Keiki.Codec.JSON.Event` — Template Haskell helpers for generating a+  `kind`-discriminated event codec skeleton from event sum types++## Using++```haskell+import Data.Proxy (Proxy (..))+import Keiki.Codec.JSON (regFileFromJSON, regFileToEncoding, regFileToJSON)+import Keiki.Shape (regFileShapeHash)++type Snapshot = '[ '("retryCount", Int), '("note", Text) ]++-- Snapshot persister:+let bytes = encodingToLazyByteString (regFileToEncoding rf)+    hash = regFileShapeHash (Proxy @Snapshot)+writeRow (snapshotTable hash bytes)++-- Hydration:+case Aeson.decode bytes of+  Nothing -> Left "snapshot bytes not JSON"+  Just v  -> regFileFromJSON @Snapshot v+```++## Deriving the codec for a record type++If you have a plain Haskell record and want the three codec functions+without writing them by hand, use the TH splice from+`Keiki.Codec.JSON.TH`:++```haskell+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE TemplateHaskell #-}++import qualified Data.Aeson as Aeson+import Data.Text (Text)+import GHC.Generics (Generic)+import Keiki.Codec.JSON.TH (deriveRegFileCodec)++data Snapshot = Snapshot+  { retryCount :: Int+  , note       :: Text+  }+  deriving stock (Eq, Show, Generic)++$(deriveRegFileCodec ''Snapshot)+-- emits:+--   snapshotToJSON     :: Snapshot -> Aeson.Value+--   snapshotToEncoding :: Snapshot -> Aeson.Encoding+--   snapshotFromJSON   :: Aeson.Value -> Either String Snapshot+```++The emitted functions route through the same `RegFileToJSON` class as+the hand-written path: the record's field names become the JSON object's+keys, missing/extra/type-mismatched fields are rejected with the same+per-slot error messages, and the encoding path streams without+allocating an intermediate `Aeson.Value`.++Every field type must carry `Aeson.ToJSON` + `Aeson.FromJSON`. If a+field type lacks either instance, compilation fails at the use site of+the emitted function with a precise per-field error pointing at the+missing instance.++The record must have `deriving (Generic)` — the splice does not emit+a `Generic` instance for you. Multi-constructor sum types, positional+(non-record-syntax) constructors, and type synonyms are rejected at+splice time with a precise error message.++## Deriving an event codec skeleton++A service that stores its events as JSON usually hand-writes a+`kind`-discriminated encoder/decoder per event *sum* type — a large+`case` with one branch per constructor and one `.=` per payload field,+plus a matching parser. `deriveEventCodecSkeleton` (from+`Keiki.Codec.JSON.Event`) removes that boilerplate. Given a sum type whose+constructors each wrap a single record payload, or are no-argument+singletons:++```haskell+{-# LANGUAGE TemplateHaskell #-}++import qualified Data.Aeson as Aeson+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Text (Text)+import Keiki.Codec.JSON.Event+  ( EventCodecOptions (..)+  , FieldCodec (..)+  , defaultEventCodecOptions+  , deriveEventCodecSkeleton+  )++newtype OrderId = OrderId Int deriving stock (Eq, Show)+orderIdToJSON   :: OrderId -> Aeson.Value+orderIdFromJSON :: Aeson.Value -> Either String OrderId++data PlacedData = PlacedData+  { orderId :: OrderId+  , qty     :: Int+  }+  deriving stock (Eq, Show)++data ShippedData = ShippedData+  { trackingNo :: Text+  }+  deriving stock (Eq, Show)++data OrderEvent+  = Placed PlacedData+  | Shipped ShippedData+  | Cancelled+  deriving stock (Eq, Show)++$(deriveEventCodecSkeleton+    defaultEventCodecOptions+      { fieldCodecOverrides =+          Map.fromList [("orderId", FieldCodec 'orderIdToJSON 'orderIdFromJSON)]+      , passthroughFields = Set.fromList ["qty", "trackingNo"]+      }+    ''OrderEvent)+-- emits, using the lower-cased type name as prefix:+--   orderEventToJSON     :: OrderEvent -> Aeson.Value+--   orderEventFromJSON   :: Aeson.Value -> Either String OrderEvent+--   orderEventEventTypes :: [Text]+--   orderEventKindMap    :: [(Text, Text)]+```++Each constructor encodes to an object carrying a `"kind"` discriminator+(its constructor name) 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`.++**No silent generic fallback.** Each payload field is encoded by *name*:+an override (`fieldCodecOverrides`), a passthrough using the field's own+aeson instances (`passthroughFields`), or — for a field in neither —+whatever `onMissingCodec` says. The default `FailAtCompileTime` aborts the+splice listing every unhandled `<Event>.<field> :: <Type>`; the+alternative `EmitTodoBindings` emits a `_todo_<Event>_<field>` placeholder+that compiles but fails when evaluated. Adding a field to a payload+record therefore forces a compile-time decision instead of silently+changing, or dropping, the stored JSON.++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`).++## When to use the streaming encoder++`regFileToJSON` builds an `Aeson.Value` whose `Object` is an+`Aeson.KeyMap` — internally a `Map Key Value` in aeson 2.2, so its+serialised form orders keys alphabetically. `regFileToEncoding` walks+the slot list directly into `Aeson.Series` (slot-list order) without+materialising the intermediate `Aeson.Value`. Both paths round-trip+through `regFileFromJSON` to the same `RegFile`, but for multi-MB+RegFiles the Encoding path saves a substantial allocation (see+`bench/baseline.csv` — for the 5000-item batch reconciliation fixture+the Encoding path is ~1.5× faster and allocates roughly two-thirds the+bytes).++## Benchmarks++```sh+cabal bench keiki-codec-json:keiki-codec-json-bench+```++Four fixtures cover representative snapshot sizes:++| Fixture                | Scenario                 | Condensed size           |+|------------------------|--------------------------|--------------------------|+| `BenchA_ContractSign`  | Contract signing         | 5 parties, 50 audit rows |+| `BenchB_BatchRecon`    | Batch reconciliation     | 5,000 processedItems     |+| `BenchC_TicketAgg`     | Ticket aggregate         | 100 comments             |+| `BenchD_Auction`       | Auction                  | 1,000 bids               |++Per fixture: `encode-via-Value`, `encode-via-Encoding`, `decode`, `hash`.++`bench/baseline.csv` carries reference numbers from a GHC 9.12.2 run on+macOS aarch64. The benchmark is a tracked metric, not a correctness+gate; the golden shape-hash tests are the release-blocking checks.++## Test toolkit for downstream consumers++If you persist `RegFile rs` to JSON and want to guard against the+schema-evolution case the shape hash cannot catch by design — a+silent change to a slot type's `Aeson.ToJSON` instance — see the+sibling package+[`keiki-codec-json-test`](../keiki-codec-json-test/README.md). It+ships a per-slot-type golden-byte detector (`slotGoldenSpec`) plus+library versions of the round-trip and sensitivity disciplines,+parameterised over your own slot list. Production consumers of+`keiki-codec-json` do not need to depend on it.++## Test suite++```sh+cabal test keiki-codec-json:keiki-codec-json-test+```++Covers unit tests, four QuickCheck properties, schema-evolution+sensitivity assertions, and the golden hash fixture pinned for+GHC 9.12.*.
+ bench/Bench.hs view
@@ -0,0 +1,198 @@+{-# OPTIONS_GHC -Wno-orphans #-}++-- | EP-36 M4 — performance baselines for the four §10 reference cases+-- (condensed to keep CI bench duration under ~30 seconds total).+--+-- For each fixture, four measurements:+--+-- * @encode-via-Value@      — @Aeson.encode . regFileToJSON@+-- * @encode-via-Encoding@   — @encodingToLazyByteString . regFileToEncoding@+-- * @decode@                — @regFileFromJSON . fromJust . Aeson.decode@+-- * @hash@                  — @regFileShapeHash (Proxy \@SlotList)@+--+-- Run with @cabal bench keiki-codec-json:keiki-codec-json-bench@. The+-- baseline numbers are checked in at @bench/baseline.csv@; CI compares+-- new runs against the baseline and flags drift, but DOES NOT block+-- merges. The GHC-9.12 golden hash gate (M5) is the release-blocking+-- gate; the bench is a tracked metric.+module Main (main) where++import Control.DeepSeq (NFData (..))+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 (Text)+import Data.Text qualified as T+import Keiki.Codec.JSON (regFileFromJSON, regFileToEncoding, regFileToJSON)+import Keiki.Core (RegFile (..), Slot)+import Keiki.Shape (regFileShapeHash)+import Test.Tasty.Bench (bench, bgroup, defaultMain, nf)++-- * NFData for RegFile rs (bench-local orphan) -------------------------------++-- Bench measurements use 'nf', which needs 'NFData'. Inductive walker+-- forces every slot's value to normal form via the slot type's+-- 'NFData' instance.+class NFDataRegFile (rs :: [Slot]) where+  rnfRegFile :: RegFile rs -> ()++instance NFDataRegFile '[] where+  rnfRegFile RNil = ()++instance+  (NFData t, NFDataRegFile rs) =>+  NFDataRegFile ('(s, t) ': rs)+  where+  rnfRegFile (RCons _ x rest) = rnf x `seq` rnfRegFile rest++instance (NFDataRegFile rs) => NFData (RegFile rs) where+  rnf = rnfRegFile++-- * §10 Case A — Multi-party contract signing (condensed) -------------------++--+-- 5 parties, 50 audit entries → ~5 KB encoded.++type SlotsA =+  '[ '("retryCount", Int),+     '("auditLog", [Text]),+     '("currentPhase", Text)+   ]++fixtureA :: RegFile SlotsA+fixtureA =+  RCons (Proxy @"retryCount") 3 $+    RCons (Proxy @"auditLog") (replicate 50 (T.pack "audit:partial-signature-recorded")) $+      RCons (Proxy @"currentPhase") (T.pack "phase-3-awaiting-final-signature") RNil++-- * §10 Case B — Long-running batch reconciliation (condensed) --------------++--+-- 5000 processed items → ~250 KB encoded. This is the streaming-encoder+-- motivating case (R10) — the Value path allocates the intermediate+-- @Aeson.Value@ for the whole list; the Encoding path walks slot-by-+-- slot.++type SlotsB =+  '[ '("processedItems", [(Int, Text)]),+     '("phase", Text)+   ]++fixtureB :: RegFile SlotsB+fixtureB =+  RCons+    (Proxy @"processedItems")+    [(i, T.pack ("item-result-" <> show i)) | i <- [1 .. 5000]]+    $ RCons (Proxy @"phase") (T.pack "reconciling") RNil++-- * §10 Case C — Customer support ticket aggregate (condensed) --------------++--+-- 100 comments + 10 escalation entries → ~25 KB encoded.++type SlotsC =+  '[ '("comments", [Text]),+     '("escalationHistory", [Text]),+     '("priority", Text),+     '("channel", Text)+   ]++fixtureC :: RegFile SlotsC+fixtureC =+  RCons+    (Proxy @"comments")+    [ T.pack ("comment-" <> show i <> ": " <> "lorem-ipsum-dolor-sit-amet")+    | i <- [1 :: Int .. 100]+    ]+    $ RCons+      (Proxy @"escalationHistory")+      [T.pack ("escalation-" <> show i) | i <- [1 :: Int .. 10]]+    $ RCons (Proxy @"priority") (T.pack "high")+    $ RCons (Proxy @"channel") (T.pack "email") RNil++-- * §10 Case D — Real-time auction aggregate (condensed) --------------------++--+-- 1000 bids → ~50 KB encoded. High-write-rate snapshot pressure.++type SlotsD =+  '[ '("bidHistory", [(Int, Int)]),+     '("status", Text),+     '("watchers", [Int])+   ]++fixtureD :: RegFile SlotsD+fixtureD =+  RCons+    (Proxy @"bidHistory")+    [(t, t * 110 + 5000) | t <- [1 :: Int .. 1000]]+    $ RCons (Proxy @"status") (T.pack "active")+    $ RCons (Proxy @"watchers") [10000 .. 10100] RNil++-- * Driver -------------------------------------------------------------------++main :: IO ()+main = do+  let bytesA = Aeson.encode (regFileToJSON fixtureA)+      bytesB = Aeson.encode (regFileToJSON fixtureB)+      bytesC = Aeson.encode (regFileToJSON fixtureC)+      bytesD = Aeson.encode (regFileToJSON fixtureD)+  -- Force the input bytes so the decode benchmark isn't paying for+  -- the encode work as a side effect.+  _ <-+    pure $!+      LBS.length bytesA+        + LBS.length bytesB+        + LBS.length bytesC+        + LBS.length bytesD+  defaultMain+    [ bgroup+        "BenchA_ContractSign"+        [ bench "encode-via-Value" $ nf (Aeson.encode . regFileToJSON) fixtureA,+          bench "encode-via-Encoding" $ nf (AesonEnc.encodingToLazyByteString . regFileToEncoding) fixtureA,+          bench "decode" $ nf (decodeFixtureA) bytesA,+          bench "hash" $ nf (\() -> regFileShapeHash (Proxy @SlotsA)) ()+        ],+      bgroup+        "BenchB_BatchRecon"+        [ bench "encode-via-Value" $ nf (Aeson.encode . regFileToJSON) fixtureB,+          bench "encode-via-Encoding" $ nf (AesonEnc.encodingToLazyByteString . regFileToEncoding) fixtureB,+          bench "decode" $ nf (decodeFixtureB) bytesB,+          bench "hash" $ nf (\() -> regFileShapeHash (Proxy @SlotsB)) ()+        ],+      bgroup+        "BenchC_TicketAgg"+        [ bench "encode-via-Value" $ nf (Aeson.encode . regFileToJSON) fixtureC,+          bench "encode-via-Encoding" $ nf (AesonEnc.encodingToLazyByteString . regFileToEncoding) fixtureC,+          bench "decode" $ nf (decodeFixtureC) bytesC,+          bench "hash" $ nf (\() -> regFileShapeHash (Proxy @SlotsC)) ()+        ],+      bgroup+        "BenchD_Auction"+        [ bench "encode-via-Value" $ nf (Aeson.encode . regFileToJSON) fixtureD,+          bench "encode-via-Encoding" $ nf (AesonEnc.encodingToLazyByteString . regFileToEncoding) fixtureD,+          bench "decode" $ nf (decodeFixtureD) bytesD,+          bench "hash" $ nf (\() -> regFileShapeHash (Proxy @SlotsD)) ()+        ]+    ]++decodeFixtureA :: LBS.ByteString -> Either String (RegFile SlotsA)+decodeFixtureA bs = case Aeson.decode bs of+  Nothing -> Left "Aeson.decode failed"+  Just v -> regFileFromJSON v++decodeFixtureB :: LBS.ByteString -> Either String (RegFile SlotsB)+decodeFixtureB bs = case Aeson.decode bs of+  Nothing -> Left "Aeson.decode failed"+  Just v -> regFileFromJSON v++decodeFixtureC :: LBS.ByteString -> Either String (RegFile SlotsC)+decodeFixtureC bs = case Aeson.decode bs of+  Nothing -> Left "Aeson.decode failed"+  Just v -> regFileFromJSON v++decodeFixtureD :: LBS.ByteString -> Either String (RegFile SlotsD)+decodeFixtureD bs = case Aeson.decode bs of+  Nothing -> Left "Aeson.decode failed"+  Just v -> regFileFromJSON v
+ bench/baseline.csv view
@@ -0,0 +1,17 @@+Name,Mean (ps),2*Stdev (ps),Allocated,Copied,Peak Memory+All.BenchA_ContractSign.encode-via-Value,6807769,347816,34269,2,49283072+All.BenchA_ContractSign.encode-via-Encoding,3387927,264216,16065,0,65011712+All.BenchA_ContractSign.decode,12882232,1220802,106640,20,65011712+All.BenchA_ContractSign.hash,1747040,133960,5737,0,65011712+All.BenchB_BatchRecon.encode-via-Value,1311845312,130324330,6544326,89657,65011712+All.BenchB_BatchRecon.encode-via-Encoding,890765625,52914452,4256444,105,65011712+All.BenchB_BatchRecon.decode,2989114062,286069958,16482818,368064,65011712+All.BenchB_BatchRecon.hash,1784275,106480,5737,0,65011712+All.BenchC_TicketAgg.encode-via-Value,15564422,1321522,97357,7,65011712+All.BenchC_TicketAgg.encode-via-Encoding,7660565,566564,57640,0,78643200+All.BenchC_TicketAgg.decode,30095593,2245184,243181,55,78643200+All.BenchC_TicketAgg.hash,1799739,107384,5806,0,78643200+All.BenchD_Auction.encode-via-Value,234994824,16470910,1291776,4027,78643200+All.BenchD_Auction.encode-via-Encoding,153516113,10548170,778679,16,78643200+All.BenchD_Auction.decode,521485937,36636576,3347864,16826,78643200+All.BenchD_Auction.hash,1748992,160464,5772,0,78643200
+ keiki-codec-json.cabal view
@@ -0,0 +1,121 @@+cabal-version:      3.0+name:               keiki-codec-json+version:            0.1.0.0+synopsis:           Optional JSON codec for keiki's RegFile.+description:+  Sibling package to keiki providing a JSON encoder, decoder, and+  streaming encoder for @RegFile rs@. The keiki core remains+  aeson-free; this package opts in. See keiki's @Keiki.Shape@+  module for the GHC-upgrade-safe shape hash used to discriminate+  snapshots — the two halves of the snapshot persistence story.+  .+  Three methods on @class RegFileToJSON rs@:+  .+  * @regFileToJSON :: RegFile rs -> Aeson.Value@ — strict+  Value-path encoder.+  .+  * @regFileFromJSON :: Aeson.Value -> Either String (RegFile rs)@+  — strict decoder with per-slot error messages on missing /+  extra / type-mismatched fields.+  .+  * @regFileToEncoding :: RegFile rs -> Aeson.Encoding@ —+  streaming encoder over @Aeson.Series@, avoiding the+  O(output-size) intermediate @Aeson.Value@ allocation. Recommended+  for RegFiles with multi-MB slot values.+  .+  Also ships a Template Haskell helper module+  @Keiki.Codec.JSON.TH@ with @deriveRegFileCodec@ that emits the+  three codec functions for a record type with @deriving Generic@.+  .+  The companion package @keiki-codec-json-test@ ships a property-+  test toolkit for downstream consumers (case-#10 ToJSON-change+  detector plus library-ised round-trip / sensitivity helpers).++license:            BSD-3-Clause+author:             Nadeem Bitar+maintainer:         nadeem@gmail.com+copyright:          2026 Nadeem Bitar+category:           Codec+build-type:         Simple+tested-with:        GHC >=9.12 && <9.13+extra-doc-files:+  CHANGELOG.md+  CONTRIBUTING.md+  README.md++extra-source-files: bench/baseline.csv++common warnings+  ghc-options:+    -Wall -Wcompat -Widentities -Wincomplete-record-updates+    -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints++common shared-extensions+  default-language:   GHC2024+  default-extensions:+    AllowAmbiguousTypes+    DuplicateRecordFields+    FunctionalDependencies+    OverloadedLabels+    OverloadedRecordDot+    OverloadedStrings+    UndecidableInstances++library+  import:          warnings, shared-extensions+  exposed-modules:+    Keiki.Codec.JSON+    Keiki.Codec.JSON.Event+    Keiki.Codec.JSON.TH++  hs-source-dirs:  src+  build-depends:+    , aeson             ^>=2.2+    , base              ^>=4.21+    , containers        ^>=0.7+    , keiki             ^>=0.1+    , template-haskell  ^>=2.23+    , text              ^>=2.1++benchmark keiki-codec-json-bench+  import:         warnings, shared-extensions+  type:           exitcode-stdio-1.0+  hs-source-dirs: bench+  main-is:        Bench.hs+  build-depends:+    , aeson             ^>=2.2+    , base              ^>=4.21+    , bytestring        ^>=0.12+    , deepseq           ^>=1.5+    , keiki             ^>=0.1+    , keiki-codec-json  ^>=0.1+    , tasty-bench       ^>=0.4  || ^>=0.5+    , text              ^>=2.1++  ghc-options:    -fproc-alignment=64 "-with-rtsopts=-A32m -T"++test-suite keiki-codec-json-test+  import:         warnings, shared-extensions+  type:           exitcode-stdio-1.0+  hs-source-dirs: test+  main-is:        Spec.hs+  other-modules:+    Keiki.Codec.JSON.Fixtures+    Keiki.Codec.JSON.GoldenSpec+    Keiki.Codec.JSON.PropSpec+    Keiki.Codec.JSON.SensitivitySpec+    Keiki.Codec.JSON.THEventSpec+    Keiki.Codec.JSON.THSpec++  build-depends:+    , aeson                 ^>=2.2+    , base                  ^>=4.21+    , bytestring            ^>=0.12+    , containers            ^>=0.7+    , hspec                 ^>=2.11+    , keiki                 ^>=0.1+    , keiki-codec-json      ^>=0.1+    , QuickCheck            ^>=2.15+    , quickcheck-instances  ^>=0.3+    , text                  ^>=2.1+    , time                  ^>=1.14
+ src/Keiki/Codec/JSON.hs view
@@ -0,0 +1,169 @@+-- |+-- Module      : Keiki.Codec.JSON+-- Description : JSON encoder, decoder, and streaming encoder for @RegFile rs@.+--+-- This module is the public entry point of the @keiki-codec-json@+-- package. It provides+--+-- * 'regFileToJSON'      — strict 'Aeson.Value' encoder.+-- * 'regFileFromJSON'    — strict decoder (rejects missing, extra, or+--   type-mismatched fields with a per-slot error message).+-- * 'regFileToEncoding'  — streaming encoder that walks the slot list+--   directly into 'Aeson.Series', avoiding the O(output-size)+--   intermediate 'Aeson.Value' allocation. Use this on RegFiles whose+--   slot /values/ are large (multi-MB) — see §10 case B in+--   @docs\/plans\/36-regfile-json-codec-and-shape-hash-for-snapshot-persistence.md@.+--+-- The codec is the keiki-side counterpart of the shape hash in+-- @Keiki.Shape@ ("keiki" package). Together they implement the+-- snapshot persistence story: the hash discriminates eligible+-- snapshots (catches structural drift, EP-36 §4 cases #1–9); the+-- codec serialises the eligible ones.+--+-- == Slot-value size guidance (P11)+--+-- keiki's per-slot dispatch overhead is microseconds at any realistic+-- slot count (< 1000 slots). The actual cost of encoding is dominated+-- by each slot type's 'Aeson.ToJSON'/'Aeson.FromJSON' instance and the+-- size of the value it carries. The §10 reference cases in EP-36+-- exhibit RegFiles of 50 KB to 10 MB encoded; this codec serves all of+-- them, but users carrying multi-megabyte slot values should:+--+-- 1. Call 'regFileToEncoding' (this module) instead of 'regFileToJSON'+--    to avoid the O(output-size) intermediate 'Aeson.Value' allocation.+--    Concretely: use+--    @'Aeson.encodingToLazyByteString' . 'regFileToEncoding'@ instead+--    of @'Aeson.encode' . 'regFileToJSON'@.+-- 2. Consider whether the bulk slot belongs in the RegFile at all —+--    for some workloads, splitting bulk data into a separate event+--    stream and projecting it via subscriptions is structurally+--    cleaner than carrying it in the workflow's RegFile.+module Keiki.Codec.JSON+  ( -- * The codec class+    RegFileToJSON (..),++    -- * Internal helper (exported so its instances are reachable; users++    -- typically program against 'RegFileToJSON' only)+    RegFileWalk,+  )+where++import Data.Aeson ((.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types qualified as Aeson (Pair)+import Data.Proxy (Proxy (..))+import GHC.TypeLits (KnownSymbol, symbolVal)+import Keiki.Core (RegFile (..), Slot)++-- * Internal walker -----------------------------------------------------------++-- | Inductive walker over a slot list. Internal helper that powers the+-- three public methods on 'RegFileToJSON'; the auto-derived instances+-- cover any slot list whose components satisfy 'Aeson.ToJSON' and+-- 'Aeson.FromJSON'. Users program against 'RegFileToJSON', not against+-- this class.+class RegFileWalk (rs :: [Slot]) where+  -- | The slot list as a flat list of @(Key, Value)@ pairs, in+  -- slot-list order. Used to build 'Aeson.Value' via 'Aeson.object'.+  regFilePairs :: RegFile rs -> [Aeson.Pair]++  -- | The slot list as an 'Aeson.Series', in slot-list order. Used to+  -- build 'Aeson.Encoding' via 'Aeson.pairs', avoiding the+  -- 'Aeson.Value' intermediate.+  regFileSeries :: RegFile rs -> Aeson.Series++  -- | Consume slots from an 'Aeson.Object', returning the populated+  -- 'RegFile' and the leftover keys. The top-level decoder+  -- ('regFileFromJSON') uses the leftover to reject extra fields.+  regFileReadObject ::+    Aeson.Object ->+    Either String (RegFile rs, Aeson.Object)++instance RegFileWalk '[] where+  regFilePairs _ = []+  regFileSeries _ = mempty+  regFileReadObject km = Right (RNil, km)++instance+  ( KnownSymbol s,+    Aeson.ToJSON t,+    Aeson.FromJSON t,+    RegFileWalk rs+  ) =>+  RegFileWalk ('(s, t) ': rs)+  where+  regFilePairs (RCons _ x rest) =+    let k = Key.fromString (symbolVal (Proxy @s))+     in (k .= x) : regFilePairs rest++  regFileSeries (RCons _ x rest) =+    let k = Key.fromString (symbolVal (Proxy @s))+     in (k .= x) <> regFileSeries rest++  regFileReadObject km =+    let slotName = symbolVal (Proxy @s)+        k = Key.fromString slotName+     in case KeyMap.lookup k km of+          Nothing -> Left (slotName <> ": missing slot")+          Just slotVal -> case Aeson.fromJSON slotVal of+            Aeson.Error msg -> Left (slotName <> ": " <> msg)+            Aeson.Success x -> do+              (rest, km') <- regFileReadObject (KeyMap.delete k km)+              Right (RCons (Proxy @s) x rest, km')++-- * Public class --------------------------------------------------------------++-- | The codec class for 'RegFile' slot lists.+--+-- A slot list @rs@ supports JSON serialisation iff every slot value+-- type carries both 'Aeson.ToJSON' and 'Aeson.FromJSON'. The instance+-- is auto-derived for any such slot list — users do not write+-- 'RegFileToJSON' instances themselves; the structural inductive+-- 'RegFileWalk' instances do the work.+--+-- The three methods correspond to the three columns in EP-36 §3:+--+-- * 'regFileToJSON'     — R1, strict encoder over 'Aeson.Value'.+-- * 'regFileFromJSON'   — R2, strict decoder (missing / extra / type-+--   mismatched fields are 'Left' with a per-slot error message).+-- * 'regFileToEncoding' — R10, streaming encoder over 'Aeson.Encoding'+--   that avoids the 'Aeson.Value' intermediate.+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.+  regFileToJSON :: RegFile rs -> Aeson.Value+  regFileToJSON = Aeson.object . regFilePairs++  -- | Streaming encoder. Walks the slot list directly into an+  -- 'Aeson.Series' via 'Aeson.pairs', avoiding the+  -- 'Aeson.Value' intermediate. Recommended for RegFiles with+  -- multi-MB slot values; see the module header's "Slot-value size+  -- guidance".+  regFileToEncoding :: RegFile rs -> Aeson.Encoding+  regFileToEncoding = Aeson.pairs . regFileSeries++  -- | Strict decoder. Returns @Left "\<slotName\>: \<reason\>"@ on any+  -- of: missing slot, type-mismatched slot, malformed JSON, or+  -- unknown extra field at the top level.+  regFileFromJSON :: Aeson.Value -> Either String (RegFile rs)+  regFileFromJSON v = case v of+    Aeson.Object km -> do+      (rf, leftover) <- regFileReadObject km+      if KeyMap.null leftover+        then Right rf+        else+          Left+            ( "regfile: unknown extra fields: "+                <> show (map Key.toString (KeyMap.keys leftover))+            )+    _ -> Left "regfile: expected JSON Object"++-- | Generic instance: every slot list with the inductive 'RegFileWalk'+-- 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
+ src/Keiki/Codec/JSON/Event.hs view
@@ -0,0 +1,512 @@+{-# LANGUAGE TemplateHaskell #-}++-- |+-- Module      : Keiki.Codec.JSON.Event+-- Description : Template Haskell that derives a @kind@-discriminated JSON+--               encoder/decoder skeleton for an event /sum/ type.+--+-- Where 'Keiki.Codec.JSON.TH.deriveRegFileCodec' handles a single-record+-- snapshot, this module handles an event sum — a @data@ type with several+-- constructors, each wrapping a single record payload (or a no-arg+-- singleton). For+--+-- > data OrderEvent+-- >   = Placed PlacedData+-- >   | Shipped ShippedData+-- >   | Cancelled+-- >   deriving stock (Eq, Show)+--+-- the splice @$(deriveEventCodecSkeleton opts ''OrderEvent)@ emits four+-- 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+-- > orderEventKindMap    :: [(Data.Text.Text, Data.Text.Text)]  -- (ctor, kind)+--+-- 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.+--+-- == 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;+--   * else if its name is in 'passthroughFields', the field's own+--     'Aeson.ToJSON' \/ 'Aeson.FromJSON' instances are used;+--   * otherwise the field is /unhandled/, and 'onMissingCodec' decides:+--     'FailAtCompileTime' (the default) aborts the splice listing every+--     unhandled field, while 'EmitTodoBindings' emits a clearly-named+--     @_todo_Event_field@ placeholder that compiles but is+--     @error "TODO: ..."@-bodied.+--+-- There is never a quiet generic guess: adding a field to a payload record+-- forces the author to make a decision at compile time.+--+-- This module lives in @keiki-codec-json@, not @keiki@ core, because the+-- generated code references @aeson@ and @keiki@ core must stay aeson-free+-- (EP-36 §3 R8; MP-11 Decision Log). It consumes no keiki-core symbols at+-- all; the constructor/field reflection mirrors @keiki@'s+-- @Keiki.Generics.TH@ (@conPayload@, @genTermFieldsRecord@) so the two stay+-- aligned.+--+-- == 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.+module Keiki.Codec.JSON.Event+  ( -- * Options+    FieldCodec (..),+    OnMissingCodec (..),+    EventCodecOptions (..),+    defaultEventCodecOptions,++    -- * Splices+    deriveEventCodecSkeleton,+    deriveEventCodecSkeletonAs,++    -- * Runtime helpers (referenced by generated code; not usually called directly)+    lookupField,+    lookupText,+    aesonResultToEither,+  )+where++import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Char (toLower)+import Data.List (intercalate)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Language.Haskell.TH++-- * Public option types ------------------------------------------------------++-- | A per-field encode/decode hook. Both fields name top-level functions+-- to splice in (so the hook is untyped at the TH boundary):+--+--   * 'fcEncode' names a function @fieldType -> Aeson.Value@ (e.g. @idText@);+--   * 'fcDecode' names a function @Aeson.Value -> Either String fieldType@+--     (e.g. @parseIdText@).+data FieldCodec = FieldCodec+  { fcEncode :: Name,+    fcDecode :: Name+  }++-- | What to do for a payload field whose name appears in neither+-- 'fieldCodecOverrides' nor 'passthroughFields'.+data OnMissingCodec+  = -- | Default: abort the splice with @fail@, listing every unhandled field.+    FailAtCompileTime+  | -- | Emit a named @_todo_\<Event\>_\<field\>@ stub and route the field+    --       through it, so the module compiles but any use throws an+    --       obviously-named @error@.+    EmitTodoBindings++-- | Options controlling 'deriveEventCodecSkeleton'.+data EventCodecOptions = EventCodecOptions+  { -- | keyed by payload field name (e.g. @"reservationId"@, @"divertStatus"@)+    fieldCodecOverrides :: Map String FieldCodec,+    -- | field names whose type already has aeson instances and may use them+    --     directly (e.g. @"sourceMessageId"@, @"lifeCriticalOverride"@)+    passthroughFields :: Set String,+    -- | the discriminator key; default @"kind"@+    kindFieldName :: String,+    -- | behaviour for unhandled fields; default 'FailAtCompileTime'+    onMissingCodec :: OnMissingCodec+  }++-- | Empty overrides, empty passthrough, @kindFieldName = "kind"@,+-- @onMissingCodec = 'FailAtCompileTime'@.+defaultEventCodecOptions :: EventCodecOptions+defaultEventCodecOptions =+  EventCodecOptions+    { fieldCodecOverrides = Map.empty,+      passthroughFields = Set.empty,+      kindFieldName = "kind",+      onMissingCodec = FailAtCompileTime+    }++-- * Runtime helpers (referenced by generated code) ---------------------------++-- | Look a key up in a JSON object, with a per-field error on absence.+lookupField :: Key.Key -> Aeson.Object -> Either String Aeson.Value+lookupField k o = case KeyMap.lookup k o of+  Just v -> Right v+  Nothing -> Left ("missing field: " <> Key.toString k)++-- | 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+  v <- lookupField k o+  case v of+    Aeson.String t -> Right t+    _ -> Left ("field " <> Key.toString k <> ": expected a string")++-- | Adapt aeson's 'Aeson.Result' to @Either String@.+aesonResultToEither :: Aeson.Result a -> Either String a+aesonResultToEither (Aeson.Success a) = Right a+aesonResultToEither (Aeson.Error e) = Left e++-- * Splices ------------------------------------------------------------------++-- | Derive the @kind@-discriminated codec skeleton for an event sum type,+-- with the function-name prefix derived from the type name by lower-casing+-- its first letter.+deriveEventCodecSkeleton :: EventCodecOptions -> Name -> Q [Dec]+deriveEventCodecSkeleton opts n = deriveEventCodecSkeletonAs (defaultPrefix n) opts n++-- | Variant of 'deriveEventCodecSkeleton' taking the prefix explicitly,+-- mirroring 'Keiki.Codec.JSON.TH.deriveRegFileCodecAs'.+deriveEventCodecSkeletonAs :: String -> EventCodecOptions -> Name -> Q [Dec]+deriveEventCodecSkeletonAs prefix opts tyName = do+  ctors <- reifyEventCtors tyName+  case ctors of+    [] ->+      fail $+        "deriveEventCodecSkeleton: "+          <> show tyName+          <> " has no constructors; pass an event sum type."+    _ -> pure ()++  -- No-silent-fallback safety net: classify each payload field; an+  -- unhandled field is one in neither overrides nor passthrough.+  let unhandled =+        [ (ecCtorName ec, fn, ft)+        | ec <- ctors,+          (_, fields) <- maybe [] (: []) (ecPayload ec),+          (fn, _sel, ft) <- fields,+          isUnhandled opts fn+        ]+  todoBindings <- case onMissingCodec opts of+    FailAtCompileTime+      | not (null unhandled) ->+          fail $+            "deriveEventCodecSkeleton: "+              <> show tyName+              <> " has field(s) with no provided codec and "+              <> "onMissingCodec = FailAtCompileTime:\n"+              <> intercalate+                "\n"+                [ "  - " <> nameBase c <> "." <> fn <> " :: " <> pprint ft+                | (c, fn, ft) <- unhandled+                ]+              <> "\nAdd each field to fieldCodecOverrides or passthroughFields, "+              <> "or set onMissingCodec = EmitTodoBindings."+      | otherwise -> pure []+    EmitTodoBindings -> concat <$> mapM mkTodoBinding unhandled++  let toJSONN = mkName (prefix <> "ToJSON")+      fromJSONN = mkName (prefix <> "FromJSON")+      eventTypesN = mkName (prefix <> "EventTypes")+      kindMapN = mkName (prefix <> "KindMap")+      tyT = conT tyName++  -- Encoder: one clause per constructor.+  toSig <- sigD toJSONN [t|$tyT -> Aeson.Value|]+  toDef <- funD toJSONN (map (encodeClause opts) ctors)++  -- Decoder: a single \v -> case ... clause.+  vVar <- newName "v"+  oVar <- newName "o"+  kVar <- newName "kind"+  fromSig <- sigD fromJSONN [t|Aeson.Value -> Either String $tyT|]+  fromDef <-+    funD+      fromJSONN+      [ clause+          [varP vVar]+          (normalB (decoderBody opts prefix vVar oVar kVar ctors))+          []+      ]++  -- Keiro-feeding surfaces: plain Text, no Keiro import.+  etSig <- sigD eventTypesN [t|[Text]|]+  etDef <-+    funD+      eventTypesN+      [ clause+          []+          ( normalB+              ( listE+                  [ [|T.pack $(stringE (nameBase (ecCtorName ec)))|]+                  | ec <- ctors+                  ]+              )+          )+          []+      ]+  kmSig <- sigD kindMapN [t|[(Text, Text)]|]+  kmDef <-+    funD+      kindMapN+      [ clause+          []+          ( normalB+              ( listE+                  [ [|(T.pack $(stringE nm), T.pack $(stringE nm))|]+                  | ec <- ctors,+                    let nm = nameBase (ecCtorName ec)+                  ]+              )+          )+          []+      ]++  pure $+    todoBindings+      <> [ toSig,+           toDef,+           fromSig,+           fromDef,+           etSig,+           etDef,+           kmSig,+           kmDef+         ]++-- * Encoder generation -------------------------------------------------------++-- | One @toJSON@ clause for a constructor.+encodeClause :: EventCodecOptions -> EvCtor -> Q Clause+encodeClause opts ec = case ecPayload ec of+  Nothing ->+    clause+      [conP (ecCtorName ec) []]+      (normalB [|Aeson.object [$(kindPair)]|])+      []+  Just (_pc, fields) -> do+    pVar <- newName "p"+    let pairs = kindPair : map (fieldPair opts (ecCtorName ec) pVar) fields+    clause+      [conP (ecCtorName ec) [varP pVar]]+      (normalB [|Aeson.object $(listE pairs)|])+      []+  where+    kindPair =+      [|+        $(keyE (kindFieldName opts))+          Aeson..= (T.pack $(stringE (nameBase (ecCtorName ec))) :: Text)+        |]++-- | One @"field" .= <encoded>@ pair.+fieldPair :: EventCodecOptions -> Name -> Name -> (String, Name, Type) -> Q Exp+fieldPair opts ctorName pVar f@(fn, _, _) =+  [|$(keyE fn) Aeson..= $(encodeFieldExpr opts ctorName pVar f)|]++-- | The encoded 'Aeson.Value' for one field of a constructor.+encodeFieldExpr :: EventCodecOptions -> Name -> Name -> (String, Name, Type) -> Q Exp+encodeFieldExpr opts ctorName pVar (fn, sel, _ft) =+  case classify opts fn of+    Override fc -> [|$(varE (fcEncode fc)) ($(varE sel) $(varE pVar))|]+    Passthrough -> [|Aeson.toJSON ($(varE sel) $(varE pVar))|]+    Unhandled ->+      [|($(varE (todoName ctorName fn)) ($(varE sel) $(varE pVar)) :: Aeson.Value)|]++-- * Decoder generation -------------------------------------------------------++-- | 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 =+  caseE+    (varE vVar)+    [ match+        (conP 'Aeson.Object [varP oVar])+        ( normalB+            ( infixE+                (Just [|lookupText $(keyE (kindFieldName opts)) $(varE oVar)|])+                (varE '(>>=))+                (Just (lamE [varP kVar] (dispatch opts oVar kVar ctors)))+            )+        )+        [],+      match+        wildP+        (normalB [|Left $(stringE (prefix <> ": expected a JSON object"))|])+        []+    ]++-- | 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 =+  foldr+    ( \ec elseQ ->+        [|+          if $(varE kVar) == T.pack $(stringE (nameBase (ecCtorName ec)))+            then $(buildCtorDecode opts oVar ec)+            else $elseQ+          |]+    )+    [|Left ("unknown event kind: " <> T.unpack $(varE kVar))|]++-- | Decode one constructor's payload: @C \<$> (PayloadCtor \<$> d1 \<*> ...)@.+buildCtorDecode :: EventCodecOptions -> Name -> EvCtor -> Q Exp+buildCtorDecode opts oVar ec = case ecPayload ec of+  Nothing -> [|Right $(conE (ecCtorName ec))|]+  Just (pc, fields) ->+    let decs = map (decodeFieldExpr opts oVar (ecCtorName ec)) fields+     in [|$(conE (ecCtorName ec)) <$> $(mkApplicative (conE pc) decs)|]++-- | Build @ctor \<$> d1 \<*> d2 \<*> ...@ (or @Right ctor@ for no fields)+-- in the @Either String@ applicative.+mkApplicative :: Q Exp -> [Q Exp] -> Q Exp+mkApplicative ctorQ [] = [|Right $ctorQ|]+mkApplicative ctorQ (d : ds) =+  foldl (\acc x -> [|$acc <*> $x|]) [|$ctorQ <$> $d|] ds++-- | @Either String fieldType@ decoder for one field.+decodeFieldExpr :: EventCodecOptions -> Name -> Name -> (String, Name, Type) -> Q Exp+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|]+        Unhandled -> [|$(varE (todoName ctorName fn)) =<< $getV|]++-- * TODO bindings ------------------------------------------------------------++-- | The placeholder binding name for an unhandled field.+todoName :: Name -> String -> Name+todoName ctorName fn = mkName ("_todo_" <> nameBase ctorName <> "_" <> fn)++-- | Emit @_todo_C_field :: a; _todo_C_field = error "TODO: ..."@.+mkTodoBinding :: (Name, String, Type) -> Q [Dec]+mkTodoBinding (cn, fn, ft) = do+  let nm = todoName cn fn+      msg =+        "TODO: provide a FieldCodec for "+          <> nameBase cn+          <> "."+          <> fn+          <> " :: "+          <> pprint ft+  aV <- newName "a"+  sig <- sigD nm (varT aV)+  def <- funD nm [clause [] (normalB [|error $(stringE msg)|]) []]+  pure [sig, def]++-- * Field classification -----------------------------------------------------++-- | How one field is encoded/decoded.+data FieldClass+  = Override FieldCodec+  | Passthrough+  | Unhandled++classify :: EventCodecOptions -> String -> FieldClass+classify opts fn =+  case Map.lookup fn (fieldCodecOverrides opts) of+    Just fc -> Override fc+    Nothing+      | fn `Set.member` passthroughFields opts -> Passthrough+      | otherwise -> Unhandled++isUnhandled :: EventCodecOptions -> String -> Bool+isUnhandled opts fn =+  not (fn `Map.member` fieldCodecOverrides opts)+    && not (fn `Set.member` passthroughFields opts)++-- * Reflection ---------------------------------------------------------------++-- | A reflected event constructor: its name plus, for a payload+-- constructor, the payload record's data-constructor name and its+-- @(fieldName, selectorName, fieldType)@ list. 'Nothing' for a singleton.+data EvCtor = EvCtor+  { ecCtorName :: Name,+    ecPayload :: Maybe (Name, [(String, Name, Type)])+  }++reifyEventCtors :: Name -> Q [EvCtor]+reifyEventCtors tyName = do+  info <- reify tyName+  case info of+    TyConI (DataD _ _ _ _ ctors _) -> mapM (toEvCtor tyName) ctors+    TyConI (NewtypeD _ _ _ _ ctor _) -> mapM (toEvCtor tyName) [ctor]+    _ ->+      fail $+        "deriveEventCodecSkeleton: expected a data declaration for "+          <> show tyName+          <> ", got "+          <> show info++toEvCtor :: Name -> Con -> Q EvCtor+toEvCtor tyName con = case con of+  NormalC cn [] -> pure (EvCtor cn Nothing)+  NormalC cn [(_, payTy)] -> do+    payload <- reifyPayloadFields tyName cn payTy+    pure (EvCtor cn (Just payload))+  NormalC cn _ ->+    fail $+      "deriveEventCodecSkeleton: constructor "+        <> nameBase cn+        <> " of "+        <> show tyName+        <> " is multi-argument; wrap a single record payload "+        <> "type instead, e.g. `Placed PlacedData`."+  RecC cn _ ->+    fail $+      "deriveEventCodecSkeleton: constructor "+        <> nameBase cn+        <> " of "+        <> show tyName+        <> " uses record syntax directly; wrap a single record "+        <> "payload type instead, e.g. `Placed PlacedData`."+  _ ->+    fail $+      "deriveEventCodecSkeleton: "+        <> show tyName+        <> " has an unsupported constructor shape (infix or GADT)."++reifyPayloadFields :: Name -> Name -> Type -> Q (Name, [(String, Name, Type)])+reifyPayloadFields tyName cn payTy = do+  payName <- typeConName payTy+  info <- reify payName+  case info of+    TyConI (DataD _ _ _ _ [RecC pcn fs] _) -> pure (pcn, map field fs)+    TyConI (NewtypeD _ _ _ _ (RecC pcn fs) _) -> pure (pcn, map field fs)+    _ ->+      fail $+        "deriveEventCodecSkeleton: payload of constructor "+          <> nameBase cn+          <> " in "+          <> show tyName+          <> " must be a single record-syntax "+          <> "constructor type, got "+          <> show info+  where+    field (sel, _, ty) = (nameBase sel, sel, ty)++typeConName :: Type -> Q Name+typeConName (ConT n) = pure n+typeConName (SigT t _) = typeConName t+typeConName other =+  fail $+    "deriveEventCodecSkeleton: payload type must be a type constructor, "+      <> "got "+      <> show other++-- * Small helpers ------------------------------------------------------------++-- | Lower-case the first character of the type name for the conventional+-- function-name prefix.+defaultPrefix :: Name -> String+defaultPrefix n = case nameBase n of+  [] -> error "deriveEventCodecSkeleton: empty type name"+  (c : cs) -> toLower c : cs++-- | An 'Key.fromString'-built JSON key expression.+keyE :: String -> Q Exp+keyE s = [|Key.fromString $(stringE s)|]
+ src/Keiki/Codec/JSON/TH.hs view
@@ -0,0 +1,259 @@+{-# 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+--               codec functions for plain Haskell record types.+--+-- A user with a record type+--+-- @+-- data MySnapshot = MySnapshot+--   { retryCount    :: Int+--   , correlationId :: Text+--   , dispatchedAt  :: UTCTime+--   }+--   deriving stock ('Eq', 'Show', GHC.Generics.'GHC.Generics.Generic')+-- @+--+-- invokes+--+-- @+module Keiki.Codec.JSON.TH+  ( deriveRegFileCodec,+    deriveRegFileCodecAs,+  )+where++import Data.Aeson qualified as Aeson+import Data.Char (toLower)+import GHC.Generics qualified as Generics+import Keiki.Codec.JSON (regFileFromJSON, regFileToEncoding, regFileToJSON)+import Keiki.Generics (RegFieldsOf, gFromRegFile, gToRegFile)+import Language.Haskell.TH+  ( Con (NormalC, RecC),+    Dec (DataD, NewtypeD, TySynD),+    Info (TyConI),+    Name,+    Q,+    clause,+    conT,+    funD,+    mkName,+    nameBase,+    normalB,+    reify,+    sigD,+  )++-- | Emit three top-level JSON-codec functions for the record type+-- @t@, with names derived from the type's name by lower-casing the+-- first character.+--+-- For @t = MySnapshot@ the splice emits+--+-- * @mySnapshotToJSON     :: MySnapshot -> Aeson.Value@+-- * @mySnapshotToEncoding :: MySnapshot -> Aeson.Encoding@+-- * @mySnapshotFromJSON   :: Aeson.Value -> Either String MySnapshot@+--+-- The record must have @deriving (Generic)@ (the splice does not+-- emit a @Generic@ instance). Every field type must have+-- @Aeson.ToJSON@ and @Aeson.FromJSON@ in scope, or compilation fails+-- at the use site of the emitted function with a missing-instance+-- error naming the field's type.+--+-- The splice rejects:+--+-- * Type synonyms, classes, value bindings, primitive types — only+--   @data@ and @newtype@ declarations are accepted.+-- * Multi-constructor types (@data Foo = A | B@) — a single slot list+--   cannot represent a sum.+-- * Positional (non-record-syntax) constructors — there are no field+--   names to use as slot symbols.+--+-- Singleton constructors with no fields (@data Empty = Empty@) are+-- accepted; the slot list is @'[]@ and the emitted functions codec+-- the empty JSON object.+deriveRegFileCodec :: Name -> Q [Dec]+deriveRegFileCodec n = deriveRegFileCodecAs (defaultPrefix n) n++-- | Variant of 'deriveRegFileCodec' that takes the function-name+-- prefix explicitly. The three emitted functions are named+-- @\<prefix\>ToJSON@, @\<prefix\>ToEncoding@, @\<prefix\>FromJSON@.+deriveRegFileCodecAs :: String -> Name -> Q [Dec]+deriveRegFileCodecAs prefix tyName = do+  validateRecord tyName+  let recTy = conT tyName+      toJSONN = mkName (prefix <> "ToJSON")+      toEncN = mkName (prefix <> "ToEncoding")+      fromJSONN = mkName (prefix <> "FromJSON")++  toJSONSig <-+    sigD+      toJSONN+      [t|$recTy -> Aeson.Value|]+  toJSONDef <-+    funD+      toJSONN+      [ clause+          []+          ( normalB+              [|+                regFileToJSON+                  @(RegFieldsOf $recTy)+                  . gToRegFile+                  . Generics.from+                |]+          )+          []+      ]++  toEncSig <-+    sigD+      toEncN+      [t|$recTy -> Aeson.Encoding|]+  toEncDef <-+    funD+      toEncN+      [ clause+          []+          ( normalB+              [|+                regFileToEncoding+                  @(RegFieldsOf $recTy)+                  . gToRegFile+                  . Generics.from+                |]+          )+          []+      ]++  fromJSONSig <-+    sigD+      fromJSONN+      [t|Aeson.Value -> Either String $recTy|]+  fromJSONDef <-+    funD+      fromJSONN+      [ clause+          []+          ( normalB+              [|+                fmap (Generics.to . gFromRegFile)+                  . regFileFromJSON+                    @(RegFieldsOf $recTy)+                |]+          )+          []+      ]++  pure+    [ toJSONSig,+      toJSONDef,+      toEncSig,+      toEncDef,+      fromJSONSig,+      fromJSONDef+    ]++-- * Internal helpers ---------------------------------------------------------++-- | Lower-case the first character of the type name to produce the+-- conventional function-name prefix.+defaultPrefix :: Name -> String+defaultPrefix n = case nameBase n of+  [] -> error "deriveRegFileCodec: empty type name"+  (c : cs) -> toLower c : cs++-- | Validate that @tyName@ refers to a single-constructor record-syntax+-- @data@ or @newtype@ declaration. Reject every other shape with a+-- precise error message.+validateRecord :: Name -> Q ()+validateRecord tyName = do+  info <- reify tyName+  case info of+    TyConI dec -> case dec of+      DataD _ _ _ _ ctors _ ->+        validateCtors tyName ctors+      NewtypeD _ _ _ _ ctor _ ->+        validateCtors tyName [ctor]+      TySynD {} ->+        failure "a type synonym; only `data` and `newtype` are supported"+      _ ->+        failure+          ( "an unsupported declaration shape; only `data` and "+              <> "`newtype` are supported"+          )+    _ ->+      failure+        ( "not a type constructor; pass a record type name like "+            <> "''MyRecord"+        )+  where+    failure :: String -> Q a+    failure detail =+      fail $ "deriveRegFileCodec: " <> show tyName <> " is " <> detail++-- | Inspect the constructor list. Accept iff there is exactly one+-- constructor, and that constructor is either @RecC@ (record syntax+-- with named fields) or @NormalC@ with zero positional arguments+-- (the no-field singleton case).+validateCtors :: Name -> [Con] -> Q ()+validateCtors tyName ctors = case ctors of+  [RecC _ _] -> pure ()+  [NormalC _ []] -> pure ()+  [NormalC _ _] ->+    fail $+      "deriveRegFileCodec: "+        <> show tyName+        <> " has a positional (non-record-syntax) "+        <> "constructor; switch to record syntax so "+        <> "field names are available as slot symbols, "+        <> "e.g. `data Foo = Foo { x :: Int }`."+  [] ->+    fail $+      "deriveRegFileCodec: "+        <> show tyName+        <> " has no constructors; pass a record type."+  (_ : _ : _) ->+    fail $+      "deriveRegFileCodec: "+        <> show tyName+        <> " has multiple constructors; a single slot "+        <> "list cannot represent a sum type."+  _ ->+    fail $+      "deriveRegFileCodec: "+        <> show tyName+        <> " has an unsupported constructor shape "+        <> "(infix or GADT); pass a plain record type."
+ test/Keiki/Codec/JSON/Fixtures.hs view
@@ -0,0 +1,188 @@+{-# LANGUAGE DeriveAnyClass #-}++-- | Shared fixtures for the property, sensitivity, and golden hash+-- specs. Defines an exemplar slot list (used by every spec as the+-- baseline), schema-evolution mutations of it, and an inductive+-- 'Arbitrary' generator for 'RegFile rs'.+module Keiki.Codec.JSON.Fixtures+  ( -- * Baseline slot list+    ExemplarSlots,++    -- * Schema-evolution mutations (EP-36 §4 cases #1–9)+    AddSlots,+    RemoveSlots,+    RenameSlots,+    ReorderSlots,+    TypeChangeSameJsonSlots,+    NewtypeWrapSlots,+    RecordReplaceSlots,+    SplitSlots,+    RenamedTypeSlots,++    -- * Auxiliary types for the mutations+    OrderId (..),+    Address (..),+    RenamedAddress (..),++    -- * Arbitrary generator+    ArbitraryRegFile (..),+  )+where++import Data.Aeson qualified as Aeson+import Data.Proxy (Proxy (..))+import Data.Text (Text)+import Data.Time.Clock (UTCTime)+import Data.Word (Word32)+import GHC.Generics (Generic)+import GHC.TypeLits (KnownSymbol)+-- Arbitrary UTCTime, Text, etc.++import Keiki.Core (RegFile (..), Slot)+import Keiki.Shape (CanonicalTypeName)+import Test.QuickCheck (Arbitrary (..), Gen)+import Test.QuickCheck.Instances ()++-- * Baseline slot list -------------------------------------------------------++-- | Exemplar three-slot list used by every spec as the baseline against+-- which mutations are compared.+type ExemplarSlots =+  '[ '("retryCount", Int),+     '("cooldownUntil", UTCTime),+     '("correlationId", Text)+   ]++-- * Schema-evolution mutations (EP-36 §4) ------------------------------------++-- | §4 case #1 — Add slot. A fourth slot is appended to the baseline.+type AddSlots =+  '[ '("retryCount", Int),+     '("cooldownUntil", UTCTime),+     '("correlationId", Text),+     '("dispatchedAt", UTCTime)+   ]++-- | §4 case #2 — Remove slot. The baseline minus @correlationId@.+type RemoveSlots =+  '[ '("retryCount", Int),+     '("cooldownUntil", UTCTime)+   ]++-- | §4 case #3 — Rename slot. @cooldownUntil@ becomes @retryAfter@.+type RenameSlots =+  '[ '("retryCount", Int),+     '("retryAfter", UTCTime),+     '("correlationId", Text)+   ]++-- | §4 case #4 — Reorder slots. @cooldownUntil@ and @retryCount@ swap.+type ReorderSlots =+  '[ '("cooldownUntil", UTCTime),+     '("retryCount", Int),+     '("correlationId", Text)+   ]++-- | §4 case #5 — Slot type change, same JSON. @retryCount@ moves from+-- 'Int' to 'Word32' (both encode identically on positive integers, but+-- the TypeRep differs).+type TypeChangeSameJsonSlots =+  '[ '("retryCount", Word32),+     '("cooldownUntil", UTCTime),+     '("correlationId", Text)+   ]++-- | §4 case #6 — Newtype wrap. @correlationId :: Text@ becomes+-- @correlationId :: OrderId@ where 'OrderId' is a 'Text' newtype with+-- @deriving newtype@ JSON instances. Wire-compatible, but the type's+-- name differs (@OrderId@ vs @Text@) so the hash changes.+type NewtypeWrapSlots =+  '[ '("retryCount", Int),+     '("cooldownUntil", UTCTime),+     '("correlationId", OrderId)+   ]++-- | §4 case #7 — Replace primitive with record. @correlationId :: Text@+-- becomes @correlationId :: Address@.+type RecordReplaceSlots =+  '[ '("retryCount", Int),+     '("cooldownUntil", UTCTime),+     '("correlationId", Address)+   ]++-- | §4 case #8 — Split slot. @correlationId :: Text@ is split into two+-- slots @correlationStream :: Text@ + @correlationId :: Text@.+type SplitSlots =+  '[ '("retryCount", Int),+     '("cooldownUntil", UTCTime),+     '("correlationStream", Text),+     '("correlationId", Text)+   ]++-- | §4 case #9 — Slot type's internal record changes. To exercise the+-- hash's sensitivity here we use a /distinctly-named/ second type+-- 'RenamedAddress' in place of 'Address'. (Two definitions of @data+-- Address@ with different fields cannot coexist in one Haskell+-- module; for the hash to discriminate them they must differ in+-- @tyConModule + tyConName@. The §4 row notes "Maybe (TypeRep)" — the+-- hash flips when the user actually renames the type to signal the+-- breaking change, which is the disciplined practice.)+type RenamedTypeSlots =+  '[ '("retryCount", Int),+     '("cooldownUntil", UTCTime),+     '("correlationId", RenamedAddress)+   ]++-- * Auxiliary types used by the mutations ------------------------------------++newtype OrderId = OrderId Text+  deriving stock (Eq, Show, Generic)+  deriving newtype (Aeson.ToJSON, Aeson.FromJSON, Arbitrary)+  deriving anyclass (CanonicalTypeName)++data Address = Address+  { line :: Text,+    city :: Text,+    postcode :: Text+  }+  deriving stock (Eq, Show, Generic)+  deriving anyclass (Aeson.ToJSON, Aeson.FromJSON, CanonicalTypeName)++instance Arbitrary Address where+  arbitrary = Address <$> arbitrary <*> arbitrary <*> arbitrary++-- | A distinctly-named near-copy of 'Address' used by 'RenamedTypeSlots'+-- to demonstrate the §4 case #9 detection path (rename-on-breaking-+-- change). Adds a @country@ field to also illustrate the "Address adds+-- country field" example from §4.+data RenamedAddress = RenamedAddress+  { line :: Text,+    city :: Text,+    postcode :: Text,+    country :: Text+  }+  deriving stock (Eq, Show, Generic)+  deriving anyclass (Aeson.ToJSON, Aeson.FromJSON, CanonicalTypeName)++instance Arbitrary RenamedAddress where+  arbitrary =+    RenamedAddress <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary++-- * Arbitrary generator for 'RegFile rs' -------------------------------------++-- | Inductive generator over the slot list. Each slot value comes from+-- the slot type's 'Arbitrary' instance.+class ArbitraryRegFile (rs :: [Slot]) where+  arbRegFile :: Gen (RegFile rs)++instance ArbitraryRegFile '[] where+  arbRegFile = pure RNil++instance+  ( KnownSymbol s,+    Arbitrary t,+    ArbitraryRegFile rs+  ) =>+  ArbitraryRegFile ('(s, t) ': rs)+  where+  arbRegFile = RCons (Proxy @s) <$> arbitrary <*> arbRegFile
+ test/Keiki/Codec/JSON/GoldenSpec.hs view
@@ -0,0 +1,24 @@+-- | 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.+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 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" $+    regFileShapeHash (Proxy @ExemplarSlots)+      `shouldBe` T.pack "a37b2b77042a635f394a082765f3410ea23a0b89745b0c77242b925a03aa172b"
+ test/Keiki/Codec/JSON/PropSpec.hs view
@@ -0,0 +1,80 @@+-- | EP-36 M3 property tests: roundtrip on both encoder paths plus+-- within-path encoding determinism (R9).+--+-- The Value path and Encoding path are exercised independently. Cross-+-- path byte equality is /not/ asserted because aeson 2.2's+-- @Aeson.Value@ Object iterates 'Aeson.KeyMap' in (alphabetical)+-- KeyMap order, while the Encoding path emits in slot-list order via+-- @Aeson.Series@. See EP-36 Surprises & Discoveries for the+-- 2026-05-13 M2 entry documenting this.+module Keiki.Codec.JSON.PropSpec (spec) where++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 Keiki.Core (RegFile)+import Test.Hspec (Spec, describe, it)+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 = show . regFileToJSON++spec :: Spec+spec = do+  describe "Roundtrip" $ do+    it "Value path round-trips" $+      forAllShow arbRegFile showRegFile valueRoundTrip+    it "Encoding path round-trips" $+      forAllShow arbRegFile showRegFile encodingRoundTrip++  describe "Determinism (R9 within-path)" $ do+    it "Value path is deterministic" $+      forAllShow arbRegFile showRegFile valueDeterministic+    it "Encoding path is deterministic" $+      forAllShow arbRegFile 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+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+          Left msg ->+            False === error ("regFileFromJSON failed: " <> msg)+          Right rf' ->+            Aeson.encode (regFileToJSON rf') === bytes++-- | Encoding path round-trip via+-- @regFileFromJSON . fromJust . Aeson.decode . AesonEnc.encodingToLazyByteString . regFileToEncoding@.+encodingRoundTrip :: RegFile ExemplarSlots -> 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+          Left msg ->+            False === error ("regFileFromJSON failed: " <> msg)+          Right rf' ->+            AesonEnc.encodingToLazyByteString (regFileToEncoding rf') === bytes++-- | Re-encoding the same RegFile via the Value path produces byte-+-- equal output.+valueDeterministic :: RegFile ExemplarSlots -> 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 rf =+  AesonEnc.encodingToLazyByteString (regFileToEncoding rf)+    === AesonEnc.encodingToLazyByteString (regFileToEncoding rf)
+ test/Keiki/Codec/JSON/SensitivitySpec.hs view
@@ -0,0 +1,55 @@+-- | EP-36 M3 sensitivity tests (P7.4).+--+-- The baseline 'ExemplarSlots' shape hash is computed once, and each+-- mutation from EP-36 §4 (cases #1–9) is asserted to produce a /different/+-- hash. This is the discrimination side of the shape hash contract: any+-- structural change in the slot list must flip the hash.+module Keiki.Codec.JSON.SensitivitySpec (spec) where++import Data.Proxy (Proxy (..))+import Keiki.Codec.JSON.Fixtures+  ( AddSlots,+    ExemplarSlots,+    NewtypeWrapSlots,+    RecordReplaceSlots,+    RemoveSlots,+    RenameSlots,+    RenamedTypeSlots,+    ReorderSlots,+    SplitSlots,+    TypeChangeSameJsonSlots,+  )+import Keiki.Shape (regFileShapeHash)+import Test.Hspec (Spec, describe, it, shouldSatisfy)++spec :: Spec+spec = describe "Sensitivity (EP-36 §4 cases #1–9)" $ do+  let baseline = regFileShapeHash (Proxy @ExemplarSlots)++  it "#1 add slot flips the hash" $+    regFileShapeHash (Proxy @AddSlots) `shouldSatisfy` (/= baseline)++  it "#2 remove slot flips the hash" $+    regFileShapeHash (Proxy @RemoveSlots) `shouldSatisfy` (/= baseline)++  it "#3 rename slot flips the hash" $+    regFileShapeHash (Proxy @RenameSlots) `shouldSatisfy` (/= baseline)++  it "#4 reorder slots flips the hash (P10)" $+    regFileShapeHash (Proxy @ReorderSlots) `shouldSatisfy` (/= baseline)++  it "#5 slot type change (Int → Word32) flips the hash" $+    regFileShapeHash (Proxy @TypeChangeSameJsonSlots)+      `shouldSatisfy` (/= baseline)++  it "#6 newtype wrap (Text → OrderId) flips the hash" $+    regFileShapeHash (Proxy @NewtypeWrapSlots) `shouldSatisfy` (/= baseline)++  it "#7 primitive → record (Text → Address) flips the hash" $+    regFileShapeHash (Proxy @RecordReplaceSlots) `shouldSatisfy` (/= baseline)++  it "#8 split slot flips the hash" $+    regFileShapeHash (Proxy @SplitSlots) `shouldSatisfy` (/= baseline)++  it "#9 type rename (Address → RenamedAddress) flips the hash" $+    regFileShapeHash (Proxy @RenamedTypeSlots) `shouldSatisfy` (/= baseline)
+ test/Keiki/Codec/JSON/THEventSpec.hs view
@@ -0,0 +1,174 @@+{-# LANGUAGE TemplateHaskell #-}++-- | EP-59 M3 tests for+-- 'Keiki.Codec.JSON.Event.deriveEventCodecSkeleton'.+--+-- The splice derives a @kind@-discriminated encoder/decoder for an event+-- /sum/ type. This spec exercises round-trip, the @kind@ discriminator,+-- that a per-field override actually runs (rather than a generic instance),+-- and the constructor-name list, against a 3-constructor fixture: one+-- payload event with an overridden newtype field, one all-passthrough+-- payload event, and one singleton.+--+-- == Negative checks (manual)+--+-- The no-silent-fallback contract is that an /unhandled/ field (one in+-- neither 'fieldCodecOverrides' nor 'passthroughFields') is never silently+-- given a generic codec. There are two behaviours, neither expressible as a+-- passing unit test:+--+-- 1. @onMissingCodec = FailAtCompileTime@ (the default). Add a field to a+--    payload record without listing it, e.g. give @PlacedData@ a+--    @discount :: Int@ field and do NOT add @"discount"@ to+--    'passthroughFields'. Run+--    @cabal build keiki-codec-json:keiki-codec-json-test@. The build must+--    fail with a message of the form:+--+--    @+--    deriveEventCodecSkeleton: Keiki.Codec.JSON.THEventSpec.OrderEvent has+--    field(s) with no provided codec and onMissingCodec = FailAtCompileTime:+--      - Placed.discount :: GHC.Types.Int+--    Add each field to fieldCodecOverrides or passthroughFields, or set+--    onMissingCodec = EmitTodoBindings.+--    @+--+--    (The type name and field type print fully qualified — @show@/@pprint@+--    of the reified names.) Revert the field.+--+-- 2. @onMissingCodec = EmitTodoBindings@. With the same unhandled field but+--    @onMissingCodec = EmitTodoBindings@ in the options, the build SUCCEEDS+--    and a top-level binding @_todo_Placed_discount :: a@ is emitted whose+--    body is @error "TODO: provide a FieldCodec for Placed.discount :: Int"@.+--    Any actual encode/decode of that field throws the named error rather+--    than guessing. Verify by adding @discount@, switching to+--    @EmitTodoBindings@, and observing the module compiles; referencing+--    @_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++import Data.Aeson qualified as Aeson+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 (..),+    defaultEventCodecOptions,+    deriveEventCodecSkeleton,+  )+import Test.Hspec (Spec, describe, it, shouldBe)+import Text.Read (readMaybe)++-- * Fixtures -----------------------------------------------------------------++-- | A newtype field whose JSON form is overridden (a @"ord-<n>"@ string),+-- proving the override hook runs instead of a generic 'Int' encoding.+newtype OrderId = OrderId Int+  deriving stock (Eq, Show)++orderIdToJSON :: OrderId -> Aeson.Value+orderIdToJSON (OrderId n) = Aeson.toJSON (T.pack ("ord-" <> show n))++orderIdFromJSON :: Aeson.Value -> Either String OrderId+orderIdFromJSON v = case v of+  Aeson.String t+    | Just rest <- T.stripPrefix (T.pack "ord-") t,+      Just n <- readMaybe (T.unpack rest) ->+        Right (OrderId n)+  _ -> Left "orderIdFromJSON: expected an \"ord-<int>\" string"++data PlacedData = PlacedData+  { orderId :: OrderId,+    qty :: Int+  }+  deriving stock (Eq, Show)++data ShippedData = ShippedData+  { trackingNo :: Text+  }+  deriving stock (Eq, Show)++data OrderEvent+  = Placed PlacedData+  | Shipped ShippedData+  | Cancelled+  deriving stock (Eq, Show)++$( deriveEventCodecSkeleton+     defaultEventCodecOptions+       { fieldCodecOverrides =+           Map.fromList [("orderId", FieldCodec 'orderIdToJSON 'orderIdFromJSON)],+         passthroughFields = Set.fromList ["qty", "trackingNo"]+       }+     ''OrderEvent+ )++-- * Spec --------------------------------------------------------------------++spec :: Spec+spec = describe "deriveEventCodecSkeleton" $ do+  let placed = Placed (PlacedData (OrderId 7) 3)+      shipped = Shipped (ShippedData (T.pack "1Z999"))+      cancelled = Cancelled++  describe "round-trip (decode . encode == Right)" $ do+    it "Placed (payload with an overridden field) round-trips" $+      orderEventFromJSON (orderEventToJSON placed) `shouldBe` Right placed++    it "Shipped (all-passthrough payload) round-trips" $+      orderEventFromJSON (orderEventToJSON shipped) `shouldBe` Right shipped++    it "Cancelled (singleton) round-trips" $+      orderEventFromJSON (orderEventToJSON cancelled) `shouldBe` Right cancelled++  describe "kind discriminator + override usage" $ do+    -- orderEventToJSON (Placed (PlacedData (OrderId 7) 3))+    -- == {"kind":"Placed","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),+            "orderId" Aeson..= (T.pack "ord-7" :: Text),+            "qty" Aeson..= (3 :: Int)+          ]++    it "the orderId override ran (string \"ord-7\", not the integer 7)" $+      -- If a generic Int codec had been silently used, this would be 7.+      orderEventFromJSON+        ( Aeson.object+            [ "kind" Aeson..= (T.pack "Placed" :: Text),+              "orderId" Aeson..= (T.pack "ord-7" :: Text),+              "qty" Aeson..= (3 :: Int)+            ]+        )+        `shouldBe` Right placed++    it "Cancelled encodes to just the kind object" $+      orderEventToJSON cancelled+        `shouldBe` Aeson.object ["kind" Aeson..= (T.pack "Cancelled" :: Text)]++  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)++    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" $+      orderEventEventTypes+        `shouldBe` map T.pack ["Placed", "Shipped", "Cancelled"]++    it "KindMap pairs each constructor name with its kind string" $+      orderEventKindMap+        `shouldBe` [ (T.pack "Placed", T.pack "Placed"),+                     (T.pack "Shipped", T.pack "Shipped"),+                     (T.pack "Cancelled", T.pack "Cancelled")+                   ]
+ test/Keiki/Codec/JSON/THSpec.hs view
@@ -0,0 +1,123 @@+{-# LANGUAGE TemplateHaskell #-}++-- | EP-38 M3 tests for 'Keiki.Codec.JSON.TH.deriveRegFileCodec'.+--+-- The splice emits three top-level functions per record type. This spec+-- exercises the round-trip and strict-decoder properties of the emitted+-- functions against two test records — a non-trivial @TestRec@ and the+-- @Empty@ singleton — and verifies the encoding-path / value-path+-- semantic-round-trip agreement (the same invariant the in-tree M2 and+-- M3 specs exercise for the underlying class).+--+-- == Negative-test procedure (manual)+--+-- The splice's contract is that a record whose field type lacks+-- 'Aeson.ToJSON' or 'Aeson.FromJSON' fails to compile. This is not+-- expressible in a passing unit test, so the procedure is manual:+--+-- 1. Edit @TestRec@ below to add a field @trBad :: Int -> Int@.+-- 2. Run @cabal build keiki-codec-json:keiki-codec-json-test@.+-- 3. The build must fail with @No instance for 'Aeson.ToJSON' (Int -> Int)@+--    pointing at the use site of @testRecToJSON@.+-- 4. Revert. The expected error proves the splice's elaboration trips+--    the missing-instance check; an automated should-not-compile test+--    is out of scope for v0.2.+module Keiki.Codec.JSON.THSpec (spec) where++import Data.Aeson qualified as Aeson+import Data.Aeson.Encoding qualified as AesonEnc+import Data.Maybe (fromJust)+import Data.Text (Text)+import Data.Text qualified as T+import GHC.Generics (Generic)+import Keiki.Codec.JSON.TH (deriveRegFileCodec)+import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)++-- * Test fixtures -----------------------------------------------------------++-- | Non-trivial two-field record used to exercise round-trip and strict+-- decoder behaviour.+data TestRec = TestRec+  { trCount :: Int,+    trNote :: Text+  }+  deriving stock (Eq, Show, Generic)++$(deriveRegFileCodec ''TestRec)++-- | Singleton record used to exercise the empty-slot-list edge case.+data Empty = Empty+  deriving stock (Eq, Show, Generic)++$(deriveRegFileCodec ''Empty)++-- * Spec --------------------------------------------------------------------++spec :: Spec+spec = describe "deriveRegFileCodec" $ do+  let tr = TestRec {trCount = 7, trNote = T.pack "hi"}++  describe "TestRec — round-trip and encoding agreement" $ do+    it "Value path round-trips" $+      testRecFromJSON (testRecToJSON tr) `shouldBe` Right tr++    it "Encoding path bytes parse back to the same value" $ do+      let bytes = AesonEnc.encodingToLazyByteString (testRecToEncoding tr)+          v = fromJust (Aeson.decode bytes :: Maybe Aeson.Value)+      testRecFromJSON v `shouldBe` Right tr++    it "Value path emits both slots in slot-list order" $+      testRecToJSON tr+        `shouldBe` Aeson.object+          [ "trCount" Aeson..= (7 :: Int),+            "trNote" Aeson..= T.pack "hi"+          ]++  describe "TestRec — strict decoder" $ do+    it "rejects an Object missing trCount with a slot-prefixed message" $ do+      let v = Aeson.object ["trNote" Aeson..= T.pack "hi"]+      testRecFromJSON v `shouldBe` Left "trCount: missing slot"++    it "rejects an Object with an unknown extra field" $ do+      let v =+            Aeson.object+              [ "trCount" Aeson..= (7 :: Int),+                "trNote" Aeson..= T.pack "hi",+                "bogus" Aeson..= (1 :: Int)+              ]+      testRecFromJSON v `shouldSatisfy` isExtraFieldsLeft++    it "rejects a type-mismatched field with a slot-prefixed message" $ do+      let v =+            Aeson.object+              [ "trCount" Aeson..= T.pack "seven",+                "trNote" Aeson..= T.pack "hi"+              ]+      testRecFromJSON v `shouldSatisfy` hasPrefix "trCount:"++  describe "Empty — empty-slot-list edge case" $ do+    it "encodes Empty to the empty JSON object" $+      emptyToJSON Empty `shouldBe` Aeson.object []++    it "decodes the empty JSON object back to Empty" $+      emptyFromJSON (Aeson.object []) `shouldBe` Right Empty++    it "encodes Empty via the streaming path to the literal bytes {}" $+      AesonEnc.encodingToLazyByteString (emptyToEncoding Empty)+        `shouldBe` AesonEnc.encodingToLazyByteString (AesonEnc.pairs mempty)++    it "rejects a non-empty JSON object as unknown extra fields" $+      emptyFromJSON (Aeson.object ["x" Aeson..= (1 :: Int)])+        `shouldSatisfy` isExtraFieldsLeft++-- * Helpers -----------------------------------------------------------------++isExtraFieldsLeft :: Either String a -> Bool+isExtraFieldsLeft = \case+  Left msg -> T.pack "regfile: unknown extra fields:" `T.isPrefixOf` T.pack msg+  Right _ -> False++hasPrefix :: String -> Either String a -> Bool+hasPrefix p = \case+  Left msg -> T.pack p `T.isPrefixOf` T.pack msg+  Right _ -> False
+ test/Spec.hs view
@@ -0,0 +1,196 @@+-- | EP-36 M2 unit tests for the JSON codec.+--+-- Covers acceptance items from the milestone: empty RegFile encode /+-- decode on both paths (Value and Encoding); a single-slot list; a+-- multi-slot list with both encoders producing the same bytes after+-- @'Aeson.encode'@ / @'AesonEnc.encodingToLazyByteString'@; strict failure+-- on missing slot, extra slot, type mismatch.+module Main (main) where++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.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.GoldenSpec qualified+import Keiki.Codec.JSON.PropSpec qualified+import Keiki.Codec.JSON.SensitivitySpec qualified+import Keiki.Codec.JSON.THEventSpec qualified+import Keiki.Codec.JSON.THSpec qualified+import Keiki.Core (RegFile (..))+import Test.Hspec+  ( Spec,+    describe,+    hspec,+    it,+    shouldBe,+  )++main :: IO ()+main = hspec $ do+  spec+  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-38 deriveRegFileCodec" Keiki.Codec.JSON.THSpec.spec+  describe "EP-59 deriveEventCodecSkeleton" Keiki.Codec.JSON.THEventSpec.spec++spec :: Spec+spec = do+  describe "Empty RegFile" $ do+    it "encodes to {}" $+      regFileToJSON (RNil :: RegFile '[])+        `shouldBe` Aeson.object []++    it "encodes via streaming to the same bytes as the Value path" $+      AesonEnc.encodingToLazyByteString (regFileToEncoding (RNil :: RegFile '[]))+        `shouldBe` Aeson.encode (regFileToJSON (RNil :: RegFile '[]))++    it "decodes {} back to RNil" $ do+      let r = regFileFromJSON @'[] (Aeson.object [])+      case r of+        Right RNil -> (True :: Bool) `shouldBe` True+        Left msg -> error ("expected Right RNil, got Left " <> msg)++    it "rejects {\"x\": 1} as having unknown extra fields" $ do+      let r = regFileFromJSON @'[] (Aeson.object ["x" Aeson..= (1 :: Int)])+      isExtraFieldsLeft r `shouldBe` True++  describe "Single-slot RegFile '[ '(\"retryCount\", Int) ]" $ do+    let rf = RCons (Proxy @"retryCount") (3 :: Int) RNil :: RegFile '[ '("retryCount", Int)]++    it "encodes to {\"retryCount\": 3}" $+      regFileToJSON rf+        `shouldBe` Aeson.object ["retryCount" Aeson..= (3 :: Int)]++    it "encodes via streaming to byte-equal bytes" $+      AesonEnc.encodingToLazyByteString (regFileToEncoding rf)+        `shouldBe` Aeson.encode (regFileToJSON rf)++    it "round-trips through the Value path" $ do+      let r = regFileFromJSON @'[ '("retryCount", Int)] (regFileToJSON rf)+      case r of+        Right (RCons _ n RNil) -> n `shouldBe` 3+        Left msg -> error ("expected round-trip success, got Left " <> msg)++    it "round-trips through the Encoding path" $ do+      let bytes = AesonEnc.encodingToLazyByteString (regFileToEncoding rf)+          v = fromJust (Aeson.decode bytes :: Maybe Aeson.Value)+          r = regFileFromJSON @'[ '("retryCount", Int)] v+      case r of+        Right (RCons _ n RNil) -> n `shouldBe` 3+        Left msg -> error ("expected round-trip success, got Left " <> msg)++    it "rejects {} as missing retryCount" $ do+      let r = regFileFromJSON @'[ '("retryCount", Int)] (Aeson.object [])+      case r of+        Left msg -> msg `shouldBe` "retryCount: missing slot"+        Right _ -> error "expected Left, got Right"++    it "rejects {\"retryCount\": \"three\"} as type mismatch" $ do+      let r =+            regFileFromJSON @'[ '("retryCount", Int)]+              (Aeson.object ["retryCount" Aeson..= ("three" :: Text)])+      case r of+        Left msg+          | "retryCount:" `T.isPrefixOf` T.pack msg ->+              (True :: Bool) `shouldBe` True+        Left msg -> error ("expected slot-prefixed type error, got " <> msg)+        Right _ -> error "expected Left, got Right"++    it "rejects {\"retryCount\": 3, \"extra\": 1} as unknown extra fields" $ do+      let r =+            regFileFromJSON @'[ '("retryCount", Int)]+              ( Aeson.object+                  [ "retryCount" Aeson..= (3 :: Int),+                    "extra" Aeson..= (1 :: Int)+                  ]+              )+      isExtraFieldsLeft r `shouldBe` True++  describe "Multi-slot RegFile (Int + Text)" $ do+    let rf :: RegFile '[ '("retryCount", Int), '("note", Text)]+        rf =+          RCons (Proxy @"retryCount") (5 :: Int) $+            RCons (Proxy @"note") ("hello" :: Text) RNil++    it "encodes both slots in slot-list order via Value path" $+      regFileToJSON rf+        `shouldBe` Aeson.object+          [ "retryCount" Aeson..= (5 :: Int),+            "note" Aeson..= ("hello" :: Text)+          ]++    -- The Value path emits keys in 'Aeson.KeyMap' order (alphabetical for+    -- aeson 2.2's default backing 'Map Key'); the Encoding path emits in+    -- slot-list order. The two byte streams therefore differ when slot+    -- order is not alphabetical. What both paths guarantee is /semantic/+    -- round-trip equality plus within-path determinism — see the next+    -- two tests.+    it "Encoding path emits keys in slot-list order; Value path is sorted" $ do+      let viaStreaming = AesonEnc.encodingToLazyByteString (regFileToEncoding rf)+          viaValue = Aeson.encode (regFileToJSON rf)+      viaStreaming+        `shouldBe` LBS.pack+          ( map+              (fromIntegral . fromEnum)+              "{\"retryCount\":5,\"note\":\"hello\"}"+          )+      viaValue+        `shouldBe` LBS.pack+          ( map+              (fromIntegral . fromEnum)+              "{\"note\":\"hello\",\"retryCount\":5}"+          )++    it "both paths round-trip to the same RegFile (cross-path semantic equality)" $ do+      let valueBytes = Aeson.encode (regFileToJSON rf)+          streamBytes = AesonEnc.encodingToLazyByteString (regFileToEncoding rf)+          fromValue =+            regFileFromJSON @'[ '("retryCount", Int), '("note", Text)]+              =<< maybe (Left "decode failed") Right (Aeson.decode valueBytes)+          fromStream =+            regFileFromJSON @'[ '("retryCount", Int), '("note", Text)]+              =<< maybe (Left "decode failed") Right (Aeson.decode streamBytes)+      assertMultiRoundTrip fromValue+      assertMultiRoundTrip fromStream++    it "within-path determinism: re-encoding produces byte-equal output (R9)" $ do+      Aeson.encode (regFileToJSON rf)+        `shouldBe` Aeson.encode (regFileToJSON rf)+      AesonEnc.encodingToLazyByteString (regFileToEncoding rf)+        `shouldBe` AesonEnc.encodingToLazyByteString (regFileToEncoding rf)++    it "rejects {\"retryCount\": 5} as missing note" $ do+      let r =+            regFileFromJSON+              @'[ '("retryCount", Int), '("note", Text)]+              (Aeson.object ["retryCount" Aeson..= (5 :: Int)])+      case r of+        Left msg -> msg `shouldBe` "note: missing slot"+        Right _ -> error "expected Left, got Right"++isExtraFieldsLeft :: Either String a -> Bool+isExtraFieldsLeft = \case+  Left msg -> "regfile: unknown extra fields:" `T.isPrefixOf` T.pack msg+  Right _ -> False++assertMultiRoundTrip ::+  Either String (RegFile '[ '("retryCount", Int), '("note", Text)]) ->+  IO ()+assertMultiRoundTrip = \case+  Right (RCons _ n (RCons _ t RNil)) -> do+    n `shouldBe` 5+    t `shouldBe` ("hello" :: Text)+  Left msg -> error ("expected round-trip success, got Left " <> msg)++-- Silence GHC's unused-import warnings: Symbol/Type are referenced by+-- the type-level slot lists above, but GHC's warning pass doesn't see+-- the kind annotations as references.+_unused :: (Proxy (rs :: [(Symbol, Type)]), ())+_unused = (Proxy, ())